diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 0309ec93..94f6e108 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -2,6 +2,7 @@ import calendar import datetime +import re import warnings from enum import IntEnum, Enum from typing import Any, Dict, List, MutableMapping, Optional, Type, TypeVar, Union @@ -671,18 +672,43 @@ class APIException(Exception): api_error: Optional[APIError] raw_api_error: Optional[int] error_message: Optional[str] + headers: Dict[str, List[str]] + """ + The response headers, keyed by lowercased header name. + """ + + retry_after: Optional[int] + """ + A UNIX time, in milliseconds, that informs you when you can next send a request. + + https://developer.apple.com/documentation/appstoreserverapi/identifying-rate-limits + """ - 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, headers: Optional[Dict[str, List[str]]] = 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.headers = headers if headers is not None else {} + self.retry_after = _parse_retry_after(self.headers.get('retry-after')) try: if raw_api_error is not None: self.api_error = APIError(raw_api_error) except ValueError: pass + +_RETRY_AFTER_PATTERN = re.compile(r'[0-9]+') + + +def _parse_retry_after(retry_after_values: Optional[List[str]]) -> Optional[int]: + if not retry_after_values: + return None + stripped_retry_after = retry_after_values[0].strip() + if _RETRY_AFTER_PATTERN.fullmatch(stripped_retry_after) is None: + return None + return int(stripped_retry_after) + class GetTransactionHistoryVersion(str, Enum): V1 = "v1" """ @@ -736,6 +762,12 @@ 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 _normalize_headers(self, headers: MutableMapping) -> Dict[str, List[str]]: + normalized_headers: Dict[str, List[str]] = {} + for name, value in headers.items(): + normalized_headers.setdefault(name.lower(), []).append(value) + return normalized_headers + 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: @@ -744,16 +776,17 @@ def _parse_response(self, status_code: int, headers: MutableMapping, json_suppli response_body = json_supplier() return c.structure(response_body, destination_class) else: + normalized_headers = self._normalize_headers(headers) # Best effort parsing of the response body if not 'content-type' in headers or headers['content-type'] != 'application/json': - raise APIException(status_code) + raise APIException(status_code, headers=normalized_headers) 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'], headers=normalized_headers) except APIException as e: raise e except Exception as e: - raise APIException(status_code) from e + raise APIException(status_code, headers=normalized_headers) from e class AppStoreServerAPIClient(BaseAppStoreServerAPIClient): @@ -1167,6 +1200,12 @@ def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: s async def async_close(self): await self.http_client.aclose() + + def _normalize_headers(self, headers: MutableMapping) -> Dict[str, List[str]]: + normalized_headers: Dict[str, List[str]] = {} + for name, value in headers.multi_items(): + normalized_headers.setdefault(name.lower(), []).append(value) + return normalized_headers async def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T], content_type: Optional[str] = None) -> T: url = self._get_full_url(path) diff --git a/tests/test_api_client.py b/tests/test_api_client.py index f3f9061e..ba0fee29 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -443,10 +443,47 @@ def test_api_too_many_requests(self): 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.assertIsNone(e.retry_after) return - + self.assertFalse(True) + def test_api_too_many_requests_with_retry_after(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 429, + response_headers={'Retry-After': '1698148900000'}) + try: + client.request_test_notification() + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error) + self.assertEqual(1698148900000, e.retry_after) + self.assertEqual(['1698148900000'], e.headers['retry-after']) + return + + self.assertFalse(True) + + def test_api_too_many_requests_with_malformed_retry_after(self): + for raw_retry_after in ['', ' ', 'not-a-number', '1698148900000.0', '+1698148900000', '-1698148900000', '1_698_148_900_000', '1698148900000abc', 'Wed, 21 Oct 2015 07:28:00 GMT']: + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 429, + response_headers={'Retry-After': raw_retry_after}) + try: + client.request_test_notification() + self.assertFalse(True) + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertIsNone(e.retry_after) + self.assertEqual([raw_retry_after], e.headers['retry-after']) + def test_unknown_error(self): client = self.get_client_with_body_from_file('tests/resources/models/apiUnknownError.json', 'POST', @@ -870,7 +907,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: 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): @@ -900,11 +937,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: 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) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 8b837261..7d571682 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -448,10 +448,47 @@ async def test_api_too_many_requests(self): 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.assertIsNone(e.retry_after) return - + self.assertFalse(True) + async def test_api_too_many_requests_with_retry_after(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 429, + response_headers={'Retry-After': '1698148900000'}) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error) + self.assertEqual(1698148900000, e.retry_after) + self.assertEqual(['1698148900000'], e.headers['retry-after']) + return + + self.assertFalse(True) + + async def test_api_too_many_requests_with_malformed_retry_after(self): + for raw_retry_after in ['', ' ', 'not-a-number', '1698148900000.0', '+1698148900000', '-1698148900000', '1_698_148_900_000', '1698148900000abc', 'Wed, 21 Oct 2015 07:28:00 GMT']: + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 429, + response_headers={'Retry-After': raw_retry_after}) + try: + await client.request_test_notification() + self.assertFalse(True) + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertIsNone(e.retry_after) + self.assertEqual([raw_retry_after], e.headers['retry-after']) + async def test_unknown_error(self): client = self.get_client_with_body_from_file('tests/resources/models/apiUnknownError.json', 'POST', @@ -874,7 +911,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: 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): @@ -900,12 +937,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) + response_header_values = {'Content-Type': 'application/json'} + if response_headers is not None: + response_header_values.update(response_headers) + response = Response(status_code, headers=response_header_values, 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: 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)