From 406a74ba8a5d27b76f63f07455d4f31c470df017 Mon Sep 17 00:00:00 2001 From: Ohad Benita Date: Wed, 15 Jul 2026 13:24:18 +0300 Subject: [PATCH] Expose Retry-After on API exceptions --- appstoreserverlibrary/api_client.py | 20 ++++++++++++++++---- tests/test_api_client.py | 14 +++++++++----- tests/test_api_client_async.py | 17 +++++++++++------ 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 0309ec93..f60f764c 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -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) @@ -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: @@ -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): diff --git a/tests/test_api_client.py b/tests/test_api_client.py index f3f9061e..93a84f37 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -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 @@ -435,7 +435,8 @@ 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: @@ -443,6 +444,7 @@ 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.assertEqual(1698148904000, e.retry_after) return self.assertFalse(True) @@ -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): @@ -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) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 8b837261..0d6a8847 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -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 @@ -440,7 +440,8 @@ 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: @@ -448,6 +449,7 @@ 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.assertEqual(1698148904000, e.retry_after) return self.assertFalse(True) @@ -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): @@ -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)