Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions appstoreserverlibrary/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,12 +671,14 @@ class APIException(Exception):
api_error: Optional[APIError]
raw_api_error: Optional[int]
error_message: Optional[str]
retry_after: Optional[int]

def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None, error_message: Optional[str] = None):
def __init__(self, http_status_code: int, raw_api_error: Optional[int] = None, error_message: Optional[str] = None, retry_after: Optional[int] = None):
self.http_status_code = http_status_code
self.raw_api_error = raw_api_error
self.api_error = None
self.error_message = error_message
self.retry_after = retry_after
try:
if raw_api_error is not None:
self.api_error = APIError(raw_api_error)
Expand Down Expand Up @@ -736,6 +738,15 @@ def _get_request_json(self, body) -> Dict[str, Any]:
c = _get_cattrs_converter(type(body)) if body is not None else None
return c.unstructure(body) if body is not None else None

def _get_retry_after(self, headers: MutableMapping) -> Optional[int]:
retry_after = headers.get("Retry-After")
if retry_after is None:
return None
try:
return int(retry_after)
except ValueError:
return None

def _parse_response(self, status_code: int, headers: MutableMapping, json_supplier, destination_class: Type[T]) -> T:
if 200 <= status_code < 300:
if destination_class is None:
Expand All @@ -745,15 +756,16 @@ def _parse_response(self, status_code: int, headers: MutableMapping, json_suppli
return c.structure(response_body, destination_class)
else:
# Best effort parsing of the response body
retry_after = self._get_retry_after(headers)
if not 'content-type' in headers or headers['content-type'] != 'application/json':
raise APIException(status_code)
raise APIException(status_code, retry_after=retry_after)
try:
response_body = json_supplier()
raise APIException(status_code, response_body['errorCode'], response_body['errorMessage'])
raise APIException(status_code, response_body['errorCode'], response_body['errorMessage'], retry_after)
except APIException as e:
raise e
except Exception as e:
raise APIException(status_code) from e
raise APIException(status_code, retry_after=retry_after) from e


class AppStoreServerAPIClient(BaseAppStoreServerAPIClient):
Expand Down
14 changes: 9 additions & 5 deletions tests/test_api_client.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copyright (c) 2023 Apple Inc. Licensed under MIT License.

from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, Union
import unittest

from requests import Response
Expand Down Expand Up @@ -435,14 +435,16 @@ def test_api_too_many_requests(self):
'https://local-testing-base-url/inApps/v1/notifications/test',
{},
None,
429)
429,
response_headers={'Retry-After': '1698148904000'})
try:
client.request_test_notification()
except APIException as e:
self.assertEqual(429, e.http_status_code)
self.assertEqual(4290000, e.raw_api_error)
self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error)
self.assertEqual("Rate limit exceeded.", e.error_message)
self.assertEqual(1698148904000, e.retry_after)
return

self.assertFalse(True)
Expand Down Expand Up @@ -870,7 +872,7 @@ def test_finish_transaction(self):
def get_signing_key(self):
return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8')

def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, expected_data: bytes = None, expected_content_type: str = None):
def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, expected_data: bytes = None, expected_content_type: str = None, response_headers: Optional[Dict[str, str]] = None):
signing_key = self.get_signing_key()
client = AppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING)
def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any], data: bytes):
Expand Down Expand Up @@ -900,11 +902,13 @@ def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str,
response.status_code = status_code
response.raw = BytesIO(body)
response.headers['Content-Type'] = 'application/json'
if response_headers is not None:
response.headers.update(response_headers)
return response

client._execute_request = fake_execute_and_validate_inputs
return client

def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200):
def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, response_headers: Optional[Dict[str, str]] = None):
body = read_data_from_binary_file(path)
return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code)
return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code, response_headers=response_headers)
17 changes: 11 additions & 6 deletions tests/test_api_client_async.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Copyright (c) 2023 Apple Inc. Licensed under MIT License.

from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, Union
import unittest

from httpx import Response
Expand Down Expand Up @@ -440,14 +440,16 @@ async def test_api_too_many_requests(self):
'https://local-testing-base-url/inApps/v1/notifications/test',
{},
None,
429)
429,
response_headers={'Retry-After': '1698148904000'})
try:
await client.request_test_notification()
except APIException as e:
self.assertEqual(429, e.http_status_code)
self.assertEqual(4290000, e.raw_api_error)
self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error)
self.assertEqual("Rate limit exceeded.", e.error_message)
self.assertEqual(1698148904000, e.retry_after)
return

self.assertFalse(True)
Expand Down Expand Up @@ -874,7 +876,7 @@ async def test_finish_transaction(self):
def get_signing_key(self):
return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8')

def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, expected_data: bytes = None, expected_content_type: str = None):
def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, expected_data: bytes = None, expected_content_type: str = None, response_headers: Optional[Dict[str, str]] = None):
signing_key = self.get_signing_key()
client = AsyncAppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING)
async def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any], data: bytes):
Expand All @@ -900,12 +902,15 @@ async def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict
self.assertEqual(['User-Agent', 'Authorization', 'Accept'], list(headers.keys()))
self.assertEqual(expected_json, json)

response = Response(status_code, headers={'Content-Type': 'application/json'}, content=body)
headers = {'Content-Type': 'application/json'}
if response_headers is not None:
headers.update(response_headers)
response = Response(status_code, headers=headers, content=body)
return response

client._execute_request = fake_execute_and_validate_inputs
return client

def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200):
def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200, response_headers: Optional[Dict[str, str]] = None):
body = read_data_from_binary_file(path)
return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code)
return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code, response_headers=response_headers)