From f2ef19d216f1ab23240aa3e46d5106515eacb747 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 10:29:22 +0000 Subject: [PATCH] fix: close the gaps found reviewing the clients Both clients drifted apart on the auth endpoint, the errors they raise and the values they accept, so this brings them back together and covers the cases that failed silently. - The async client rejected a version out of its list even when a custom auth_endpoint was given, which is what that parameter is for. Both clients now resolve the endpoint with the same list, so a version only has to be added once and the SDK list is no longer a second source of truth. - A token response that is not JSON escaped from the async client as the error of the JSON parser, while the sync one already reported it as an AuthenticationError. - Errors reported by the transport, such as an invalid certificate, arrive without a body and were reported as "Request failed with status 0", dropping the only useful part of the failure. - An ASIN was matched without a boundary, so a longer identifier in a URL was trimmed to ten characters and a different item was requested. - get_items only checked the items returned by Amazon, not the ones that matched the request, so a response holding other items returned an empty list instead of raising. - throttling was the only option without validation, accepting a negative value and failing with a TypeError for anything that is not a number. - The token manager of the sync client had no lock, so every thread asked for its own token as soon as the cached one expired. - Retry-After is also read when Amazon sends it as a date. - AmazonCreatorsApi can be closed, releasing the pool of connections that it keeps open, which only the async client could do. - mypy was configured for a version it no longer supports, so the whole check was running against its default instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KN1v6C671EcHw5Vur9rC3y --- CHANGELOG.md | 10 ++ README.md | 11 +- amazon_creatorsapi/aio/api.py | 53 ++++---- amazon_creatorsapi/aio/auth.py | 82 +++++++----- amazon_creatorsapi/api.py | 75 +++++++++-- amazon_creatorsapi/core/auth.py | 31 ++++- amazon_creatorsapi/core/error_handling.py | 11 +- amazon_creatorsapi/core/oauth.py | 77 +++++++++++ amazon_creatorsapi/core/parsers.py | 9 +- amazon_creatorsapi/core/retry.py | 30 ++++- amazon_creatorsapi/core/validation.py | 45 ++++++- pyproject.toml | 4 +- tests/amazon_creatorsapi/aio/api_test.py | 113 +++++++++++++++- tests/amazon_creatorsapi/aio/auth_test.py | 23 ++++ tests/amazon_creatorsapi/api_test.py | 124 +++++++++++++++++- tests/amazon_creatorsapi/core/auth_test.py | 44 +++++++ .../core/error_handling_test.py | 23 ++++ tests/amazon_creatorsapi/core/oauth_test.py | 71 ++++++++++ tests/amazon_creatorsapi/core/retry_test.py | 36 ++++- .../core/validation_test.py | 33 +++++ tests/amazon_creatorsapi/tools_test.py | 6 + 21 files changed, 806 insertions(+), 105 deletions(-) create mode 100644 amazon_creatorsapi/core/oauth.py create mode 100644 tests/amazon_creatorsapi/core/oauth_test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b40477b..a4a707a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `get_asin` and `errors` are available directly in `amazon_creatorsapi` - The identifier that Amazon gives to a request is part of the message of the error, so it can be reported to Amazon support - `py.typed` marker, so the type hints of the package are used by type checkers +- `close` method and context manager support in `AmazonCreatorsApi`, to release the connections of a client that is not going to be reused ### Changed @@ -21,11 +22,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `AsyncAmazonCreatorsApi` builds its requests with the models of the SDK, so both clients validate the same values before sending a request - Every client uses its own configuration for the SDK instead of the one shared by the whole process - Throttling is measured with a monotonic clock and is safe to use from several threads +- `throttling` is validated like the rest of the options, so a negative or invalid value raises `InvalidArgumentError` instead of being accepted or failing with a `TypeError` +- `Retry-After` is also honoured when Amazon sends it as a date instead of an amount of seconds +- Both clients resolve the auth endpoint with the same list of versions, so a new version only has to be added once ### Fixed - Examples in the documentation that used names that do not exist, such as `SortBy.PRICE_LOW_TO_HIGH` or `GetItemsResource.ITEMINFO_TITLE` - Documented limits of `item_count`, `min_reviews_rating` and `variation_page`, which did not match the ones accepted by the API +- `auth_endpoint` is enough to use a version that the library does not know about in `AsyncAmazonCreatorsApi`, which rejected it even with a custom endpoint +- A token response that does not hold JSON raises `AuthenticationError` in `AsyncAmazonCreatorsApi`, instead of the error of the JSON parser +- Errors reported by the transport, such as an invalid certificate, keep their reason instead of being reported as `Request failed with status 0` +- An ASIN longer than ten characters in a URL is rejected instead of being trimmed to a different item +- `get_items` raises `ItemsNotFoundError` when the response holds no requested item, instead of returning an empty list +- Threads sharing a client ask for a single token when the cached one expires, instead of one for every thread ### Removed diff --git a/README.md b/README.md index a708a2e..50f19f3 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,15 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=4) # M amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=0) # No wait time between requests ``` +### Closing the client + +The client keeps a pool of connections open, so it is meant to be created once and reused. Close it, or use it as a context manager, when it is not going to be used again: + +```python +with AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY) as amazon: + items = amazon.get_items(["B01N5IB20Q"]) +``` + ### Timeout Timeout value represents the number of seconds to wait for a response before failing, being the default value 30 seconds. Use `None` to wait indefinitely. @@ -196,7 +205,7 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=0) # Fail ### Custom Endpoints -The base URL of the API and the one used to get the OAuth2 token can be replaced, which is useful to run the tests of a project against a mock server: +The base URL of the API and the one used to get the OAuth2 token can be replaced, which is useful to run the tests of a project against a mock server. Providing `auth_endpoint` also makes any `version` valid, so a new one can be used before the library knows about it: ```python api = AmazonCreatorsApi( diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 097dd9e..ce74ead 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -25,6 +25,7 @@ get_unique_items, sort_items, ) +from amazon_creatorsapi.core.oauth import get_auth_endpoint from amazon_creatorsapi.core.parsers import get_asin, get_items_ids from amazon_creatorsapi.core.requests import get_request_body from amazon_creatorsapi.core.resources import get_all_resources @@ -35,6 +36,7 @@ validate_and_get_marketplace, validate_retries, validate_search_criteria, + validate_throttling, validate_timeout, ) from amazon_creatorsapi.errors import ( @@ -48,7 +50,7 @@ try: import httpx - from .auth import VERSION_ENDPOINTS, AsyncOAuth2TokenManager + from .auth import AsyncOAuth2TokenManager from .client import AsyncHttpClient, AsyncHttpResponse except ImportError as exc: # pragma: no cover msg = ( @@ -176,9 +178,10 @@ class AsyncAmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided, - if timeout is not greater than zero, or if retries is negative. - ValueError: If version is not supported (valid versions: 2.1, 2.2, 2.3, - 3.1, 3.2, 3.3). + if timeout is not greater than zero, if throttling is negative or + if retries is negative. + ValueError: If the version is not supported and no auth_endpoint is + given (valid versions: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3). """ @@ -197,19 +200,20 @@ def __init__( auth_endpoint: str | None = None, ) -> None: """Initialize the async Amazon Creators API client.""" - # Validate version early to fail fast (before token manager initialization) - self._validate_version(version) + # Resolve the endpoint early to fail fast on an unsupported version, + # which a custom endpoint makes valid + endpoint = get_auth_endpoint(version, auth_endpoint) self._credential_id = credential_id self._credential_secret = credential_secret self._version = version - self._last_query_time = time.monotonic() - throttling self.host = host self._throttle_lock: asyncio.Lock | None = None self.tag = tag - self.throttling = float(throttling) + self.throttling = validate_throttling(throttling) self.timeout = validate_timeout(timeout) self.retries = validate_retries(retries) + self._last_query_time = time.monotonic() - self.throttling # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) @@ -220,26 +224,11 @@ def __init__( credential_id=credential_id, credential_secret=credential_secret, version=version, - auth_endpoint=auth_endpoint, + auth_endpoint=endpoint, timeout=self.timeout, ) self._owns_client = False - def _validate_version(self, version: str) -> None: - """Validate that the API version is supported. - - Args: - version: API version to validate. - - Raises: - ValueError: If version is not in the list of supported versions. - - """ - if version not in VERSION_ENDPOINTS: - supported = ", ".join(VERSION_ENDPOINTS.keys()) - msg = f"Unsupported version: {version}. Supported versions are: {supported}" - raise ValueError(msg) - async def __aenter__(self) -> Self: """Enter async context manager, creating a persistent HTTP client.""" self._http_client = AsyncHttpClient(host=self.host, timeout=self.timeout) @@ -273,7 +262,8 @@ async def get_items( Duplicated items are requested only once, and the request is split into as many API calls as needed when it goes over the limit of items that - Amazon accepts at once. + Amazon accepts at once. A call that keeps failing after the retries + raises, discarding the items returned by the previous calls. Args: items: One or more items, using ASIN or Amazon product URL. @@ -329,14 +319,17 @@ async def get_items( if items_result.get("items"): found_items.extend(self._deserialize_items(items_result["items"])) - if not found_items and not include_unavailable: + sorted_items = sort_items( + found_items, + item_ids, + include_unavailable=include_unavailable, + ) + + if not sorted_items and not include_unavailable: msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return ResultList( - sort_items(found_items, item_ids, include_unavailable=include_unavailable), - errors=errors, - ) + return ResultList(sorted_items, errors=errors) async def search_items( self, diff --git a/amazon_creatorsapi/aio/auth.py b/amazon_creatorsapi/aio/auth.py index c7c6f39..a38da26 100644 --- a/amazon_creatorsapi/aio/auth.py +++ b/amazon_creatorsapi/aio/auth.py @@ -7,8 +7,20 @@ import asyncio import time - -from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT +from typing import Any + +from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT, HTTP_OK +from amazon_creatorsapi.core.oauth import ( + COGNITO_SCOPE, + DEFAULT_EXPIRATION, + GRANT_TYPE, + LWA_SCOPE, + TOKEN_EXPIRATION_BUFFER, + VERSION_ENDPOINTS, + get_auth_endpoint, + get_scope, + is_lwa, +) from amazon_creatorsapi.errors import AuthenticationError try: @@ -21,25 +33,18 @@ raise ImportError(msg) from exc -# OAuth2 constants -COGNITO_SCOPE = "creatorsapi/default" -LWA_SCOPE = "creatorsapi::default" # Backward-compatible alias for existing v2.x users. SCOPE = COGNITO_SCOPE -GRANT_TYPE = "client_credentials" - -# Token expiration buffer in seconds (refresh 30s before actual expiration) -TOKEN_EXPIRATION_BUFFER = 30 -# Version to auth endpoint mapping -VERSION_ENDPOINTS = { - "2.1": "https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token", - "2.2": "https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token", - "2.3": "https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token", - "3.1": "https://api.amazon.com/auth/o2/token", - "3.2": "https://api.amazon.co.uk/auth/o2/token", - "3.3": "https://api.amazon.co.jp/auth/o2/token", -} +__all__ = [ + "COGNITO_SCOPE", + "GRANT_TYPE", + "LWA_SCOPE", + "SCOPE", + "TOKEN_EXPIRATION_BUFFER", + "VERSION_ENDPOINTS", + "AsyncOAuth2TokenManager", +] class AsyncOAuth2TokenManager: @@ -98,23 +103,15 @@ def _determine_auth_endpoint( ValueError: If version is not supported and no custom endpoint provided. """ - if auth_endpoint and auth_endpoint.strip(): - return auth_endpoint - - if version not in VERSION_ENDPOINTS: - supported = ", ".join(VERSION_ENDPOINTS.keys()) - msg = f"Unsupported version: {version}. Supported versions are: {supported}" - raise ValueError(msg) - - return VERSION_ENDPOINTS[version] + return get_auth_endpoint(version, auth_endpoint) def is_lwa(self) -> bool: """Return whether this token manager uses the LWA auth flow.""" - return self._version.startswith("3.") + return is_lwa(self._version) def get_scope(self) -> str: """Return the version-appropriate OAuth2 scope.""" - return LWA_SCOPE if self.is_lwa() else COGNITO_SCOPE + return get_scope(self._version) @property def lock(self) -> asyncio.Lock: @@ -205,7 +202,7 @@ async def refresh_token(self) -> str: headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - if response.status_code != 200: # noqa: PLR2004 + if response.status_code != HTTP_OK: self.clear_token() msg = ( f"OAuth2 token request failed with status {response.status_code}: " @@ -213,7 +210,7 @@ async def refresh_token(self) -> str: ) raise AuthenticationError(msg) - data = response.json() + data = self._parse_token_response(response) if "access_token" not in data: self.clear_token() @@ -222,7 +219,7 @@ async def refresh_token(self) -> str: self._access_token = data["access_token"] # Set expiration time with buffer to avoid edge cases - expires_in = data.get("expires_in", 3600) + expires_in = data.get("expires_in", DEFAULT_EXPIRATION) self._expires_at = time.time() + expires_in - TOKEN_EXPIRATION_BUFFER except httpx.RequestError as exc: @@ -236,6 +233,27 @@ async def refresh_token(self) -> str: raise AuthenticationError(msg) return self._access_token + def _parse_token_response(self, response: httpx.Response) -> dict[str, Any]: + """Parse the token response as JSON. + + Args: + response: Response from the auth endpoint. + + Returns: + The parsed response body. + + Raises: + AuthenticationError: If the response is not valid JSON. + + """ + try: + data: dict[str, Any] = response.json() + except ValueError as error: + self.clear_token() + msg = f"Failed to parse OAuth2 token response: {error}" + raise AuthenticationError(msg) from error + return data + def clear_token(self) -> None: """Clear the cached token, forcing a refresh on the next get_token() call.""" self._access_token = None diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 1906493..d11ccdb 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -24,6 +24,7 @@ get_unique_items, sort_items, ) +from amazon_creatorsapi.core.oauth import get_auth_endpoint from amazon_creatorsapi.core.parsers import get_asin, get_items_ids from amazon_creatorsapi.core.resources import get_all_resources from amazon_creatorsapi.core.results import ResultList @@ -33,6 +34,7 @@ validate_and_get_marketplace, validate_retries, validate_search_criteria, + validate_throttling, validate_timeout, ) from amazon_creatorsapi.errors import ( @@ -73,6 +75,8 @@ from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource if TYPE_CHECKING: + from types import TracebackType + from amazon_creatorsapi.core.marketplaces import CountryCode from creatorsapi_python_sdk.models.availability import Availability from creatorsapi_python_sdk.models.browse_node import BrowseNode @@ -89,6 +93,9 @@ from creatorsapi_python_sdk.models.variations_result import VariationsResult ResponseT = TypeVar("ResponseT") +# typing.Self needs Python 3.11 and typing_extensions is only required by the +# async extra, so the client is typed with a TypeVar bound to itself +ClientT = TypeVar("ClientT", bound="AmazonCreatorsApi") class AmazonCreatorsApi: @@ -112,7 +119,10 @@ class AmazonCreatorsApi: Raises: InvalidArgumentError: If neither country nor marketplace is provided, - if timeout is not greater than zero, or if retries is negative. + if timeout is not greater than zero, if throttling is negative or + if retries is negative. + ValueError: If the version is not supported and no auth_endpoint is + given (valid versions: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3). Example: >>> api = AmazonCreatorsApi( @@ -124,6 +134,10 @@ class AmazonCreatorsApi: ... ) >>> items = api.get_items(["B0DLFMFBJW"]) + The client keeps a pool of connections open, so it is meant to be reused. + Call close, or use it as a context manager, to release the connections of + a client that is not going to be used again. + """ def __init__( @@ -144,16 +158,20 @@ def __init__( self._credential_id = credential_id self._credential_secret = credential_secret self._version = version - self._last_query_time = time.monotonic() - throttling self._throttle_lock = threading.Lock() self.tag = tag - self.throttling = float(throttling) + self.throttling = validate_throttling(throttling) self.timeout = validate_timeout(timeout) self.retries = validate_retries(retries) + self._last_query_time = time.monotonic() - self.throttling # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) + # The endpoint is resolved here, so both clients share the same list + # of versions instead of relying on the one bundled with the SDK + endpoint = get_auth_endpoint(version, auth_endpoint) + # A new configuration for every client, as the default one of the # SDK is shared by the whole process self._api_client = ApiClient( @@ -162,17 +180,39 @@ def __init__( credential_secret=credential_secret, version=version, host=host, - auth_endpoint=auth_endpoint, + auth_endpoint=endpoint, ) # The token manager bundled with the SDK requests the token without # any timeout, so it is replaced by one that honours the configured # value and reports failures as library errors. self._api_client._token_manager = TimeoutOAuth2TokenManager( # noqa: SLF001 - OAuth2Config(credential_id, credential_secret, version, auth_endpoint), + OAuth2Config(credential_id, credential_secret, version, endpoint), self.timeout, ) self._api = DefaultApi(self._api_client) + def __enter__(self: ClientT) -> ClientT: # noqa: PYI019 + """Return the client, which closes its connections when leaving.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Close the connections of the client.""" + self.close() + + def close(self) -> None: + """Release the connections kept open by the client. + + The client stays usable after closing it, opening a new connection on + the next request. Calling it is only needed for clients that are not + reused, as the ones created for a single request. + """ + self._api_client.rest_client.pool_manager.clear() + def get_items( self, items: str | list[str], @@ -187,7 +227,8 @@ def get_items( Duplicated items are requested only once, and the request is split into as many API calls as needed when it goes over the limit of items that - Amazon accepts at once. + Amazon accepts at once. A call that keeps failing after the retries + raises, discarding the items returned by the previous calls. Args: items: One or more items, using ASIN or Amazon product URL. @@ -242,14 +283,17 @@ def get_items( if response.items_result is not None and response.items_result.items: found_items.extend(response.items_result.items) - if not found_items and not include_unavailable: + sorted_items = sort_items( + found_items, + item_ids, + include_unavailable=include_unavailable, + ) + + if not sorted_items and not include_unavailable: msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return ResultList( - sort_items(found_items, item_ids, include_unavailable=include_unavailable), - errors=errors, - ) + return ResultList(sorted_items, errors=errors) def search_items( self, @@ -645,9 +689,16 @@ def _handle_api_exception( ) -> NoReturn: """Handle API exceptions and raise appropriate custom exceptions.""" body = error.body if isinstance(error.body, str) else "" + reason = str(error.reason) if error.reason else None try: - handle_api_error(error.status, body, not_found_error, error.headers) + handle_api_error( + error.status, + body, + not_found_error, + error.headers, + reason, + ) except AmazonCreatorsApiError as exc: # Re-raise with original exception as cause for better stack traces raise exc from error diff --git a/amazon_creatorsapi/core/auth.py b/amazon_creatorsapi/core/auth.py index a43654c..6d4e51e 100644 --- a/amazon_creatorsapi/core/auth.py +++ b/amazon_creatorsapi/core/auth.py @@ -2,29 +2,29 @@ from __future__ import annotations +import threading import time from typing import TYPE_CHECKING, Any import requests +from amazon_creatorsapi.core.constants import HTTP_OK +from amazon_creatorsapi.core.oauth import DEFAULT_EXPIRATION, TOKEN_EXPIRATION_BUFFER from amazon_creatorsapi.errors import AuthenticationError from creatorsapi_python_sdk.auth.oauth2_token_manager import OAuth2TokenManager if TYPE_CHECKING: from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config -# Token expiration buffer in seconds (refresh before the actual expiration) -TOKEN_EXPIRATION_BUFFER = 30 -DEFAULT_EXPIRATION = 3600 -HTTP_OK = 200 - class TimeoutOAuth2TokenManager(OAuth2TokenManager): """Token manager that fails instead of waiting forever for a token. The token manager bundled with the SDK requests the token without any timeout, so an unresponsive auth endpoint blocks the call indefinitely - even when a timeout is set for the API requests themselves. + even when a timeout is set for the API requests themselves. It also asks + for a token without any lock, so every thread sharing a client requests + its own token as soon as the cached one expires. Args: config: OAuth2 configuration with the credentials and the endpoint. @@ -37,6 +37,25 @@ def __init__(self, config: OAuth2Config, timeout: float | None) -> None: """Initialize the token manager with its timeout.""" super().__init__(config) self._timeout = timeout + self._lock = threading.Lock() + + def get_token(self) -> str: + """Return a valid token, asking for a new one only once at a time. + + Returns: + A valid access token. + + Raises: + AuthenticationError: If the token cannot be obtained. + + """ + if self.is_token_valid(): + return str(self.access_token) + + with self._lock: + if self.is_token_valid(): + return str(self.access_token) + return self.refresh_token() def refresh_token(self) -> str: """Refresh the OAuth2 access token using the client credentials grant. diff --git a/amazon_creatorsapi/core/error_handling.py b/amazon_creatorsapi/core/error_handling.py index 51eff9d..aede1d2 100644 --- a/amazon_creatorsapi/core/error_handling.py +++ b/amazon_creatorsapi/core/error_handling.py @@ -113,6 +113,7 @@ def handle_api_error( body: str, not_found_error: type[AmazonCreatorsApiError] = ItemsNotFoundError, headers: Mapping[str, str] | None = None, + reason: str | None = None, ) -> NoReturn: """Handle API error responses and raise appropriate exceptions. @@ -123,6 +124,8 @@ def handle_api_error( operations tell apart items from feeds and reports. headers: Headers of the response, used to report the identifier that Amazon gave to the request. + reason: Reason of the failure, used when the response has no body, + as happens for the errors reported by the transport itself. Raises: InvalidArgumentError: For requests rejected by the API. @@ -135,16 +138,16 @@ def handle_api_error( """ data = parse_error_body(body) - detail = get_error_detail(data, body) - reason = data.get("reason") + detail = get_error_detail(data, body) or (f" - {reason}" if reason else "") + error_reason = data.get("reason") request_id = get_request_id(headers) if request_id: detail = f"{detail} [request id: {request_id}]" if status_code == HTTP_BAD_REQUEST: - if reason == INVALID_ASSOCIATE_REASON or ( - reason is None and INVALID_ASSOCIATE_REASON in body + if error_reason == INVALID_ASSOCIATE_REASON or ( + error_reason is None and INVALID_ASSOCIATE_REASON in body ): msg = f"Credentials are not valid for the selected marketplace{detail}" raise AssociateValidationError(msg) diff --git a/amazon_creatorsapi/core/oauth.py b/amazon_creatorsapi/core/oauth.py new file mode 100644 index 0000000..be99a57 --- /dev/null +++ b/amazon_creatorsapi/core/oauth.py @@ -0,0 +1,77 @@ +"""OAuth2 settings shared by the synchronous and the asynchronous clients.""" + +from __future__ import annotations + +# Scopes and grant type accepted by the auth endpoints of Amazon +COGNITO_SCOPE = "creatorsapi/default" +LWA_SCOPE = "creatorsapi::default" +GRANT_TYPE = "client_credentials" + +# Seconds subtracted from the lifetime of a token, so it is refreshed before +# the actual expiration +TOKEN_EXPIRATION_BUFFER = 30 + +# Lifetime assumed for a token when the auth endpoint does not send one +DEFAULT_EXPIRATION = 3600 + +# Auth endpoint of every version of the API, Cognito for 2.x and LWA for 3.x +VERSION_ENDPOINTS = { + "2.1": "https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token", + "2.2": "https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token", + "2.3": "https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token", + "3.1": "https://api.amazon.com/auth/o2/token", + "3.2": "https://api.amazon.co.uk/auth/o2/token", + "3.3": "https://api.amazon.co.jp/auth/o2/token", +} + + +def is_lwa(version: str) -> bool: + """Return whether a version authenticates with Login with Amazon. + + Args: + version: API version in use. + + Returns: + True for the versions using LWA, False for the ones using Cognito. + + """ + return version.startswith("3.") + + +def get_scope(version: str) -> str: + """Return the OAuth2 scope of a version. + + Args: + version: API version in use. + + Returns: + The scope to ask the auth endpoint for. + + """ + return LWA_SCOPE if is_lwa(version) else COGNITO_SCOPE + + +def get_auth_endpoint(version: str, auth_endpoint: str | None = None) -> str: + """Return the auth endpoint to use, validating the version when needed. + + Args: + version: API version in use. + auth_endpoint: Endpoint provided by the user, which takes precedence + over the one of the version and makes any version valid. + + Returns: + The URL used to get the OAuth2 token. + + Raises: + ValueError: If the version is not supported and no endpoint is given. + + """ + if auth_endpoint and auth_endpoint.strip(): + return auth_endpoint + + if version not in VERSION_ENDPOINTS: + supported = ", ".join(VERSION_ENDPOINTS) + msg = f"Unsupported version: {version}. Supported versions are: {supported}" + raise ValueError(msg) + + return VERSION_ENDPOINTS[version] diff --git a/amazon_creatorsapi/core/parsers.py b/amazon_creatorsapi/core/parsers.py index ac70b84..c840432 100644 --- a/amazon_creatorsapi/core/parsers.py +++ b/amazon_creatorsapi/core/parsers.py @@ -24,8 +24,13 @@ def get_asin(text: str) -> str: if re.search(r"^[a-zA-Z0-9]{10}$", text): return text.upper() - # Extract ASIN from URL searching for common Amazon URL patterns - asin = re.search(r"(dp|gp/product|gp/aw/d|dp/product)/([a-zA-Z0-9]{10})", text) + # Extract ASIN from URL searching for common Amazon URL patterns. The + # identifier has to end after ten characters, so a longer one is reported + # instead of silently trimmed to something that looks like a valid ASIN. + asin = re.search( + r"(dp|gp/product|gp/aw/d|dp/product)/([a-zA-Z0-9]{10})(?![a-zA-Z0-9])", + text, + ) if asin: return asin.group(2).upper() diff --git a/amazon_creatorsapi/core/retry.py b/amazon_creatorsapi/core/retry.py index 742db91..ee2ed2d 100644 --- a/amazon_creatorsapi/core/retry.py +++ b/amazon_creatorsapi/core/retry.py @@ -2,6 +2,8 @@ from __future__ import annotations +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -41,7 +43,7 @@ def get_retry_after(headers: Mapping[str, str] | None) -> float | None: Returns: The amount of seconds to wait, or None when the header is missing or - does not hold an amount of seconds. + holds neither an amount of seconds nor a date. """ if not headers: @@ -53,11 +55,35 @@ def get_retry_after(headers: Mapping[str, str] | None) -> float | None: try: return max(float(value), 0.0) except (TypeError, ValueError): - return None + return get_seconds_until(value) return None +def get_seconds_until(value: str) -> float | None: + """Return the seconds left until an HTTP date, which Retry-After allows. + + Args: + value: Value of the header, expected to hold a date. + + Returns: + The amount of seconds until the date, zero when it is already past, + or None when the value is not a date. + + """ + # Python 3.9 reports an unparseable value with a TypeError instead of the + # ValueError raised by the newer versions + try: + date = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + + if date.tzinfo is None: + date = date.replace(tzinfo=timezone.utc) + + return max((date - datetime.now(timezone.utc)).total_seconds(), 0.0) + + def get_retry_delay(attempt: int, headers: Mapping[str, str] | None = None) -> float: """Return the seconds to wait before the next attempt. diff --git a/amazon_creatorsapi/core/validation.py b/amazon_creatorsapi/core/validation.py index f12d3c1..3aa591c 100644 --- a/amazon_creatorsapi/core/validation.py +++ b/amazon_creatorsapi/core/validation.py @@ -59,10 +59,39 @@ def validate_timeout(timeout: float | None) -> float | None: """ if timeout is None: return None - if timeout <= 0: + try: + value = float(timeout) + except (TypeError, ValueError) as error: + msg = f"Timeout must be a number of seconds, or None: {timeout!r}" + raise InvalidArgumentError(msg) from error + if value <= 0: msg = "Timeout must be greater than zero, or None to wait indefinitely" raise InvalidArgumentError(msg) - return float(timeout) + return value + + +def validate_throttling(throttling: float) -> float: + """Validate the wait time between API calls. + + Args: + throttling: Wait time in seconds between API calls. + + Returns: + The wait time as a float. + + Raises: + InvalidArgumentError: If the wait time is not a number or is negative. + + """ + try: + value = float(throttling) + except (TypeError, ValueError) as error: + msg = f"Throttling must be a number of seconds: {throttling!r}" + raise InvalidArgumentError(msg) from error + if value < 0: + msg = "Throttling must be zero or greater" + raise InvalidArgumentError(msg) + return value def build_request(request_class: type[RequestT], **fields: Any) -> RequestT: @@ -100,13 +129,19 @@ def validate_retries(retries: int) -> int: The amount of retries as an integer. Raises: - InvalidArgumentError: If the amount of retries is negative. + InvalidArgumentError: If the amount of retries is not a whole number + or is negative. """ - if retries < 0: + try: + value = int(retries) + except (TypeError, ValueError) as error: + msg = f"Retries must be a whole number: {retries!r}" + raise InvalidArgumentError(msg) from error + if value < 0: msg = "Retries must be zero or greater" raise InvalidArgumentError(msg) - return int(retries) + return value def validate_search_criteria(**criteria: object) -> None: diff --git a/pyproject.toml b/pyproject.toml index 022f632..9f508e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,7 +96,9 @@ exclude = ["creatorsapi_python_sdk/*", "docs/*"] exclude = ["creatorsapi_python_sdk/*", "docs/*", ".github"] [tool.mypy] -python_version = "3.9" +# The package supports Python 3.9, but mypy only checks 3.10 and above, so the +# lowest version it can be told about is the one used here +python_version = "3.10" ignore_missing_imports = true no_implicit_optional = true strict_equality = true diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index a2b8adf..ffe3c5e 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -12,6 +12,7 @@ ) from amazon_creatorsapi.aio.api import API_HOST from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT +from amazon_creatorsapi.core.oauth import VERSION_ENDPOINTS from amazon_creatorsapi.errors import ( AssociateValidationError, AuthenticationError, @@ -212,6 +213,73 @@ def test_raises_error_for_invalid_version( self.assertIn("Unsupported version: 9.9", str(context.exception)) self.assertIn("Supported versions are:", str(context.exception)) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_custom_endpoint_accepts_any_version( + self, mock_token_manager: MagicMock + ) -> None: + """Test that a custom endpoint makes any version valid.""" + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="9.9", + tag="test-tag", + country="ES", + auth_endpoint="https://example.com/token", + ) + + self.assertEqual( + mock_token_manager.call_args.kwargs["auth_endpoint"], + "https://example.com/token", + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_the_endpoint_of_the_version_is_used( + self, mock_token_manager: MagicMock + ) -> None: + """Test that the endpoint of the version reaches the token manager.""" + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ) + + self.assertEqual( + mock_token_manager.call_args.kwargs["auth_endpoint"], + VERSION_ENDPOINTS["2.2"], + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_negative_throttling_is_rejected( + self, mock_token_manager: MagicMock + ) -> None: + """Test that a negative wait time between calls is rejected.""" + with self.assertRaises(InvalidArgumentError): + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=-1, + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_throttling_that_is_not_a_number_is_rejected( + self, mock_token_manager: MagicMock + ) -> None: + """Test that a wait time that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling="fast", # type: ignore[arg-type] + ) + class TestAsyncAmazonCreatorsApiContextManager(unittest.IsolatedAsyncioTestCase): """Tests for AsyncAmazonCreatorsApi async context manager.""" @@ -713,8 +781,8 @@ async def test_throttling_waits_between_requests( country="ES", throttling=0.5, ) as api: - await api.get_items(["B0DLFMFBJ1"]) - await api.get_items(["B0DLFMFBJ2"]) + await api.get_items(["B0DLFMFBJZ"]) + await api.get_items(["B0DLFMFBJZ"]) # asyncio.sleep should have been called for throttling self.assertTrue(mock_sleep.called) @@ -2422,5 +2490,46 @@ async def test_custom_host_and_auth_endpoint( ) +class TestAsyncAmazonCreatorsApiUnrequestedItems(unittest.IsolatedAsyncioTestCase): + """Tests for a response holding items that were not requested.""" + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_items_of_other_asins_are_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a response without any requested item is not found.""" + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.json.return_value = { + "itemsResult": {"items": [{"asin": "B000000002"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + retries=0, + ) + + with self.assertRaises(ItemsNotFoundError): + await api.get_items(["B000000001"]) + + if __name__ == "__main__": unittest.main() diff --git a/tests/amazon_creatorsapi/aio/auth_test.py b/tests/amazon_creatorsapi/aio/auth_test.py index a46e503..d437ac0 100644 --- a/tests/amazon_creatorsapi/aio/auth_test.py +++ b/tests/amazon_creatorsapi/aio/auth_test.py @@ -354,6 +354,29 @@ async def test_raises_error_on_request_error( self.assertIn("token request failed", str(context.exception)) +class TestAsyncOAuth2TokenManagerInvalidJson(unittest.IsolatedAsyncioTestCase): + """Tests for a token response that does not hold JSON.""" + + async def test_invalid_json_raises_authentication_error(self) -> None: + """Test that an unparseable response is reported as a library error.""" + response = MagicMock() + response.status_code = 200 + response.json.side_effect = ValueError("Expecting value") + + client = AsyncMock() + client.post.return_value = response + client.__aenter__.return_value = client + + manager = AsyncOAuth2TokenManager("test_id", "test_secret", "2.2") + + with patch("httpx.AsyncClient", return_value=client): + with self.assertRaises(AuthenticationError) as context: + await manager.refresh_token() + + self.assertIn("Failed to parse OAuth2 token response", str(context.exception)) + self.assertIsNone(manager._access_token) + + class TestAsyncOAuth2TokenManagerTimeout(unittest.IsolatedAsyncioTestCase): """Tests for the timeout applied to token refresh requests.""" diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index a80acbc..edebf49 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -13,6 +13,7 @@ from amazon_creatorsapi import AmazonCreatorsApi from amazon_creatorsapi.core.auth import TimeoutOAuth2TokenManager from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT +from amazon_creatorsapi.core.oauth import VERSION_ENDPOINTS from amazon_creatorsapi.errors import ( AccessDeniedError, AssociateValidationError, @@ -165,7 +166,7 @@ def test_get_items( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -774,7 +775,7 @@ def test_get_items_with_explicit_resources( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1107,7 +1108,7 @@ def test_get_items_uses_default_timeout( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1138,7 +1139,7 @@ def test_get_items_forwards_custom_timeout( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1169,7 +1170,7 @@ def test_get_items_with_timeout_disabled( mock_api = MagicMock() mock_api_class.return_value = mock_api mock_response = MagicMock() - mock_response.items_result.items = [MagicMock()] + mock_response.items_result.items = [MagicMock(asin="B0DLFMFBJW")] mock_api.get_items.return_value = mock_response api = AmazonCreatorsApi( @@ -1859,3 +1860,116 @@ def test_every_client_has_its_own_configuration(self) -> None: first._api_client.configuration, second._api_client.configuration, ) + + def build_api_with(self, **options: object) -> AmazonCreatorsApi: + """Build an API client overriding any of its options.""" + return AmazonCreatorsApi( + **{ # type: ignore[arg-type] + "credential_id": self.credential_id, + "credential_secret": self.credential_secret, + "version": self.version, + "tag": self.tag, + "country": self.country, + "throttling": 0, + "retries": 0, + **options, + } + ) + + def test_unsupported_version_is_rejected(self) -> None: + """Test that a version out of the list needs a custom endpoint.""" + with self.assertRaises(ValueError) as context: + self.build_api_with(version="4.0") + + self.assertIn("Unsupported version: 4.0", str(context.exception)) + + def test_custom_endpoint_accepts_any_version(self) -> None: + """Test that a custom endpoint makes any version valid.""" + api = self.build_api_with( + version="4.0", + auth_endpoint="https://example.com/token", + ) + + token_manager = api._api_client.token_manager + assert token_manager is not None + self.assertEqual( + token_manager.config.get_cognito_endpoint(), + "https://example.com/token", + ) + + def test_the_endpoint_of_the_version_is_used(self) -> None: + """Test that the endpoint of the version reaches the token manager.""" + api = self.build_api() + + token_manager = api._api_client.token_manager + assert token_manager is not None + self.assertEqual( + token_manager.config.get_cognito_endpoint(), + VERSION_ENDPOINTS["2.2"], + ) + + def test_negative_throttling_is_rejected(self) -> None: + """Test that a negative wait time between calls is rejected.""" + with self.assertRaises(InvalidArgumentError): + self.build_api_with(throttling=-1) + + def test_throttling_that_is_not_a_number_is_rejected(self) -> None: + """Test that a wait time that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + self.build_api_with(throttling="fast") + + def test_close_releases_the_connections(self) -> None: + """Test that closing the client clears the pool of connections.""" + api = self.build_api() + api._api_client.rest_client.pool_manager = MagicMock() + + api.close() + + api._api_client.rest_client.pool_manager.clear.assert_called_once() + + def test_context_manager_closes_the_client(self) -> None: + """Test that leaving the context manager closes the client.""" + api = self.build_api() + api._api_client.rest_client.pool_manager = MagicMock() + + with api as client: + self.assertIs(client, api) + + api._api_client.rest_client.pool_manager.clear.assert_called_once() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_transport_error_keeps_its_reason( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that an error without a body is reported with its reason.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = ApiException( + status=0, + reason="SSL error: certificate verify failed", + ) + + with self.assertRaises(RequestError) as context: + self.build_api().get_items(["B000000001"]) + + self.assertIn("certificate verify failed", str(context.exception)) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_items_of_other_asins_are_not_found( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a response without any requested item is not found.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = GetItemsResponseContent( + itemsResult=ItemsResult(items=[Item(asin="B000000002")]), + ) + + with self.assertRaises(ItemsNotFoundError): + self.build_api().get_items(["B000000001"]) diff --git a/tests/amazon_creatorsapi/core/auth_test.py b/tests/amazon_creatorsapi/core/auth_test.py index 2483a40..35f7d1c 100644 --- a/tests/amazon_creatorsapi/core/auth_test.py +++ b/tests/amazon_creatorsapi/core/auth_test.py @@ -2,6 +2,8 @@ from __future__ import annotations +import threading +import time import unittest from unittest import mock from unittest.mock import MagicMock @@ -119,3 +121,45 @@ def test_invalid_json_raises_authentication_error( self.manager.refresh_token() self.assertIn("parse OAuth2 token response", str(context.exception)) + + +class TestTimeoutOAuth2TokenManagerThreads(unittest.TestCase): + """Tests for the token manager when it is shared by several threads.""" + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_only_one_thread_requests_the_token(self, mock_post: MagicMock) -> None: + """Test that threads asking at once share a single token request.""" + started = threading.Barrier(4) + + def build_token(*_args: object, **_kwargs: object) -> MagicMock: + # The request takes long enough for the other threads to reach the + # cache while it is still empty + time.sleep(0.05) + response = MagicMock() + response.status_code = 200 + response.json.return_value = { + "access_token": "test_token", + "expires_in": 3600, + } + return response + + mock_post.side_effect = build_token + + manager = TimeoutOAuth2TokenManager( + OAuth2Config("test_id", "test_secret", "2.2", None), + timeout=7.0, + ) + tokens: list[str] = [] + + def get_token() -> None: + started.wait() + tokens.append(manager.get_token()) + + threads = [threading.Thread(target=get_token) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(tokens, ["test_token"] * 4) + self.assertEqual(mock_post.call_count, 1) diff --git a/tests/amazon_creatorsapi/core/error_handling_test.py b/tests/amazon_creatorsapi/core/error_handling_test.py index b09b14b..9265e91 100644 --- a/tests/amazon_creatorsapi/core/error_handling_test.py +++ b/tests/amazon_creatorsapi/core/error_handling_test.py @@ -137,6 +137,29 @@ def test_body_without_details(self) -> None: self.assertIn("Bad gateway", str(context.exception)) +class TestHandleApiErrorReason(unittest.TestCase): + """Tests for the reason reported when the response has no body.""" + + def test_reason_is_reported(self) -> None: + """Test that the reason is kept when the response has no body.""" + with self.assertRaises(RequestError) as context: + handle_api_error(0, "", reason="SSL error: certificate verify failed") + + self.assertIn("certificate verify failed", str(context.exception)) + + def test_body_wins_over_the_reason(self) -> None: + """Test that the body of the response is preferred to the reason.""" + with self.assertRaises(RequestError) as context: + handle_api_error( + 500, + '{"message": "Internal failure"}', + reason="Internal Server Error", + ) + + self.assertIn("Internal failure", str(context.exception)) + self.assertNotIn("Internal Server Error", str(context.exception)) + + class TestGetRequestId(unittest.TestCase): """Tests for get_request_id function.""" diff --git a/tests/amazon_creatorsapi/core/oauth_test.py b/tests/amazon_creatorsapi/core/oauth_test.py new file mode 100644 index 0000000..31d7d3b --- /dev/null +++ b/tests/amazon_creatorsapi/core/oauth_test.py @@ -0,0 +1,71 @@ +"""Unit tests for the shared OAuth2 settings.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.oauth import ( + COGNITO_SCOPE, + LWA_SCOPE, + VERSION_ENDPOINTS, + get_auth_endpoint, + get_scope, + is_lwa, +) + + +class TestIsLwa(unittest.TestCase): + """Tests for is_lwa function.""" + + def test_lwa_versions(self) -> None: + """Test that the 3.x versions authenticate with LWA.""" + for version in ("3.1", "3.2", "3.3"): + self.assertTrue(is_lwa(version)) + + def test_cognito_versions(self) -> None: + """Test that the 2.x versions authenticate with Cognito.""" + for version in ("2.1", "2.2", "2.3"): + self.assertFalse(is_lwa(version)) + + +class TestGetScope(unittest.TestCase): + """Tests for get_scope function.""" + + def test_scope_of_every_version(self) -> None: + """Test that every version asks for the scope of its flow.""" + self.assertEqual(get_scope("3.1"), LWA_SCOPE) + self.assertEqual(get_scope("2.2"), COGNITO_SCOPE) + + +class TestGetAuthEndpoint(unittest.TestCase): + """Tests for get_auth_endpoint function.""" + + def test_endpoint_of_every_version(self) -> None: + """Test that every supported version resolves to its endpoint.""" + for version, endpoint in VERSION_ENDPOINTS.items(): + self.assertEqual(get_auth_endpoint(version), endpoint) + + def test_custom_endpoint_wins(self) -> None: + """Test that the endpoint given by the user is the one used.""" + self.assertEqual( + get_auth_endpoint("2.2", "https://example.test/token"), + "https://example.test/token", + ) + + def test_custom_endpoint_makes_any_version_valid(self) -> None: + """Test that a custom endpoint accepts a version out of the list.""" + self.assertEqual( + get_auth_endpoint("4.0", "https://example.test/token"), + "https://example.test/token", + ) + + def test_blank_endpoint_is_ignored(self) -> None: + """Test that a blank endpoint falls back to the one of the version.""" + self.assertEqual(get_auth_endpoint("2.2", " "), VERSION_ENDPOINTS["2.2"]) + + def test_unsupported_version_is_rejected(self) -> None: + """Test that an unknown version without an endpoint is rejected.""" + with self.assertRaises(ValueError) as context: + get_auth_endpoint("4.0") + + self.assertIn("Unsupported version: 4.0", str(context.exception)) diff --git a/tests/amazon_creatorsapi/core/retry_test.py b/tests/amazon_creatorsapi/core/retry_test.py index 14ffa7a..273f38e 100644 --- a/tests/amazon_creatorsapi/core/retry_test.py +++ b/tests/amazon_creatorsapi/core/retry_test.py @@ -3,6 +3,8 @@ from __future__ import annotations import unittest +from datetime import datetime, timedelta, timezone +from email.utils import format_datetime from amazon_creatorsapi.core.retry import ( MAX_BACKOFF, @@ -43,9 +45,37 @@ def test_missing_header(self) -> None: self.assertIsNone(get_retry_after(None)) self.assertIsNone(get_retry_after({"Content-Type": "application/json"})) - def test_date_header_is_ignored(self) -> None: - """Test that a header holding a date is ignored.""" - self.assertIsNone(get_retry_after({"Retry-After": "Wed, 21 Oct 2026 07:28:00"})) + def test_reads_a_date_header(self) -> None: + """Test that a header holding a date is read as the seconds left.""" + date = datetime.now(timezone.utc) + timedelta(seconds=120) + seconds = get_retry_after({"Retry-After": format_datetime(date, usegmt=True)}) + + self.assertIsNotNone(seconds) + assert seconds is not None + self.assertAlmostEqual(seconds, 120, delta=5) + + def test_date_header_in_the_past(self) -> None: + """Test that a date already past asks for no wait at all.""" + date = datetime.now(timezone.utc) - timedelta(seconds=120) + + self.assertEqual( + get_retry_after({"Retry-After": format_datetime(date, usegmt=True)}), + 0.0, + ) + + def test_date_header_without_timezone(self) -> None: + """Test that a date without timezone is read as UTC.""" + date = datetime.now(timezone.utc) + timedelta(seconds=120) + header = date.strftime("%a, %d %b %Y %H:%M:%S") + seconds = get_retry_after({"Retry-After": header}) + + self.assertIsNotNone(seconds) + assert seconds is not None + self.assertAlmostEqual(seconds, 120, delta=5) + + def test_invalid_header_is_ignored(self) -> None: + """Test that a header holding neither seconds nor a date is ignored.""" + self.assertIsNone(get_retry_after({"Retry-After": "soon"})) class TestGetRetryDelay(unittest.TestCase): diff --git a/tests/amazon_creatorsapi/core/validation_test.py b/tests/amazon_creatorsapi/core/validation_test.py index a59cd4e..e9a5c2a 100644 --- a/tests/amazon_creatorsapi/core/validation_test.py +++ b/tests/amazon_creatorsapi/core/validation_test.py @@ -8,6 +8,7 @@ build_request, validate_retries, validate_search_criteria, + validate_throttling, validate_timeout, ) from amazon_creatorsapi.errors import InvalidArgumentError @@ -60,6 +61,11 @@ def test_zero_is_rejected(self) -> None: with self.assertRaises(InvalidArgumentError): validate_timeout(0) + def test_not_a_number_is_rejected(self) -> None: + """Test that a value that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_timeout("slow") # type: ignore[arg-type] + class TestValidateRetries(unittest.TestCase): """Tests for validate_retries function.""" @@ -77,6 +83,33 @@ def test_negative_is_rejected(self) -> None: with self.assertRaises(InvalidArgumentError): validate_retries(-1) + def test_not_a_number_is_rejected(self) -> None: + """Test that a value that is not a whole number is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_retries("many") # type: ignore[arg-type] + + +class TestValidateThrottling(unittest.TestCase): + """Tests for validate_throttling function.""" + + def test_accepts_a_wait_time(self) -> None: + """Test that a wait time is returned as a float.""" + self.assertEqual(validate_throttling(2), 2.0) + + def test_accepts_no_wait_time(self) -> None: + """Test that no wait between calls is accepted.""" + self.assertEqual(validate_throttling(0), 0.0) + + def test_negative_is_rejected(self) -> None: + """Test that a negative wait time is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_throttling(-1) + + def test_not_a_number_is_rejected(self) -> None: + """Test that a value that is not a number is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_throttling("fast") # type: ignore[arg-type] + class TestValidateSearchCriteria(unittest.TestCase): """Tests for validate_search_criteria function.""" diff --git a/tests/amazon_creatorsapi/tools_test.py b/tests/amazon_creatorsapi/tools_test.py index db62d9b..d57814e 100644 --- a/tests/amazon_creatorsapi/tools_test.py +++ b/tests/amazon_creatorsapi/tools_test.py @@ -49,6 +49,12 @@ def test_get_asin_with_complex_url(self) -> None: url = "https://www.amazon.com/Product-Name-Description/dp/B0DLFMFBJW/ref=sr_1_1" self.assertEqual(get_asin(url), "B0DLFMFBJW") + def test_get_asin_with_a_longer_identifier_raises_error(self) -> None: + """Test that an identifier longer than an ASIN is not trimmed.""" + url = "https://www.amazon.es/dp/B0DLFMFBJW1234" + with self.assertRaises(InvalidArgumentError): + get_asin(url) + def test_get_asin_with_invalid_input_raises_error(self) -> None: """Test that invalid input raises InvalidArgumentError.""" with self.assertRaises(InvalidArgumentError):