Skip to content
Merged
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
47 changes: 43 additions & 4 deletions appstoreserverlibrary/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
"""
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 43 additions & 4 deletions tests/test_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
50 changes: 45 additions & 5 deletions tests/test_api_client_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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):
Expand All @@ -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)