diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b2e1e..b40477b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [7.4.0] - 2026-09-04 + +### Added + +- `availability` parameter in `search_items` to include the items that are out of stock +- `host` and `auth_endpoint` parameters in `AmazonCreatorsApi` and `AsyncAmazonCreatorsApi` to replace the endpoints of the API, useful to run tests against a mock server +- `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 + +### Changed + +- `search_items` rejects a search without any criteria instead of sending it to the API +- `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 + +### 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 + +### Removed + +- `six` dependency, which was not used + +## [7.3.0] - 2026-09-03 + +### Added + +- `retries` parameter in `AmazonCreatorsApi` and `AsyncAmazonCreatorsApi` to retry the throttled and failed requests that Amazon asks to retry, waiting longer before every attempt and honouring the `Retry-After` header +- `AccessDeniedError`, raised when the credentials cannot perform the requested operation +- `ResourceNotFoundError`, raised when a feed or report does not exist, telling it apart from missing items + +### Changed + +- Errors are mapped from the response of the Creators API instead of the codes of the old Product Advertising API, so the reason and the fields that failed are part of the message +- A rejected request raises `InvalidArgumentError`, missing or expired credentials raise `AuthenticationError` and a forbidden request raises `AccessDeniedError`, instead of a generic `RequestError` +- An expired token is refreshed once and the request is sent again instead of failing +- Connection failures and unparseable responses raise `RequestError` instead of leaking the errors of the HTTP client + +## [7.2.0] - 2026-09-03 + +### Added + +- `get_items` splits a request with more items than the API accepts into as many calls as needed, so any amount of items can be requested at once +- `include_unavailable` parameter in `get_items` to get an item holding only the ASIN for every requested item missing from the response +- Partial errors of a response are available in the `errors` attribute of the lists returned by `get_items` and `get_browse_nodes`, and are reported in the message of `ItemsNotFoundError` +- `ErrorData` and `ResultList` available in `amazon_creatorsapi.models` + +### Changed + +- `get_items` returns the items in the order they were requested, and asks for duplicated items only once +- `AmazonCreatorsApi` applies the timeout to the OAuth2 token refresh as well, which previously waited indefinitely, and reports its failures as `AuthenticationError` +- `AmazonCreatorsApi` validates the version when it is created, as `AsyncAmazonCreatorsApi` already did, instead of failing on the first request +- Values rejected by the API constraints raise `InvalidArgumentError` instead of a `pydantic.ValidationError` +- `get_items` raises `ItemsNotFoundError` when the response holds no items, as documented, instead of returning an empty list + ## [7.1.0] - 2026-09-03 ### Added diff --git a/README.md b/README.md index 61bbf50..a708a2e 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,29 @@ for item in items: print(item.images.primary.large.url) ``` +Items come back in the order they were requested, duplicates are asked for +only once, and requests with more items than the API accepts at once are +split into as many calls as needed, so any amount of items can be requested: + +```python +items = api.get_items(asins) # Any amount of items, split into several calls +``` + +Amazon can answer with only some of the requested items, describing the +missing ones as partial errors. Those errors are available in the returned +list, and unavailable items can be included as an item holding only the ASIN: + +```python +items = api.get_items(["B01N5IB20Q", "0000000000"], include_unavailable=True) + +for error in items.errors: + print(error.code, error.message) + +for item in items: + if item.item_info is None: + print(f"{item.asin} is not available") +``` + ### Search Products ```python @@ -68,6 +91,17 @@ for item in results.items: print(item.item_info.title.display_value) ``` +A search needs at least one of `keywords`, `actor`, `artist`, `author`, `brand`, `title`, `browse_node_id` or `search_index`, and only returns the items available for purchase unless asked otherwise: + +```python +from amazon_creatorsapi.models import Availability + +results = api.search_items( + keywords="nintendo switch", + availability=Availability.INCLUDEOUTOFSTOCK, +) +``` + ### Get Product Variations ```python @@ -149,7 +183,58 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=10) # Fai amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=0.5) # Fails after half a second ``` -It applies to every API request. In `AmazonCreatorsApi` the OAuth2 token refresh is handled by the bundled SDK and is not covered by this value, while `AsyncAmazonCreatorsApi` applies it to the token refresh as well. +It applies to every API request, including the OAuth2 token refresh. + +### Retries + +Amazon asks clients to back off and try again when it throttles a request or fails to serve it. The client does that on its own, waiting longer before every attempt and honouring the `Retry-After` header when the API sends it. An expired token is refreshed once and the request is sent again. + +```python +amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=5) # Up to 5 extra attempts +amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=0) # Fail on the first error +``` + +### 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: + +```python +api = AmazonCreatorsApi( + ID, + SECRET, + VERSION, + TAG, + COUNTRY, + host="http://localhost:8080", + auth_endpoint="http://localhost:8080/token", +) +``` + +### Error Handling + +Every error raised by the library inherits from `AmazonCreatorsApiError`, so a single `except` covers them all. The message carries the reason given by Amazon, the fields that failed validation and the identifier of the request, which is what Amazon support asks for: + +| Exception | Raised when | +| --- | --- | +| `InvalidArgumentError` | An argument is not valid or the request is rejected by Amazon | +| `AssociateValidationError` | The credentials are not valid for the selected marketplace | +| `AuthenticationError` | The credentials are missing, invalid or expired | +| `AccessDeniedError` | The credentials cannot perform the requested operation | +| `ItemsNotFoundError` | No items are found for the request | +| `ResourceNotFoundError` | The requested feed or report does not exist | +| `TooManyRequestsError` | The rate limit is exceeded and the retries are exhausted | +| `RequestError` | The request fails for any other reason | + +```python +from amazon_creatorsapi.errors import AmazonCreatorsApiError, ItemsNotFoundError + +try: + items = api.get_items(["B01N5IB20Q"]) +except ItemsNotFoundError: + print("The item is not available") +except AmazonCreatorsApiError as error: + print(error) +``` ### Async Support @@ -204,11 +289,17 @@ from amazon_creatorsapi.models import ( items = api.get_items(["B01N5IB20Q"], condition=Condition.NEW) # Use SortBy enum for search ordering -results = api.search_items(keywords="laptop", sort_by=SortBy.PRICE_LOW_TO_HIGH) +results = api.search_items( + keywords="laptop", + sort_by=SortBy.PRICE_COLON_LOW_TO_HIGH, +) # Specify which resources to retrieve from amazon_creatorsapi.models import GetItemsResource -resources = [GetItemsResource.ITEMINFO_TITLE, GetItemsResource.OFFERS_LISTINGS_PRICE] +resources = [ + GetItemsResource.ITEM_INFO_DOT_TITLE, + GetItemsResource.OFFERS_V2_DOT_LISTINGS_DOT_PRICE, +] items = api.get_items(["B01N5IB20Q"], resources=resources) ``` diff --git a/amazon_creatorsapi/__init__.py b/amazon_creatorsapi/__init__.py index c02ab1f..bca66d0 100644 --- a/amazon_creatorsapi/__init__.py +++ b/amazon_creatorsapi/__init__.py @@ -4,8 +4,8 @@ """ __author__ = "Sergio Abad" -__all__ = ["AmazonCreatorsApi", "Country", "models"] +__all__ = ["AmazonCreatorsApi", "Country", "errors", "get_asin", "models"] -from . import models +from . import errors, models from .api import AmazonCreatorsApi -from .core import Country +from .core import Country, get_asin diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index f5c19ff..097dd9e 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -12,19 +12,44 @@ from typing_extensions import Self -from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING, DEFAULT_TIMEOUT -from amazon_creatorsapi.core.error_handling import handle_api_error +from amazon_creatorsapi.core.constants import ( + DEFAULT_HOST, + DEFAULT_THROTTLING, + DEFAULT_TIMEOUT, + HTTP_OK, + HTTP_UNAUTHORIZED, +) +from amazon_creatorsapi.core.error_handling import format_errors, handle_api_error +from amazon_creatorsapi.core.items import ( + get_item_chunks, + get_unique_items, + sort_items, +) 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 +from amazon_creatorsapi.core.results import ResultList +from amazon_creatorsapi.core.retry import DEFAULT_RETRIES, get_retry_delay, is_retryable from amazon_creatorsapi.core.validation import ( + build_request, validate_and_get_marketplace, + validate_retries, + validate_search_criteria, validate_timeout, ) -from amazon_creatorsapi.errors import ItemsNotFoundError +from amazon_creatorsapi.errors import ( + AmazonCreatorsApiError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + ResourceNotFoundError, +) try: + import httpx + from .auth import VERSION_ENDPOINTS, AsyncOAuth2TokenManager - from .client import AsyncHttpClient + from .client import AsyncHttpClient, AsyncHttpResponse except ImportError as exc: # pragma: no cover msg = ( "httpx is required for async support. " @@ -32,17 +57,36 @@ ) raise ImportError(msg) from exc +from creatorsapi_python_sdk.models.get_browse_nodes_request_content import ( + GetBrowseNodesRequestContent, +) from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( GetBrowseNodesResource, ) +from creatorsapi_python_sdk.models.get_feed_request_content import ( + GetFeedRequestContent, +) +from creatorsapi_python_sdk.models.get_items_request_content import ( + GetItemsRequestContent, +) from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource +from creatorsapi_python_sdk.models.get_report_request_content import ( + GetReportRequestContent, +) +from creatorsapi_python_sdk.models.get_variations_request_content import ( + GetVariationsRequestContent, +) from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource +from creatorsapi_python_sdk.models.search_items_request_content import ( + SearchItemsRequestContent, +) 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.condition import Condition from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag from creatorsapi_python_sdk.models.feed_type import FeedType @@ -50,6 +94,7 @@ from creatorsapi_python_sdk.models.sort_by import SortBy from creatorsapi_python_sdk.models.browse_node import BrowseNode +from creatorsapi_python_sdk.models.error_data import ErrorData from creatorsapi_python_sdk.models.feed import Feed from creatorsapi_python_sdk.models.get_feed_response_content import ( GetFeedResponseContent, @@ -63,7 +108,7 @@ from creatorsapi_python_sdk.models.variations_result import VariationsResult # API endpoints -API_HOST = "https://creatorsapi.amazon" +API_HOST = DEFAULT_HOST ENDPOINT_GET_ITEMS = "/catalog/v1/getItems" ENDPOINT_SEARCH_ITEMS = "/catalog/v1/searchItems" ENDPOINT_GET_VARIATIONS = "/catalog/v1/getVariations" @@ -123,10 +168,15 @@ class AsyncAmazonCreatorsApi: throttling: Wait time in seconds between API calls. Defaults to 1 second. timeout: Request timeout in seconds, or None to wait indefinitely. Defaults to 30 seconds. + retries: Extra attempts for the failures that Amazon asks to retry, + waiting longer before every attempt. Defaults to 3. + host: Base URL of the API. Defaults to the Amazon Creators API. + auth_endpoint: URL used to get the OAuth2 token. Defaults to the one + of the version in use. Raises: InvalidArgumentError: If neither country nor marketplace is provided, - or if timeout is not greater than zero. + 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). @@ -142,6 +192,9 @@ def __init__( marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, timeout: float | None = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + host: str = DEFAULT_HOST, + auth_endpoint: str | None = None, ) -> None: """Initialize the async Amazon Creators API client.""" # Validate version early to fail fast (before token manager initialization) @@ -150,11 +203,13 @@ def __init__( self._credential_id = credential_id self._credential_secret = credential_secret self._version = version - self._last_query_time = time.time() - throttling + 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.timeout = validate_timeout(timeout) + self.retries = validate_retries(retries) # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) @@ -165,6 +220,7 @@ def __init__( credential_id=credential_id, credential_secret=credential_secret, version=version, + auth_endpoint=auth_endpoint, timeout=self.timeout, ) self._owns_client = False @@ -186,7 +242,7 @@ def _validate_version(self, version: str) -> None: async def __aenter__(self) -> Self: """Enter async context manager, creating a persistent HTTP client.""" - self._http_client = AsyncHttpClient(host=API_HOST, timeout=self.timeout) + self._http_client = AsyncHttpClient(host=self.host, timeout=self.timeout) await self._http_client.__aenter__() self._owns_client = True return self @@ -210,9 +266,15 @@ async def get_items( currency_of_preference: str | None = None, languages_of_preference: list[str] | None = None, resources: list[GetItemsResource] | None = None, - ) -> list[Item]: + *, + include_unavailable: bool = False, + ) -> ResultList[Item]: """Get items information from Amazon. + 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. + Args: items: One or more items, using ASIN or Amazon product URL. Accepts a single string (comma-separated) or a list of strings. @@ -220,9 +282,13 @@ async def get_items( currency_of_preference: ISO 4217 currency code for prices. languages_of_preference: Languages in order of preference. resources: List of resources to retrieve. Defaults to all. + include_unavailable: Add an item holding only the ASIN for every + requested item missing from the response. Defaults to False. Returns: - List of Item objects with Amazon information. + List of Item objects with Amazon information, in the order of the + requested items, exposing the partial errors of the response in + its errors attribute. Raises: ItemsNotFoundError: If no items are found. @@ -232,30 +298,47 @@ async def get_items( if resources is None: resources = get_all_resources(GetItemsResource) - item_ids = get_items_ids(items) + item_ids = get_unique_items(get_items_ids(items)) - request_body = { - "partnerTag": self.tag, - "itemIds": item_ids, - "resources": [r.value for r in resources], - } - if condition is not None: - request_body["condition"] = condition.value - if currency_of_preference is not None: - request_body["currencyOfPreference"] = currency_of_preference - if languages_of_preference is not None: - request_body["languagesOfPreference"] = languages_of_preference - - response = await self._make_request(ENDPOINT_GET_ITEMS, request_body) - - items_result = response.get("itemsResult") - if items_result is None or items_result.get("items") is None: - msg = "No items have been found" + if not item_ids: + msg = "At least one item is required" + raise InvalidArgumentError(msg) + + found_items: list[Item] = [] + errors: list[ErrorData] = [] + + for chunk in get_item_chunks(item_ids): + request = build_request( + GetItemsRequestContent, + partnerTag=self.tag, + itemIds=chunk, + condition=condition, + currencyOfPreference=currency_of_preference, + languagesOfPreference=languages_of_preference, + resources=resources, + ) + + response = await self._make_request( + ENDPOINT_GET_ITEMS, + get_request_body(request), + ) + + errors.extend(self._deserialize_errors(response)) + + items_result = response.get("itemsResult") or {} + if items_result.get("items"): + found_items.extend(self._deserialize_items(items_result["items"])) + + if not found_items and not include_unavailable: + msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return self._deserialize_items(items_result["items"]) + return ResultList( + sort_items(found_items, item_ids, include_unavailable=include_unavailable), + errors=errors, + ) - async def search_items( # noqa: PLR0912, C901 + async def search_items( self, keywords: str | None = None, actor: str | None = None, @@ -267,6 +350,7 @@ async def search_items( # noqa: PLR0912, C901 search_index: str | None = None, item_count: int | None = None, item_page: int | None = None, + availability: Availability | None = None, condition: Condition | None = None, currency_of_preference: str | None = None, delivery_flags: list[DeliveryFlag] | None = None, @@ -292,8 +376,10 @@ async def search_items( # noqa: PLR0912, C901 title: Title associated with the item. browse_node_id: A unique ID for a product category. search_index: Product category to search. Defaults to All. - item_count: Number of items returned (1-10). Defaults to 10. + item_count: Number of items returned (1-100). Defaults to 10. item_page: Page of items to return (1-10). Defaults to 1. + availability: Filter results by availability. Defaults to + returning only the items available for purchase. condition: Filter offers by condition type. currency_of_preference: ISO 4217 currency code for prices. delivery_flags: Delivery programs to filter search results by. @@ -301,7 +387,7 @@ async def search_items( # noqa: PLR0912, C901 max_price: Max price in lowest currency denomination. min_price: Min price in lowest currency denomination. min_saving_percent: Min savings percentage (1-99). - min_reviews_rating: Min review rating (1-5). + min_reviews_rating: Min review rating (1-4). sort_by: Sort method for results. resources: List of resources to retrieve. Defaults to all. @@ -312,59 +398,55 @@ async def search_items( # noqa: PLR0912, C901 ItemsNotFoundError: If no items are found. """ + validate_search_criteria( + keywords=keywords, + actor=actor, + artist=artist, + author=author, + brand=brand, + title=title, + browse_node_id=browse_node_id, + search_index=search_index, + ) + if resources is None: resources = get_all_resources(SearchItemsResource) - request_body: dict[str, Any] = { - "partnerTag": self.tag, - "resources": [r.value for r in resources], - } + request = build_request( + SearchItemsRequestContent, + partnerTag=self.tag, + keywords=keywords, + actor=actor, + artist=artist, + author=author, + brand=brand, + title=title, + browseNodeId=browse_node_id, + searchIndex=search_index, + itemCount=item_count, + itemPage=item_page, + availability=availability, + condition=condition, + currencyOfPreference=currency_of_preference, + deliveryFlags=delivery_flags, + languagesOfPreference=languages_of_preference, + maxPrice=max_price, + minPrice=min_price, + minSavingPercent=min_saving_percent, + minReviewsRating=min_reviews_rating, + sortBy=sort_by, + resources=resources, + ) - # Add optional parameters - if keywords is not None: - request_body["keywords"] = keywords - if actor is not None: - request_body["actor"] = actor - if artist is not None: - request_body["artist"] = artist - if author is not None: - request_body["author"] = author - if brand is not None: - request_body["brand"] = brand - if title is not None: - request_body["title"] = title - if browse_node_id is not None: - request_body["browseNodeId"] = browse_node_id - if search_index is not None: - request_body["searchIndex"] = search_index - if item_count is not None: - request_body["itemCount"] = item_count - if item_page is not None: - request_body["itemPage"] = item_page - if condition is not None: - request_body["condition"] = condition.value - if currency_of_preference is not None: - request_body["currencyOfPreference"] = currency_of_preference - if delivery_flags is not None: - request_body["deliveryFlags"] = [flag.value for flag in delivery_flags] - if languages_of_preference is not None: - request_body["languagesOfPreference"] = languages_of_preference - if max_price is not None: - request_body["maxPrice"] = max_price - if min_price is not None: - request_body["minPrice"] = min_price - if min_saving_percent is not None: - request_body["minSavingPercent"] = min_saving_percent - if min_reviews_rating is not None: - request_body["minReviewsRating"] = min_reviews_rating - if sort_by is not None: - request_body["sortBy"] = sort_by.value - - response = await self._make_request(ENDPOINT_SEARCH_ITEMS, request_body) + response = await self._make_request( + ENDPOINT_SEARCH_ITEMS, + get_request_body(request), + ) search_result = response.get("searchResult") if search_result is None: - msg = "No items have been found" + errors = self._deserialize_errors(response) + msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) return self._deserialize_search_result(search_result) @@ -384,7 +466,8 @@ async def get_variations( Args: asin: The ASIN or Amazon product URL of the product. variation_count: Number of variations to return (1-10). Defaults to 10. - variation_page: Page of variations to return (1-10). Defaults to 1. + variation_page: Page of variations to return (1 or above). + Defaults to 1. condition: Filter offers by condition type. currency_of_preference: ISO 4217 currency code for prices. languages_of_preference: Languages in order of preference. @@ -400,30 +483,27 @@ async def get_variations( if resources is None: resources = get_all_resources(GetVariationsResource) - asin = get_asin(asin) - - request_body: dict[str, Any] = { - "partnerTag": self.tag, - "asin": asin, - "resources": [r.value for r in resources], - } - - if variation_count is not None: - request_body["variationCount"] = variation_count - if variation_page is not None: - request_body["variationPage"] = variation_page - if condition is not None: - request_body["condition"] = condition.value - if currency_of_preference is not None: - request_body["currencyOfPreference"] = currency_of_preference - if languages_of_preference is not None: - request_body["languagesOfPreference"] = languages_of_preference + request = build_request( + GetVariationsRequestContent, + partnerTag=self.tag, + asin=get_asin(asin), + variationCount=variation_count, + variationPage=variation_page, + condition=condition, + currencyOfPreference=currency_of_preference, + languagesOfPreference=languages_of_preference, + resources=resources, + ) - response = await self._make_request(ENDPOINT_GET_VARIATIONS, request_body) + response = await self._make_request( + ENDPOINT_GET_VARIATIONS, + get_request_body(request), + ) variations_result = response.get("variationsResult") if variations_result is None: - msg = "No variations have been found" + errors = self._deserialize_errors(response) + msg = f"No variations have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) return self._deserialize_variations_result(variations_result) @@ -433,7 +513,7 @@ async def get_browse_nodes( browse_node_ids: list[str], languages_of_preference: list[str] | None = None, resources: list[GetBrowseNodesResource] | None = None, - ) -> list[BrowseNode]: + ) -> ResultList[BrowseNode]: """Return browse node information including name, children, and ancestors. Args: @@ -442,7 +522,8 @@ async def get_browse_nodes( resources: List of resources to retrieve. Defaults to all. Returns: - List of BrowseNode objects. + List of BrowseNode objects, exposing the partial errors of the + response in its errors attribute. Raises: ItemsNotFoundError: If no browse nodes are found. @@ -451,26 +532,32 @@ async def get_browse_nodes( if resources is None: resources = get_all_resources(GetBrowseNodesResource) - request_body: dict[str, Any] = { - "partnerTag": self.tag, - "browseNodeIds": browse_node_ids, - "resources": [r.value for r in resources], - } - - if languages_of_preference is not None: - request_body["languagesOfPreference"] = languages_of_preference + request = build_request( + GetBrowseNodesRequestContent, + partnerTag=self.tag, + browseNodeIds=browse_node_ids, + languagesOfPreference=languages_of_preference, + resources=resources, + ) - response = await self._make_request(ENDPOINT_GET_BROWSE_NODES, request_body) + response = await self._make_request( + ENDPOINT_GET_BROWSE_NODES, + get_request_body(request), + ) + errors = self._deserialize_errors(response) browse_nodes_result = response.get("browseNodesResult") if ( browse_nodes_result is None or browse_nodes_result.get("browseNodes") is None ): - msg = "No browse nodes have been found" + msg = f"No browse nodes have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return self._deserialize_browse_nodes(browse_nodes_result["browseNodes"]) + return ResultList( + self._deserialize_browse_nodes(browse_nodes_result["browseNodes"]), + errors=errors, + ) async def list_feeds(self) -> list[Feed]: """Return the feeds available for your account. @@ -484,7 +571,10 @@ async def list_feeds(self) -> list[Feed]: RequestError: If the API request fails. """ - response = await self._make_request(ENDPOINT_LIST_FEEDS) + response = await self._make_request( + ENDPOINT_LIST_FEEDS, + not_found_error=ResourceNotFoundError, + ) return self._deserialize_feeds(response.get("feeds") or []) @@ -503,12 +593,17 @@ async def get_feed(self, feed_name: str, feed_type: FeedType | None = None) -> s RequestError: If the API request fails. """ - request_body: dict[str, Any] = {"feedName": feed_name} - - if feed_type is not None: - request_body["feedType"] = feed_type.value + request = build_request( + GetFeedRequestContent, + feedName=feed_name, + feedType=feed_type, + ) - response = await self._make_request(ENDPOINT_GET_FEED, request_body) + response = await self._make_request( + ENDPOINT_GET_FEED, + get_request_body(request), + not_found_error=ResourceNotFoundError, + ) return GetFeedResponseContent.model_validate(response).url @@ -524,7 +619,10 @@ async def list_reports(self) -> list[ReportMetadata]: RequestError: If the API request fails. """ - response = await self._make_request(ENDPOINT_LIST_REPORTS) + response = await self._make_request( + ENDPOINT_LIST_REPORTS, + not_found_error=ResourceNotFoundError, + ) return self._deserialize_reports(response.get("reports") or []) @@ -547,12 +645,17 @@ async def get_report( RequestError: If the API request fails. """ - request_body: dict[str, Any] = {"filename": filename} - - if report_type is not None: - request_body["reportType"] = report_type.value + request = build_request( + GetReportRequestContent, + filename=filename, + reportType=report_type, + ) - response = await self._make_request(ENDPOINT_GET_REPORT, request_body) + response = await self._make_request( + ENDPOINT_GET_REPORT, + get_request_body(request), + not_found_error=ResourceNotFoundError, + ) return GetReportResponseContent.model_validate(response).url @@ -567,32 +670,85 @@ async def _throttle(self) -> None: self._throttle_lock = asyncio.Lock() async with self._throttle_lock: - wait_time = self.throttling - (time.time() - self._last_query_time) + wait_time = self.throttling - (time.monotonic() - self._last_query_time) if wait_time > 0: await asyncio.sleep(wait_time) - self._last_query_time = time.time() + self._last_query_time = time.monotonic() async def _make_request( self, endpoint: str, body: dict[str, Any] | None = None, + not_found_error: type[AmazonCreatorsApiError] = ItemsNotFoundError, ) -> dict[str, Any]: - """Make an API request with authentication and throttling. + """Make an API request with authentication, throttling and retries. + + Throttled and server errors are retried waiting longer before every + attempt, honouring the Retry-After header when the API sends it. An + expired token is refreshed once and the request is sent again. Args: endpoint: API endpoint path. body: Request body, omitted for operations that take no payload. + not_found_error: Exception raised when the resource is missing. Returns: Parsed JSON response. Raises: - Various exceptions based on API errors. + RequestError: If the request cannot be completed. """ - await self._throttle() + attempt = 0 + token_refreshed = False + + while True: + await self._throttle() + + try: + response = await self._post(endpoint, body) + except httpx.HTTPError as error: + if attempt >= self.retries: + msg = f"Request failed: {error}" + raise RequestError(msg) from error + await asyncio.sleep(get_retry_delay(attempt)) + attempt += 1 + continue + + if response.status_code == HTTP_OK: + return self._parse_response(response) + + if response.status_code == HTTP_UNAUTHORIZED and not token_refreshed: + token_refreshed = True + self._token_manager.clear_token() + continue + + if not is_retryable(response.status_code) or attempt >= self.retries: + handle_api_error( + response.status_code, + response.text, + not_found_error, + response.headers, + ) + + await asyncio.sleep(get_retry_delay(attempt, response.headers)) + attempt += 1 + + async def _post( + self, + endpoint: str, + body: dict[str, Any] | None, + ) -> AsyncHttpResponse: + """Send an authenticated request to the API. + + Args: + endpoint: API endpoint path. + body: Request body, omitted for operations that take no payload. - # Get auth token + Returns: + The response of the API. + + """ token = await self._token_manager.get_token() headers = { @@ -603,16 +759,29 @@ async def _make_request( # Use persistent client if available, otherwise create a new one if self._http_client is not None: - response = await self._http_client.post(endpoint, headers, body) - else: - async with AsyncHttpClient(host=API_HOST, timeout=self.timeout) as client: - response = await client.post(endpoint, headers, body) + return await self._http_client.post(endpoint, headers, body) + + async with AsyncHttpClient(host=self.host, timeout=self.timeout) as client: + return await client.post(endpoint, headers, body) + + def _parse_response(self, response: AsyncHttpResponse) -> dict[str, Any]: + """Parse a successful response as JSON. + + Args: + response: Response of the API. + + Returns: + The parsed response body. - # Handle errors - if response.status_code != 200: # noqa: PLR2004 - self._handle_error_response(response.status_code, response.text) + Raises: + RequestError: If the response is not valid JSON. - return response.json() + """ + try: + return response.json() + except ValueError as error: + msg = f"Failed to parse the response from Amazon: {error}" + raise RequestError(msg) from error def _build_authorization_header(self, token: str) -> str: """Build the version-appropriate Authorization header.""" @@ -620,22 +789,11 @@ def _build_authorization_header(self, token: str) -> str: return f"Bearer {token}" return f"Bearer {token}, Version {self._version}" - def _handle_error_response(self, status_code: int, body: str) -> None: - """Handle API error responses and raise appropriate exceptions. - - Args: - status_code: HTTP status code. - body: Response body text. - - Raises: - ItemsNotFoundError: For 404 errors. - TooManyRequestsError: For 429 errors. - InvalidArgumentError: For validation errors. - AssociateValidationError: For invalid associate credentials. - RequestError: For other errors. - - """ - handle_api_error(status_code, body) + def _deserialize_errors(self, response: dict[str, Any]) -> list[ErrorData]: + """Deserialize the partial errors of a response to ErrorData models.""" + return [ + ErrorData.model_validate(error) for error in response.get("errors") or [] + ] def _deserialize_items(self, items_data: list[dict[str, Any]]) -> list[Item]: """Deserialize item data from API response to Item models.""" diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index c06520b..1906493 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -5,20 +5,47 @@ from __future__ import annotations +import threading import time -from typing import TYPE_CHECKING, NoReturn +from typing import TYPE_CHECKING, Any, Callable, NoReturn, TypeVar -from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING, DEFAULT_TIMEOUT -from amazon_creatorsapi.core.error_handling import handle_api_error +import urllib3 + +from amazon_creatorsapi.core.auth import TimeoutOAuth2TokenManager +from amazon_creatorsapi.core.constants import ( + DEFAULT_HOST, + DEFAULT_THROTTLING, + DEFAULT_TIMEOUT, + HTTP_UNAUTHORIZED, +) +from amazon_creatorsapi.core.error_handling import format_errors, handle_api_error +from amazon_creatorsapi.core.items import ( + get_item_chunks, + get_unique_items, + sort_items, +) 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 +from amazon_creatorsapi.core.retry import DEFAULT_RETRIES, get_retry_delay, is_retryable from amazon_creatorsapi.core.validation import ( + build_request, validate_and_get_marketplace, + validate_retries, + validate_search_criteria, validate_timeout, ) -from amazon_creatorsapi.errors import ItemsNotFoundError +from amazon_creatorsapi.errors import ( + AmazonCreatorsApiError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + ResourceNotFoundError, +) from creatorsapi_python_sdk.api.default_api import DefaultApi from creatorsapi_python_sdk.api_client import ApiClient +from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config +from creatorsapi_python_sdk.configuration import Configuration from creatorsapi_python_sdk.exceptions import ApiException from creatorsapi_python_sdk.models.get_browse_nodes_request_content import ( GetBrowseNodesRequestContent, @@ -47,9 +74,11 @@ if TYPE_CHECKING: from amazon_creatorsapi.core.marketplaces import CountryCode + from creatorsapi_python_sdk.models.availability import Availability from creatorsapi_python_sdk.models.browse_node import BrowseNode from creatorsapi_python_sdk.models.condition import Condition from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag + from creatorsapi_python_sdk.models.error_data import ErrorData from creatorsapi_python_sdk.models.feed import Feed from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.item import Item @@ -59,6 +88,8 @@ from creatorsapi_python_sdk.models.sort_by import SortBy from creatorsapi_python_sdk.models.variations_result import VariationsResult +ResponseT = TypeVar("ResponseT") + class AmazonCreatorsApi: """Provides methods to get information from Amazon using the Creators API. @@ -73,10 +104,15 @@ class AmazonCreatorsApi: throttling: Wait time in seconds between API calls. Defaults to 1 second. timeout: Request timeout in seconds, or None to wait indefinitely. Defaults to 30 seconds. + retries: Extra attempts for the failures that Amazon asks to retry, + waiting longer before every attempt. Defaults to 3. + host: Base URL of the API. Defaults to the Amazon Creators API. + auth_endpoint: URL used to get the OAuth2 token. Defaults to the one + of the version in use. Raises: InvalidArgumentError: If neither country nor marketplace is provided, - or if timeout is not greater than zero. + if timeout is not greater than zero, or if retries is negative. Example: >>> api = AmazonCreatorsApi( @@ -100,23 +136,40 @@ def __init__( marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, timeout: float | None = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + host: str = DEFAULT_HOST, + auth_endpoint: str | None = None, ) -> None: """Initialize the Amazon Creators API client.""" self._credential_id = credential_id self._credential_secret = credential_secret self._version = version - self._last_query_time = time.time() - throttling + self._last_query_time = time.monotonic() - throttling + self._throttle_lock = threading.Lock() self.tag = tag self.throttling = float(throttling) self.timeout = validate_timeout(timeout) + self.retries = validate_retries(retries) # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) + # A new configuration for every client, as the default one of the + # SDK is shared by the whole process self._api_client = ApiClient( + configuration=Configuration(), credential_id=credential_id, credential_secret=credential_secret, version=version, + host=host, + auth_endpoint=auth_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), + self.timeout, ) self._api = DefaultApi(self._api_client) @@ -127,9 +180,15 @@ def get_items( currency_of_preference: str | None = None, languages_of_preference: list[str] | None = None, resources: list[GetItemsResource] | None = None, - ) -> list[Item]: + *, + include_unavailable: bool = False, + ) -> ResultList[Item]: """Get items information from Amazon. + 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. + Args: items: One or more items, using ASIN or Amazon product URL. Accepts a single string (comma-separated) or a list of strings. @@ -137,9 +196,13 @@ def get_items( currency_of_preference: ISO 4217 currency code for prices. languages_of_preference: Languages in order of preference. resources: List of resources to retrieve. Defaults to all. + include_unavailable: Add an item holding only the ASIN for every + requested item missing from the response. Defaults to False. Returns: - List of Item objects with Amazon information. + List of Item objects with Amazon information, in the order of the + requested items, exposing the partial errors of the response in + its errors attribute. Raises: ItemsNotFoundError: If no items are found. @@ -149,33 +212,44 @@ def get_items( if resources is None: resources = get_all_resources(GetItemsResource) - item_ids = get_items_ids(items) + item_ids = get_unique_items(get_items_ids(items)) - request = GetItemsRequestContent( - partnerTag=self.tag, - itemIds=item_ids, - condition=condition, - currencyOfPreference=currency_of_preference, - languagesOfPreference=languages_of_preference, - resources=resources, - ) + if not item_ids: + msg = "At least one item is required" + raise InvalidArgumentError(msg) - self._throttle() + found_items: list[Item] = [] + errors: list[ErrorData] = [] - try: - response = self._api.get_items( - x_marketplace=self.marketplace, + for chunk in get_item_chunks(item_ids): + request = build_request( + GetItemsRequestContent, + partnerTag=self.tag, + itemIds=chunk, + condition=condition, + currencyOfPreference=currency_of_preference, + languagesOfPreference=languages_of_preference, + resources=resources, + ) + + response = self._call( + self._api.get_items, get_items_request_content=request, - _request_timeout=self.timeout, ) - except ApiException as exc: - self._handle_api_exception(exc) - if response.items_result is None or response.items_result.items is None: - msg = "No items have been found" + errors.extend(response.errors or []) + + 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: + msg = f"No items have been found{format_errors(errors)}" raise ItemsNotFoundError(msg) - return response.items_result.items + return ResultList( + sort_items(found_items, item_ids, include_unavailable=include_unavailable), + errors=errors, + ) def search_items( self, @@ -189,6 +263,7 @@ def search_items( search_index: str | None = None, item_count: int | None = None, item_page: int | None = None, + availability: Availability | None = None, condition: Condition | None = None, currency_of_preference: str | None = None, delivery_flags: list[DeliveryFlag] | None = None, @@ -214,8 +289,10 @@ def search_items( title: Title associated with the item. browse_node_id: A unique ID for a product category. search_index: Product category to search. Defaults to All. - item_count: Number of items returned (1-10). Defaults to 10. + item_count: Number of items returned (1-100). Defaults to 10. item_page: Page of items to return (1-10). Defaults to 1. + availability: Filter results by availability. Defaults to + returning only the items available for purchase. condition: Filter offers by condition type. currency_of_preference: ISO 4217 currency code for prices. delivery_flags: Delivery programs to filter search results by. @@ -223,7 +300,7 @@ def search_items( max_price: Max price in lowest currency denomination. min_price: Min price in lowest currency denomination. min_saving_percent: Min savings percentage (1-99). - min_reviews_rating: Min review rating (1-5). + min_reviews_rating: Min review rating (1-4). sort_by: Sort method for results. resources: List of resources to retrieve. Defaults to all. @@ -234,10 +311,22 @@ def search_items( ItemsNotFoundError: If no items are found. """ + validate_search_criteria( + keywords=keywords, + actor=actor, + artist=artist, + author=author, + brand=brand, + title=title, + browse_node_id=browse_node_id, + search_index=search_index, + ) + if resources is None: resources = get_all_resources(SearchItemsResource) - request = SearchItemsRequestContent( + request = build_request( + SearchItemsRequestContent, partnerTag=self.tag, keywords=keywords, actor=actor, @@ -249,6 +338,7 @@ def search_items( searchIndex=search_index, itemCount=item_count, itemPage=item_page, + availability=availability, condition=condition, currencyOfPreference=currency_of_preference, deliveryFlags=delivery_flags, @@ -261,19 +351,13 @@ def search_items( resources=resources, ) - self._throttle() - - try: - response = self._api.search_items( - x_marketplace=self.marketplace, - search_items_request_content=request, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.search_items, + search_items_request_content=request, + ) if response.search_result is None: - msg = "No items have been found" + msg = f"No items have been found{format_errors(response.errors)}" raise ItemsNotFoundError(msg) return response.search_result @@ -293,7 +377,8 @@ def get_variations( Args: asin: The ASIN or Amazon product URL of the product. variation_count: Number of variations to return (1-10). Defaults to 10. - variation_page: Page of variations to return (1-10). Defaults to 1. + variation_page: Page of variations to return (1 or above). + Defaults to 1. condition: Filter offers by condition type. currency_of_preference: ISO 4217 currency code for prices. languages_of_preference: Languages in order of preference. @@ -311,7 +396,8 @@ def get_variations( asin = get_asin(asin) - request = GetVariationsRequestContent( + request = build_request( + GetVariationsRequestContent, partnerTag=self.tag, asin=asin, variationCount=variation_count, @@ -322,19 +408,13 @@ def get_variations( resources=resources, ) - self._throttle() - - try: - response = self._api.get_variations( - x_marketplace=self.marketplace, - get_variations_request_content=request, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.get_variations, + get_variations_request_content=request, + ) if response.variations_result is None: - msg = "No variations have been found" + msg = f"No variations have been found{format_errors(response.errors)}" raise ItemsNotFoundError(msg) return response.variations_result @@ -344,7 +424,7 @@ def get_browse_nodes( browse_node_ids: list[str], languages_of_preference: list[str] | None = None, resources: list[GetBrowseNodesResource] | None = None, - ) -> list[BrowseNode]: + ) -> ResultList[BrowseNode]: """Return browse node information including name, children, and ancestors. Args: @@ -353,7 +433,8 @@ def get_browse_nodes( resources: List of resources to retrieve. Defaults to all. Returns: - List of BrowseNode objects. + List of BrowseNode objects, exposing the partial errors of the + response in its errors attribute. Raises: ItemsNotFoundError: If no browse nodes are found. @@ -362,32 +443,30 @@ def get_browse_nodes( if resources is None: resources = get_all_resources(GetBrowseNodesResource) - request = GetBrowseNodesRequestContent( + request = build_request( + GetBrowseNodesRequestContent, partnerTag=self.tag, browseNodeIds=browse_node_ids, languagesOfPreference=languages_of_preference, resources=resources, ) - self._throttle() - - try: - response = self._api.get_browse_nodes( - x_marketplace=self.marketplace, - get_browse_nodes_request_content=request, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.get_browse_nodes, + get_browse_nodes_request_content=request, + ) if ( response.browse_nodes_result is None or response.browse_nodes_result.browse_nodes is None ): - msg = "No browse nodes have been found" + msg = f"No browse nodes have been found{format_errors(response.errors)}" raise ItemsNotFoundError(msg) - return response.browse_nodes_result.browse_nodes + return ResultList( + response.browse_nodes_result.browse_nodes, + errors=response.errors, + ) def list_feeds(self) -> list[Feed]: """Return the feeds available for your account. @@ -401,15 +480,10 @@ def list_feeds(self) -> list[Feed]: RequestError: If the API request fails. """ - self._throttle() - - try: - response = self._api.list_feeds( - x_marketplace=self.marketplace, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.list_feeds, + not_found_error=ResourceNotFoundError, + ) return response.feeds or [] @@ -428,18 +502,17 @@ def get_feed(self, feed_name: str, feed_type: FeedType | None = None) -> str: RequestError: If the API request fails. """ - request = GetFeedRequestContent(feedName=feed_name, feedType=feed_type) - - self._throttle() + request = build_request( + GetFeedRequestContent, + feedName=feed_name, + feedType=feed_type, + ) - try: - response = self._api.get_feed( - x_marketplace=self.marketplace, - get_feed_request_content=request, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.get_feed, + not_found_error=ResourceNotFoundError, + get_feed_request_content=request, + ) return response.url @@ -455,15 +528,10 @@ def list_reports(self) -> list[ReportMetadata]: RequestError: If the API request fails. """ - self._throttle() - - try: - response = self._api.list_reports( - x_marketplace=self.marketplace, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.list_reports, + not_found_error=ResourceNotFoundError, + ) return response.reports @@ -482,33 +550,104 @@ def get_report(self, filename: str, report_type: ReportType | None = None) -> st RequestError: If the API request fails. """ - request = GetReportRequestContent(filename=filename, reportType=report_type) - - self._throttle() + request = build_request( + GetReportRequestContent, + filename=filename, + reportType=report_type, + ) - try: - response = self._api.get_report( - x_marketplace=self.marketplace, - get_report_request_content=request, - _request_timeout=self.timeout, - ) - except ApiException as exc: - self._handle_api_exception(exc) + response = self._call( + self._api.get_report, + not_found_error=ResourceNotFoundError, + get_report_request_content=request, + ) return response.url def _throttle(self) -> None: - """Wait for the throttling interval to elapse since the last API call.""" - wait_time = self.throttling - (time.time() - self._last_query_time) - if wait_time > 0: - time.sleep(wait_time) - self._last_query_time = time.time() + """Wait for the throttling interval to elapse since the last API call. + + Uses a lock to keep the interval between calls when the client is + shared by several threads. + """ + with self._throttle_lock: + wait_time = self.throttling - (time.monotonic() - self._last_query_time) + if wait_time > 0: + time.sleep(wait_time) + self._last_query_time = time.monotonic() + + def _call( + self, + operation: Callable[..., ResponseT], + *, + not_found_error: type[AmazonCreatorsApiError] = ItemsNotFoundError, + **kwargs: Any, + ) -> ResponseT: + """Call an operation of the SDK, retrying the failures worth retrying. + + Throttled and server errors are retried waiting longer before every + attempt, honouring the Retry-After header when the API sends it. An + expired token is refreshed once and the request is sent again. - def _handle_api_exception(self, error: ApiException) -> NoReturn: + Args: + operation: Operation of the SDK to call. + not_found_error: Exception raised when the resource is missing. + kwargs: Arguments for the operation. + + Returns: + The response of the operation. + + Raises: + RequestError: If the request cannot be completed. + + """ + attempt = 0 + token_refreshed = False + + while True: + self._throttle() + + try: + return operation( + x_marketplace=self.marketplace, + _request_timeout=self.timeout, + **kwargs, + ) + except ApiException as error: + if error.status == HTTP_UNAUTHORIZED and not token_refreshed: + token_refreshed = True + self._clear_token() + continue + + if not is_retryable(error.status) or attempt >= self.retries: + self._handle_api_exception(error, not_found_error) + + time.sleep(get_retry_delay(attempt, error.headers)) + except urllib3.exceptions.HTTPError as error: + if attempt >= self.retries: + msg = f"Request failed: {error}" + raise RequestError(msg) from error + + time.sleep(get_retry_delay(attempt)) + + attempt += 1 + + def _clear_token(self) -> None: + """Discard the cached token so the next request asks for a new one.""" + token_manager = self._api_client.token_manager + if token_manager is not None: + token_manager.clear_token() + + def _handle_api_exception( + self, + error: ApiException, + not_found_error: type[AmazonCreatorsApiError] = ItemsNotFoundError, + ) -> NoReturn: """Handle API exceptions and raise appropriate custom exceptions.""" - error_body = str(error.body) if error.body else "" + body = error.body if isinstance(error.body, str) else "" + try: - handle_api_error(error.status, error_body) - except Exception as exc: + handle_api_error(error.status, body, not_found_error, error.headers) + 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 new file mode 100644 index 0000000..a43654c --- /dev/null +++ b/amazon_creatorsapi/core/auth.py @@ -0,0 +1,130 @@ +"""OAuth2 token manager that applies a timeout to the token requests.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +import requests + +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. + + Args: + config: OAuth2 configuration with the credentials and the endpoint. + timeout: Token request timeout in seconds, or None to wait + indefinitely. + + """ + + def __init__(self, config: OAuth2Config, timeout: float | None) -> None: + """Initialize the token manager with its timeout.""" + super().__init__(config) + self._timeout = timeout + + def refresh_token(self) -> str: + """Refresh the OAuth2 access token using the client credentials grant. + + Returns: + The new access token. + + Raises: + AuthenticationError: If the token cannot be obtained. + + """ + response = self._request_token() + + if response.status_code != HTTP_OK: + self.clear_token() + msg = ( + f"OAuth2 token request failed with status {response.status_code}: " + f"{response.text}" + ) + raise AuthenticationError(msg) + + data = self._parse_token_response(response) + + if "access_token" not in data: + self.clear_token() + msg = "No access token received from OAuth2 endpoint" + raise AuthenticationError(msg) + + self.access_token = data["access_token"] + expires_in = data.get("expires_in", DEFAULT_EXPIRATION) + self.expires_at = time.time() + expires_in - TOKEN_EXPIRATION_BUFFER + + return str(self.access_token) + + def _request_token(self) -> requests.Response: + """Request a new token to the auth endpoint. + + Returns: + The response from the auth endpoint. + + Raises: + AuthenticationError: If the request cannot be completed. + + """ + request_data = { + "grant_type": self.config.get_grant_type(), + "client_id": self.config.get_credential_id(), + "client_secret": self.config.get_credential_secret(), + "scope": self.config.get_scope(), + } + endpoint = self.config.get_cognito_endpoint() + + try: + if self.config.is_lwa(): + return requests.post( + endpoint, + json=request_data, + headers={"Content-Type": "application/json"}, + timeout=self._timeout, + ) + return requests.post( + endpoint, + data=request_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=self._timeout, + ) + except requests.RequestException as error: + self.clear_token() + msg = f"OAuth2 token request failed: {error}" + raise AuthenticationError(msg) from error + + def _parse_token_response(self, response: requests.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 diff --git a/amazon_creatorsapi/core/constants.py b/amazon_creatorsapi/core/constants.py index f502ae7..8797cfa 100644 --- a/amazon_creatorsapi/core/constants.py +++ b/amazon_creatorsapi/core/constants.py @@ -1,8 +1,16 @@ """Constants for the Amazon Creators API.""" +DEFAULT_HOST = "https://creatorsapi.amazon" DEFAULT_THROTTLING = 1 DEFAULT_TIMEOUT = 30.0 +# Maximum amount of item identifiers accepted in a single request +MAX_ITEMS_PER_REQUEST = 10 + # HTTP status codes +HTTP_OK = 200 +HTTP_BAD_REQUEST = 400 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_TOO_MANY_REQUESTS = 429 diff --git a/amazon_creatorsapi/core/error_handling.py b/amazon_creatorsapi/core/error_handling.py index 80c4e70..51eff9d 100644 --- a/amazon_creatorsapi/core/error_handling.py +++ b/amazon_creatorsapi/core/error_handling.py @@ -2,54 +2,186 @@ from __future__ import annotations -from typing import NoReturn +import json +from typing import TYPE_CHECKING, Any, NoReturn -from amazon_creatorsapi.core.constants import HTTP_NOT_FOUND, HTTP_TOO_MANY_REQUESTS +from amazon_creatorsapi.core.constants import ( + HTTP_BAD_REQUEST, + HTTP_FORBIDDEN, + HTTP_NOT_FOUND, + HTTP_TOO_MANY_REQUESTS, + HTTP_UNAUTHORIZED, +) from amazon_creatorsapi.errors import ( + AccessDeniedError, + AmazonCreatorsApiError, AssociateValidationError, + AuthenticationError, InvalidArgumentError, ItemsNotFoundError, RequestError, TooManyRequestsError, ) +if TYPE_CHECKING: + from collections.abc import Mapping + + from creatorsapi_python_sdk.models.error_data import ErrorData + +# Reason returned by the API when the associate is not valid for the marketplace +INVALID_ASSOCIATE_REASON = "InvalidAssociate" + +# Amount of characters of the response body kept for unexpected errors +MAX_BODY_LENGTH = 200 + +# Headers holding the identifier Amazon gives to a request +REQUEST_ID_HEADERS = ("x-amzn-requestid", "x-amzn-request-id", "x-amz-request-id") + + +def parse_error_body(body: str) -> dict[str, Any]: + """Parse the body of an error response. + + Args: + body: Response body text. + + Returns: + The parsed body, or an empty dictionary when it is not a JSON object. + + """ + try: + data = json.loads(body) + except (TypeError, ValueError): + return {} + + return data if isinstance(data, dict) else {} + + +def get_error_detail(data: dict[str, Any], body: str) -> str: + """Build a readable detail from the contents of an error response. + + Args: + data: Parsed body of the error response. + body: Original response body text. + + Returns: + The details of the error as text, empty when there are none. + + """ + parts = [str(value) for value in (data.get("reason"), data.get("message")) if value] + + parts.extend( + f"{field.get('name')}: {field.get('message')}" + for field in data.get("fieldList") or [] + if isinstance(field, dict) + ) + + parts.extend( + f"{name}: {data[name]}" + for name in ("resourceType", "resourceId") + if data.get(name) + ) + + if not parts and body: + parts.append(body[:MAX_BODY_LENGTH]) + + return f" - {'; '.join(parts)}" if parts else "" -def handle_api_error(status_code: int, body: str) -> NoReturn: + +def get_request_id(headers: Mapping[str, str] | None) -> str | None: + """Return the identifier Amazon gave to the request, if it sent one. + + Args: + headers: Headers of the response. + + Returns: + The identifier of the request, useful to report an issue to Amazon, + or None when the response does not carry one. + + """ + if not headers: + return None + + for name, value in headers.items(): + if name.lower() in REQUEST_ID_HEADERS: + return str(value) + + return None + + +def handle_api_error( + status_code: int, + body: str, + not_found_error: type[AmazonCreatorsApiError] = ItemsNotFoundError, + headers: Mapping[str, str] | None = None, +) -> NoReturn: """Handle API error responses and raise appropriate exceptions. Args: status_code: HTTP status code. body: Response body text. + not_found_error: Exception raised for a missing resource, so that + operations tell apart items from feeds and reports. + headers: Headers of the response, used to report the identifier that + Amazon gave to the request. Raises: - ItemsNotFoundError: For 404 errors. - TooManyRequestsError: For 429 errors. - InvalidArgumentError: For validation errors. + InvalidArgumentError: For requests rejected by the API. AssociateValidationError: For invalid associate credentials. - RequestError: For other errors. + AuthenticationError: For missing or invalid credentials. + AccessDeniedError: For credentials without access to the operation. + ItemsNotFoundError: For missing items, unless another error is given. + TooManyRequestsError: For throttled requests. + RequestError: For any other error. """ + data = parse_error_body(body) + detail = get_error_detail(data, body) + 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 + ): + msg = f"Credentials are not valid for the selected marketplace{detail}" + raise AssociateValidationError(msg) + msg = f"The request was rejected by Amazon{detail}" + raise InvalidArgumentError(msg) + + if status_code == HTTP_UNAUTHORIZED: + msg = f"Authentication failed{detail}" + raise AuthenticationError(msg) + + if status_code == HTTP_FORBIDDEN: + msg = f"Access denied for the requested operation{detail}" + raise AccessDeniedError(msg) + if status_code == HTTP_NOT_FOUND: - msg = "No items found for the request" - raise ItemsNotFoundError(msg) + msg = f"No results found for the request{detail}" + raise not_found_error(msg) if status_code == HTTP_TOO_MANY_REQUESTS: - msg = "Rate limit exceeded, try increasing throttling" + msg = f"Rate limit exceeded, try increasing throttling{detail}" raise TooManyRequestsError(msg) - if "InvalidParameterValue" in body: - msg = "Invalid parameter value provided in the request" - raise InvalidArgumentError(msg) + msg = f"Request failed with status {status_code}{detail}" + raise RequestError(msg) - if "InvalidPartnerTag" in body: - msg = "The partner tag is invalid or not present" - raise InvalidArgumentError(msg) - if "InvalidAssociate" in body: - msg = "Credentials are not valid for the selected marketplace" - raise AssociateValidationError(msg) +def format_errors(errors: list[ErrorData] | None) -> str: + """Return a readable summary of the partial errors of a response. - # Generic error - body_info = f" - {body[:200]}" if body else "" - msg = f"Request failed with status {status_code}{body_info}" - raise RequestError(msg) + Args: + errors: Partial errors returned by the API, if any. + + Returns: + The errors as text, or an empty string when there are none. + + """ + if not errors: + return "" + details = "; ".join(f"{error.code}: {error.message}" for error in errors) + return f" ({details})" diff --git a/amazon_creatorsapi/core/items.py b/amazon_creatorsapi/core/items.py new file mode 100644 index 0000000..60a2c4b --- /dev/null +++ b/amazon_creatorsapi/core/items.py @@ -0,0 +1,69 @@ +"""Utilities to prepare and order the items of a request.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from amazon_creatorsapi.core.constants import MAX_ITEMS_PER_REQUEST +from creatorsapi_python_sdk.models.item import Item + +if TYPE_CHECKING: + from collections.abc import Iterator + + +def get_unique_items(item_ids: list[str]) -> list[str]: + """Remove duplicated identifiers, keeping the order of the original list. + + Args: + item_ids: List of item identifiers, possibly with duplicates. + + Returns: + The identifiers without duplicates, in their original order. + + """ + return list(dict.fromkeys(item_ids)) + + +def get_item_chunks(item_ids: list[str]) -> Iterator[list[str]]: + """Split the identifiers into chunks of the size accepted by Amazon. + + Args: + item_ids: List of item identifiers. + + Yields: + Chunks of identifiers, none of them above the API limit. + + """ + for index in range(0, len(item_ids), MAX_ITEMS_PER_REQUEST): + yield item_ids[index : index + MAX_ITEMS_PER_REQUEST] + + +def sort_items( + items: list[Item], + item_ids: list[str], + *, + include_unavailable: bool, +) -> list[Item]: + """Sort the items following the order of the requested identifiers. + + Args: + items: Items returned by Amazon, in any order. + item_ids: Requested identifiers, in the order they were asked for. + include_unavailable: Add an item holding only the ASIN for every + identifier missing from the response. + + Returns: + The items in the order of the requested identifiers. + + """ + items_by_asin = {item.asin: item for item in items if item.asin is not None} + sorted_items: list[Item] = [] + + for asin in item_ids: + item = items_by_asin.get(asin) + if item is not None: + sorted_items.append(item) + elif include_unavailable: + sorted_items.append(Item(asin=asin)) + + return sorted_items diff --git a/amazon_creatorsapi/core/requests.py b/amazon_creatorsapi/core/requests.py new file mode 100644 index 0000000..94e022e --- /dev/null +++ b/amazon_creatorsapi/core/requests.py @@ -0,0 +1,27 @@ +"""Utilities to build the body of a request for the API.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from pydantic import BaseModel + + +def get_request_body(request: BaseModel) -> dict[str, Any]: + """Return the body to send to the API for a request model. + + Args: + request: Request model from the SDK. + + Returns: + The values of the request using the names of the API, without the + ones that were not provided. + + """ + body: dict[str, Any] = request.model_dump( + by_alias=True, + exclude_none=True, + mode="json", + ) + return body diff --git a/amazon_creatorsapi/core/results.py b/amazon_creatorsapi/core/results.py new file mode 100644 index 0000000..9f9b00a --- /dev/null +++ b/amazon_creatorsapi/core/results.py @@ -0,0 +1,36 @@ +"""Containers for API results that also carry partial errors.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +if TYPE_CHECKING: + from collections.abc import Iterable + + from creatorsapi_python_sdk.models.error_data import ErrorData + +ResultT = TypeVar("ResultT") + + +class ResultList(list[ResultT]): + """List of results that also exposes the partial errors sent by Amazon. + + A request for several identifiers can succeed while only returning some of + them, listing the reason for the missing ones as partial errors. This list + behaves like any other list and keeps those errors available. + + Example: + >>> items = api.get_items(["B0DLFMFBJW", "0000000000"]) + >>> for error in items.errors: + ... print(error.code, error.message) + + """ + + def __init__( + self, + results: Iterable[ResultT] = (), + errors: Iterable[ErrorData] | None = None, + ) -> None: + """Initialize the list with its results and their partial errors.""" + super().__init__(results) + self.errors: list[ErrorData] = list(errors) if errors else [] diff --git a/amazon_creatorsapi/core/retry.py b/amazon_creatorsapi/core/retry.py new file mode 100644 index 0000000..742db91 --- /dev/null +++ b/amazon_creatorsapi/core/retry.py @@ -0,0 +1,76 @@ +"""Utilities to retry the requests that Amazon asks to retry.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + +# Amount of extra attempts made for a failure that can be retried +DEFAULT_RETRIES = 3 + +# Seconds waited before the first retry, doubled on every following one +BACKOFF_FACTOR = 1.0 +MAX_BACKOFF = 30.0 + +# Status codes that Amazon asks to retry with exponential backoff +RETRY_STATUS_CODES = frozenset({429, 500, 502, 503, 504}) + +RETRY_AFTER_HEADER = "retry-after" + + +def is_retryable(status_code: int | None) -> bool: + """Return whether a status code is worth retrying. + + Args: + status_code: Status code of the response, if there is one. + + Returns: + True when the request can be retried, False otherwise. + + """ + return status_code in RETRY_STATUS_CODES + + +def get_retry_after(headers: Mapping[str, str] | None) -> float | None: + """Return the seconds requested by the Retry-After header, if any. + + Args: + headers: Headers of the response. + + Returns: + The amount of seconds to wait, or None when the header is missing or + does not hold an amount of seconds. + + """ + if not headers: + return None + + for name, value in headers.items(): + if name.lower() != RETRY_AFTER_HEADER: + continue + try: + return max(float(value), 0.0) + except (TypeError, ValueError): + return None + + return None + + +def get_retry_delay(attempt: int, headers: Mapping[str, str] | None = None) -> float: + """Return the seconds to wait before the next attempt. + + Args: + attempt: Amount of retries already made for the request. + headers: Headers of the response, used to honour Retry-After. + + Returns: + The amount of seconds to wait, never above the maximum backoff. + + """ + retry_after = get_retry_after(headers) + if retry_after is not None: + return min(retry_after, MAX_BACKOFF) + + return min(BACKOFF_FACTOR * 2.0**attempt, MAX_BACKOFF) diff --git a/amazon_creatorsapi/core/validation.py b/amazon_creatorsapi/core/validation.py index f3d3787..f12d3c1 100644 --- a/amazon_creatorsapi/core/validation.py +++ b/amazon_creatorsapi/core/validation.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypeVar + +from pydantic import BaseModel, ValidationError from amazon_creatorsapi.core.marketplaces import MARKETPLACES from amazon_creatorsapi.errors import InvalidArgumentError @@ -10,6 +12,8 @@ if TYPE_CHECKING: from amazon_creatorsapi.core.marketplaces import CountryCode +RequestT = TypeVar("RequestT", bound=BaseModel) + def validate_and_get_marketplace( country: CountryCode | None, @@ -59,3 +63,63 @@ def validate_timeout(timeout: float | None) -> float | None: msg = "Timeout must be greater than zero, or None to wait indefinitely" raise InvalidArgumentError(msg) return float(timeout) + + +def build_request(request_class: type[RequestT], **fields: Any) -> RequestT: + """Build a request for the SDK, validating the values it receives. + + Args: + request_class: Request model from the SDK. + fields: Values for the request, using the names of the API. + + Returns: + The request model filled with the provided values. + + Raises: + InvalidArgumentError: If any value is rejected by the API constraints. + + """ + try: + return request_class(**fields) + except ValidationError as error: + details = "; ".join( + f"{'.'.join(str(location) for location in issue['loc'])}: {issue['msg']}" + for issue in error.errors() + ) + msg = f"Invalid parameters for the request: {details}" + raise InvalidArgumentError(msg) from error + + +def validate_retries(retries: int) -> int: + """Validate the amount of retries for a failed request. + + Args: + retries: Amount of extra attempts for a failure that can be retried. + + Returns: + The amount of retries as an integer. + + Raises: + InvalidArgumentError: If the amount of retries is negative. + + """ + if retries < 0: + msg = "Retries must be zero or greater" + raise InvalidArgumentError(msg) + return int(retries) + + +def validate_search_criteria(**criteria: object) -> None: + """Validate that a search has at least one criterion to look for. + + Args: + criteria: Arguments of the search, by name. + + Raises: + InvalidArgumentError: If every criterion is missing. + + """ + if all(value is None for value in criteria.values()): + names = ", ".join(criteria) + msg = f"At least one of these arguments is required: {names}" + raise InvalidArgumentError(msg) diff --git a/amazon_creatorsapi/errors.py b/amazon_creatorsapi/errors.py index 807e38e..00ac6fd 100644 --- a/amazon_creatorsapi/errors.py +++ b/amazon_creatorsapi/errors.py @@ -29,12 +29,22 @@ class AuthenticationError(AmazonCreatorsApiError): """Raised when OAuth2 authentication fails.""" +class AccessDeniedError(AmazonCreatorsApiError): + """Raised when the credentials cannot perform the requested operation.""" + + +class ResourceNotFoundError(AmazonCreatorsApiError): + """Raised when the requested feed or report does not exist.""" + + __all__ = [ + "AccessDeniedError", "AmazonCreatorsApiError", "AssociateValidationError", "AuthenticationError", "InvalidArgumentError", "ItemsNotFoundError", "RequestError", + "ResourceNotFoundError", "TooManyRequestsError", ] diff --git a/amazon_creatorsapi/models.py b/amazon_creatorsapi/models.py index 9276567..76e5bf9 100644 --- a/amazon_creatorsapi/models.py +++ b/amazon_creatorsapi/models.py @@ -8,9 +8,11 @@ >>> from amazon_creatorsapi.models import Item, Condition, SortBy >>> from amazon_creatorsapi.models import GetItemsResource, SearchItemsResource >>> from amazon_creatorsapi.models import Feed, FeedType, ReportMetadata, ReportType + >>> from amazon_creatorsapi.models import ErrorData, ResultList """ +from amazon_creatorsapi.core.results import ResultList from creatorsapi_python_sdk.models.availability import Availability from creatorsapi_python_sdk.models.browse_node import BrowseNode from creatorsapi_python_sdk.models.browse_node_ancestor import BrowseNodeAncestor @@ -25,6 +27,7 @@ from creatorsapi_python_sdk.models.customer_reviews import CustomerReviews from creatorsapi_python_sdk.models.deal_details import DealDetails from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag +from creatorsapi_python_sdk.models.error_data import ErrorData from creatorsapi_python_sdk.models.external_ids import ExternalIds from creatorsapi_python_sdk.models.feed import Feed from creatorsapi_python_sdk.models.feed_type import FeedType @@ -86,6 +89,7 @@ "CustomerReviews", "DealDetails", "DeliveryFlag", + "ErrorData", "ExternalIds", "Feed", "FeedType", @@ -116,6 +120,7 @@ "RefinementBin", "ReportMetadata", "ReportType", + "ResultList", "SavingBasisType", "SearchItemsResource", "SearchRefinements", diff --git a/amazon_creatorsapi/py.typed b/amazon_creatorsapi/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/docs/conf.py b/docs/conf.py index bda2b04..3f4f4e4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,7 +23,7 @@ author = "Sergio Abad" # The full version, including alpha/beta/rc tags -release = "7.1.0" +release = "7.4.0" # -- General configuration --------------------------------------------------- diff --git a/docs/pages/usage-guide.md b/docs/pages/usage-guide.md index 0015e8c..7d0a829 100644 --- a/docs/pages/usage-guide.md +++ b/docs/pages/usage-guide.md @@ -39,6 +39,29 @@ for item in items: print(item.images.primary.large.url) ``` +Items come back in the order they were requested, duplicates are asked for +only once, and requests with more items than the API accepts at once are +split into as many calls as needed, so any amount of items can be requested: + +```python +items = api.get_items(asins) # Any amount of items, split into several calls +``` + +Amazon can answer with only some of the requested items, describing the +missing ones as partial errors. Those errors are available in the returned +list, and unavailable items can be included as an item holding only the ASIN: + +```python +items = api.get_items(["B01N5IB20Q", "0000000000"], include_unavailable=True) + +for error in items.errors: + print(error.code, error.message) + +for item in items: + if item.item_info is None: + print(f"{item.asin} is not available") +``` + ## Search Products ```python @@ -47,6 +70,17 @@ for item in results.items: print(item.item_info.title.display_value) ``` +A search needs at least one of `keywords`, `actor`, `artist`, `author`, `brand`, `title`, `browse_node_id` or `search_index`, and only returns the items available for purchase unless asked otherwise: + +```python +from amazon_creatorsapi.models import Availability + +results = api.search_items( + keywords="nintendo switch", + availability=Availability.INCLUDEOUTOFSTOCK, +) +``` + ## Get Product Variations ```python @@ -130,7 +164,58 @@ api = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=10) # Fails api = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=0.5) # Fails after half a second ``` -It applies to every API request. In `AmazonCreatorsApi` the OAuth2 token refresh is handled by the bundled SDK and is not covered by this value, while `AsyncAmazonCreatorsApi` applies it to the token refresh as well. +It applies to every API request, including the OAuth2 token refresh. + +## Retries + +Amazon asks clients to back off and try again when it throttles a request or fails to serve it. The client does that on its own, waiting longer before every attempt and honouring the `Retry-After` header when the API sends it. An expired token is refreshed once and the request is sent again. + +```python +amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=5) # Up to 5 extra attempts +amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=0) # Fail on the first error +``` + +## 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: + +```python +api = AmazonCreatorsApi( + ID, + SECRET, + VERSION, + TAG, + COUNTRY, + host="http://localhost:8080", + auth_endpoint="http://localhost:8080/token", +) +``` + +## Error Handling + +Every error raised by the library inherits from `AmazonCreatorsApiError`, so a single `except` covers them all. The message carries the reason given by Amazon, the fields that failed validation and the identifier of the request, which is what Amazon support asks for: + +| Exception | Raised when | +| --- | --- | +| `InvalidArgumentError` | An argument is not valid or the request is rejected by Amazon | +| `AssociateValidationError` | The credentials are not valid for the selected marketplace | +| `AuthenticationError` | The credentials are missing, invalid or expired | +| `AccessDeniedError` | The credentials cannot perform the requested operation | +| `ItemsNotFoundError` | No items are found for the request | +| `ResourceNotFoundError` | The requested feed or report does not exist | +| `TooManyRequestsError` | The rate limit is exceeded and the retries are exhausted | +| `RequestError` | The request fails for any other reason | + +```python +from amazon_creatorsapi.errors import AmazonCreatorsApiError, ItemsNotFoundError + +try: + items = api.get_items(["B01N5IB20Q"]) +except ItemsNotFoundError: + print("The item is not available") +except AmazonCreatorsApiError as error: + print(error) +``` ## Async Support @@ -186,10 +271,16 @@ from amazon_creatorsapi.models import ( items = api.get_items(["B01N5IB20Q"], condition=Condition.NEW) # Use SortBy enum for search ordering -results = api.search_items(keywords="laptop", sort_by=SortBy.PRICE_LOW_TO_HIGH) +results = api.search_items( + keywords="laptop", + sort_by=SortBy.PRICE_COLON_LOW_TO_HIGH, +) # Specify which resources to retrieve from amazon_creatorsapi.models import GetItemsResource -resources = [GetItemsResource.ITEMINFO_TITLE, GetItemsResource.OFFERS_LISTINGS_PRICE] +resources = [ + GetItemsResource.ITEM_INFO_DOT_TITLE, + GetItemsResource.OFFERS_V2_DOT_LISTINGS_DOT_PRICE, +] items = api.get_items(["B01N5IB20Q"], resources=resources) ``` diff --git a/pyproject.toml b/pyproject.toml index 6d69512..022f632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-amazon-paapi" -version = "7.1.0" +version = "7.4.0" description = "Amazon Creators API wrapper for Python" readme = "README.md" requires-python = ">=3.9" @@ -27,7 +27,6 @@ dependencies = [ "pydantic>=2.0.0", "python_dateutil>=2.8.0", "requests>=2.28.0", - "six>=1.16.0", "urllib3>=1.26.0,<3", ] diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index 266fde7..a2b8adf 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -1,8 +1,12 @@ """Unit tests for AsyncAmazonCreatorsApi class.""" +from __future__ import annotations + import unittest from unittest.mock import AsyncMock, MagicMock, patch +import httpx + from amazon_creatorsapi.aio import ( AsyncAmazonCreatorsApi, ) @@ -10,11 +14,14 @@ from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT from amazon_creatorsapi.errors import ( AssociateValidationError, + AuthenticationError, InvalidArgumentError, ItemsNotFoundError, RequestError, + ResourceNotFoundError, TooManyRequestsError, ) +from creatorsapi_python_sdk.models.availability import Availability from creatorsapi_python_sdk.models.condition import Condition from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag from creatorsapi_python_sdk.models.feed_type import FeedType @@ -266,8 +273,8 @@ async def test_get_items_success( "itemsResult": { "items": [ { - "ASIN": "B0DLFMFBJW", - "ItemInfo": {"Title": {"DisplayValue": "Test"}}, + "asin": "B0DLFMFBJW", + "itemInfo": {"title": {"displayValue": "Test"}}, } ] } @@ -305,7 +312,7 @@ async def test_get_items_with_resources( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJW"}]} } mock_client = AsyncMock() @@ -361,6 +368,7 @@ async def test_get_items_not_found( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(ItemsNotFoundError): await api.get_items(["B0DLFMFBJX"]) @@ -401,6 +409,7 @@ async def test_search_items_with_delivery_flags( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: await api.search_items( keywords="laptop", @@ -423,7 +432,7 @@ async def test_get_items_with_optional_params( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJW"}]} } mock_client = AsyncMock() @@ -442,6 +451,7 @@ async def test_get_items_with_optional_params( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: items = await api.get_items( items=["B0DLFMFBJW"], @@ -468,8 +478,8 @@ async def test_search_items_success( mock_response.status_code = 200 mock_response.json.return_value = { "searchResult": { - "TotalResultCount": 1, - "items": [{"ASIN": "B0DLFMFBJY"}], + "totalResultCount": 1, + "items": [{"asin": "B0DLFMFBJY"}], } } @@ -489,6 +499,7 @@ async def test_search_items_success( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.search_items(keywords="test") @@ -505,7 +516,7 @@ async def test_search_items_with_resources( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "searchResult": {"items": [{"ASIN": "B0DLFMFBJY"}]} + "searchResult": {"items": [{"asin": "B0DLFMFBJY"}]} } mock_client = AsyncMock() mock_client.post.return_value = mock_response @@ -537,7 +548,7 @@ async def test_search_items_without_keywords( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "searchResult": {"items": [{"ASIN": "B0DLFMFBJY"}]} + "searchResult": {"items": [{"asin": "B0DLFMFBJY"}]} } mock_client = AsyncMock() mock_client.post.return_value = mock_response @@ -592,6 +603,7 @@ async def test_handles_404_error( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(ItemsNotFoundError): await api.get_items(["B0DLFMFBJW"]) @@ -624,6 +636,7 @@ async def test_handles_429_error( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(TooManyRequestsError): await api.get_items(["B0DLFMFBJW"]) @@ -656,6 +669,7 @@ async def test_handles_invalid_associate_error( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(AssociateValidationError): await api.get_items(["B0DLFMFBJW"]) @@ -677,7 +691,7 @@ async def test_throttling_waits_between_requests( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJZ"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJZ"}]} } mock_client = AsyncMock() @@ -721,8 +735,8 @@ async def test_get_variations_success( mock_response.status_code = 200 mock_response.json.return_value = { "variationsResult": { - "VariationSummary": {"PageCount": 1}, - "items": [{"ASIN": "B0DLFMFBJV"}], + "variationSummary": {"pageCount": 1}, + "items": [{"asin": "B0DLFMFBJV"}], } } @@ -742,6 +756,7 @@ async def test_get_variations_success( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.get_variations("B0DLFMFBJV") @@ -758,7 +773,7 @@ async def test_get_variations_with_resources( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "variationsResult": {"items": [{"ASIN": "B0DLFMFBJV"}]} + "variationsResult": {"items": [{"asin": "B0DLFMFBJV"}]} } mock_client = AsyncMock() mock_client.post.return_value = mock_response @@ -791,8 +806,8 @@ async def test_get_variations_with_params( mock_response.status_code = 200 mock_response.json.return_value = { "variationsResult": { - "VariationSummary": {"PageCount": 2}, - "items": [{"ASIN": "B0DLFMFBJV"}], + "variationSummary": {"pageCount": 2}, + "items": [{"asin": "B0DLFMFBJV"}], } } @@ -812,6 +827,7 @@ async def test_get_variations_with_params( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.get_variations( asin="B0DLFMFBJV", @@ -852,6 +868,7 @@ async def test_get_variations_not_found( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(ItemsNotFoundError): await api.get_variations("B0DLFMFBJV") @@ -872,7 +889,7 @@ async def test_get_browse_nodes_success( mock_response.status_code = 200 mock_response.json.return_value = { "browseNodesResult": { - "browseNodes": [{"Id": "123456", "DisplayName": "Electronics"}] + "browseNodes": [{"id": "123456", "displayName": "Electronics"}] } } @@ -892,6 +909,7 @@ async def test_get_browse_nodes_success( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.get_browse_nodes(["123456"]) @@ -909,7 +927,7 @@ async def test_get_browse_nodes_with_resources( mock_response.status_code = 200 mock_response.json.return_value = { "browseNodesResult": { - "browseNodes": [{"Id": "123456", "DisplayName": "Electronics"}] + "browseNodes": [{"id": "123456", "displayName": "Electronics"}] } } mock_client = AsyncMock() @@ -943,7 +961,7 @@ async def test_get_browse_nodes_with_languages( mock_response.status_code = 200 mock_response.json.return_value = { "browseNodesResult": { - "browseNodes": [{"Id": "123456", "DisplayName": "Electrónica"}] + "browseNodes": [{"id": "123456", "displayName": "Electrónica"}] } } @@ -963,6 +981,7 @@ async def test_get_browse_nodes_with_languages( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.get_browse_nodes( browse_node_ids=["123456"], @@ -999,6 +1018,7 @@ async def test_get_browse_nodes_not_found( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(ItemsNotFoundError): await api.get_browse_nodes(["999999"]) @@ -1031,6 +1051,7 @@ async def test_get_browse_nodes_empty_nodes_list( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(ItemsNotFoundError): await api.get_browse_nodes(["123456"]) @@ -1067,6 +1088,7 @@ async def test_handles_invalid_parameter_value_error( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(InvalidArgumentError): await api.get_items(["B0DLFMFBJW"]) @@ -1099,6 +1121,7 @@ async def test_handles_invalid_partner_tag_error( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(InvalidArgumentError): await api.get_items(["B0DLFMFBJW"]) @@ -1131,6 +1154,7 @@ async def test_handles_generic_error( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(RequestError): await api.get_items(["B0DLFMFBJW"]) @@ -1163,6 +1187,7 @@ async def test_handles_generic_error_with_empty_body( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(RequestError): await api.get_items(["B0DLFMFBJW"]) @@ -1183,8 +1208,8 @@ async def test_search_items_with_all_params( mock_response.status_code = 200 mock_response.json.return_value = { "searchResult": { - "TotalResultCount": 10, - "items": [{"ASIN": "B0DLFMFBJY"}], + "totalResultCount": 10, + "items": [{"asin": "B0DLFMFBJY"}], } } @@ -1204,6 +1229,7 @@ async def test_search_items_with_all_params( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.search_items( keywords="laptop", @@ -1255,6 +1281,7 @@ async def test_search_items_not_found( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: with self.assertRaises(ItemsNotFoundError): await api.search_items(keywords="xyznonexistent123") @@ -1271,8 +1298,8 @@ async def test_search_items_with_search_index( mock_response.status_code = 200 mock_response.json.return_value = { "searchResult": { - "TotalResultCount": 1, - "items": [{"ASIN": "B0DLFMFBJY"}], + "totalResultCount": 1, + "items": [{"asin": "B0DLFMFBJY"}], } } @@ -1292,6 +1319,7 @@ async def test_search_items_with_search_index( tag="test-tag", country="ES", throttling=0, + retries=0, ) as api: result = await api.search_items( keywords="laptop", @@ -1315,7 +1343,7 @@ async def test_request_without_context_manager( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJW"}]} } mock_client = AsyncMock() @@ -1334,6 +1362,7 @@ async def test_request_without_context_manager( tag="test-tag", country="ES", throttling=0, + retries=0, ) items = await api.get_items(["B0DLFMFBJW"]) @@ -1351,7 +1380,7 @@ async def test_request_uses_v2_authorization_header( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJW"}]} } mock_client = AsyncMock() @@ -1370,6 +1399,7 @@ async def test_request_uses_v2_authorization_header( tag="test-tag", country="ES", throttling=0, + retries=0, ) await api.get_items(["B0DLFMFBJW"]) @@ -1388,7 +1418,7 @@ async def test_request_uses_lwa_authorization_header( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJW"}]} } mock_client = AsyncMock() @@ -1407,6 +1437,7 @@ async def test_request_uses_lwa_authorization_header( tag="test-tag", country="US", throttling=0, + retries=0, ) await api.get_items(["B0DLFMFBJW"]) @@ -1449,6 +1480,7 @@ def _build_api(self) -> AsyncAmazonCreatorsApi: tag="test-tag", country="ES", throttling=0, + retries=0, ) @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @@ -1576,6 +1608,7 @@ def _build_api(self) -> AsyncAmazonCreatorsApi: tag="test-tag", country="ES", throttling=0, + retries=0, ) @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") @@ -1732,7 +1765,7 @@ async def test_request_without_context_manager_uses_custom_timeout( mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { - "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + "itemsResult": {"items": [{"asin": "B0DLFMFBJW"}]} } mock_client = AsyncMock() @@ -1751,6 +1784,7 @@ async def test_request_without_context_manager_uses_custom_timeout( tag="test-tag", country="ES", throttling=0, + retries=0, timeout=5.0, ) @@ -1781,5 +1815,612 @@ async def test_client_receives_disabled_timeout( mock_http_client_class.assert_called_once_with(host=API_HOST, timeout=None) +class TestAsyncAmazonCreatorsApiItems(unittest.IsolatedAsyncioTestCase): + """Tests for the items returned by AsyncAmazonCreatorsApi.""" + + def build_client( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + payloads: list[dict], + ) -> AsyncMock: + """Prepare the mocked HTTP client with the given response payloads.""" + responses = [] + for payload in payloads: + response = MagicMock() + response.status_code = 200 + response.json.return_value = payload + responses.append(response) + + mock_client = AsyncMock() + mock_client.post.side_effect = responses + 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 + + return mock_client + + def build_api(self) -> AsyncAmazonCreatorsApi: + """Build an async API client for the tests.""" + return AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + retries=0, + ) + + def build_payload( + self, + asins: list[str], + errors: list[dict] | None = None, + ) -> dict: + """Build a get items response payload.""" + payload: dict = {"itemsResult": {"items": [{"asin": asin} for asin in asins]}} + if errors is not None: + payload["errors"] = errors + return payload + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_keeps_requested_order( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that items are returned in the order they were requested.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_payload(["B000000002", "B000000001"])], + ) + + result = await self.build_api().get_items(["B000000001", "B000000002"]) + + self.assertEqual([item.asin for item in result], ["B000000001", "B000000002"]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_splits_requests_over_the_limit( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that more items than the limit are split into several calls.""" + item_ids = [f"B0000000{index:02d}" for index in range(12)] + mock_client = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [ + self.build_payload(item_ids[:10]), + self.build_payload(item_ids[10:]), + ], + ) + + result = await self.build_api().get_items(item_ids) + + self.assertEqual(mock_client.post.await_count, 2) + self.assertEqual([item.asin for item in result], item_ids) + sent = [call.args[2]["itemIds"] for call in mock_client.post.await_args_list] + self.assertEqual([len(chunk) for chunk in sent], [10, 2]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_removes_duplicates( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that duplicated items are requested and returned only once.""" + mock_client = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_payload(["B000000001"])], + ) + + result = await self.build_api().get_items(["B000000001", "B000000001"]) + + self.assertEqual(mock_client.post.await_args.args[2]["itemIds"], ["B000000001"]) + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_exposes_partial_errors( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that the partial errors of the response are available.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [ + self.build_payload( + ["B000000001"], + errors=[{"code": "ItemNotFound", "message": "Item not found"}], + ) + ], + ) + + result = await self.build_api().get_items(["B000000001", "B000000002"]) + + self.assertEqual([error.code for error in result.errors], ["ItemNotFound"]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_include_unavailable( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that missing items are returned when they are requested.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_payload(["B000000001"])], + ) + + result = await self.build_api().get_items( + ["B000000001", "B000000002"], + include_unavailable=True, + ) + + self.assertEqual([item.asin for item in result], ["B000000001", "B000000002"]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_not_found_reports_partial_errors( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that partial errors are reported when nothing is found.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [{"errors": [{"code": "InvalidItemId", "message": "Invalid item"}]}], + ) + + with self.assertRaises(ItemsNotFoundError) as context: + await self.build_api().get_items(["B000000001"]) + + self.assertIn("InvalidItemId", str(context.exception)) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_browse_nodes_exposes_partial_errors( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that browse nodes expose the partial errors of the response.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [ + { + "browseNodesResult": {"browseNodes": [{"id": "123"}]}, + "errors": [{"code": "InvalidBrowseNodeId", "message": "Invalid"}], + } + ], + ) + + result = await self.build_api().get_browse_nodes(["123", "456"]) + + self.assertEqual([node.id for node in result], ["123"]) + self.assertEqual( + [error.code for error in result.errors], + ["InvalidBrowseNodeId"], + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_get_items_without_items_raises_library_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that requesting no items raises an invalid argument error.""" + self.build_client(mock_http_client_class, mock_token_manager_class, []) + + with self.assertRaises(InvalidArgumentError): + await self.build_api().get_items([]) + + +class TestAsyncAmazonCreatorsApiRetries(unittest.IsolatedAsyncioTestCase): + """Tests for the retries of AsyncAmazonCreatorsApi.""" + + def build_client( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + responses: list[MagicMock | Exception], + ) -> tuple[AsyncMock, AsyncMock]: + """Prepare the mocked HTTP client with the given responses.""" + mock_client = AsyncMock() + mock_client.post.side_effect = responses + 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" + # clear_token is not a coroutine in the token manager + mock_token_manager.clear_token = MagicMock() + mock_token_manager_class.return_value = mock_token_manager + + return mock_client, mock_token_manager + + def build_response( + self, + status_code: int = 200, + headers: dict[str, str] | None = None, + ) -> MagicMock: + """Build a response of the API with the given status.""" + response = MagicMock() + response.status_code = status_code + response.headers = headers or {} + response.text = '{"message": "error"}' + response.json.return_value = { + "itemsResult": {"items": [{"asin": "B000000001"}]} + } + return response + + def build_api(self, retries: int = 2) -> AsyncAmazonCreatorsApi: + """Build an async API client with the given amount of retries.""" + return AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + retries=retries, + ) + + @patch("amazon_creatorsapi.aio.api.get_retry_delay", return_value=0) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_retries_server_errors( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + _mock_delay: MagicMock, + ) -> None: + """Test that a server error is retried until it succeeds.""" + mock_client, _ = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_response(500), self.build_response()], + ) + + result = await self.build_api().get_items(["B000000001"]) + + self.assertEqual(mock_client.post.await_count, 2) + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @patch("amazon_creatorsapi.aio.api.get_retry_delay", return_value=0) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_stops_after_the_configured_retries( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + _mock_delay: MagicMock, + ) -> None: + """Test that the error is raised once the retries are exhausted.""" + mock_client, _ = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_response(429) for _ in range(3)], + ) + + with self.assertRaises(TooManyRequestsError): + await self.build_api(retries=2).get_items(["B000000001"]) + + self.assertEqual(mock_client.post.await_count, 3) + + @patch("amazon_creatorsapi.aio.api.get_retry_delay", return_value=0) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_honours_the_retry_after_header( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + mock_delay: MagicMock, + ) -> None: + """Test that the headers of the response reach the delay.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [ + self.build_response(503, headers={"retry-after": "5"}), + self.build_response(), + ], + ) + + await self.build_api().get_items(["B000000001"]) + + mock_delay.assert_called_once_with(0, {"retry-after": "5"}) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_refreshes_the_token_once( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that an expired token is refreshed and the request repeated.""" + _, mock_token_manager = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_response(401), self.build_response()], + ) + + result = await self.build_api(retries=0).get_items(["B000000001"]) + + mock_token_manager.clear_token.assert_called_once() + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_unauthorized_twice_raises_authentication_error( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a token that stays invalid raises an authentication error.""" + mock_client, _ = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_response(401), self.build_response(401)], + ) + + with self.assertRaises(AuthenticationError): + await self.build_api(retries=0).get_items(["B000000001"]) + + self.assertEqual(mock_client.post.await_count, 2) + + @patch("amazon_creatorsapi.aio.api.get_retry_delay", return_value=0) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_connection_errors_are_wrapped( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + _mock_delay: MagicMock, + ) -> None: + """Test that a connection failure raises a request error.""" + mock_client, _ = self.build_client( + mock_http_client_class, + mock_token_manager_class, + [httpx.ConnectTimeout("timed out"), httpx.ConnectTimeout("timed out")], + ) + + with self.assertRaises(RequestError): + await self.build_api(retries=1).get_items(["B000000001"]) + + self.assertEqual(mock_client.post.await_count, 2) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_invalid_json_is_wrapped( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a response that is not JSON raises a request error.""" + response = self.build_response() + response.json.side_effect = ValueError("no json") + self.build_client(mock_http_client_class, mock_token_manager_class, [response]) + + with self.assertRaises(RequestError): + await self.build_api(retries=0).get_items(["B000000001"]) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_missing_report_raises_resource_not_found( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a missing report is told apart from missing items.""" + self.build_client( + mock_http_client_class, + mock_token_manager_class, + [self.build_response(404)], + ) + + with self.assertRaises(ResourceNotFoundError): + await self.build_api(retries=0).get_report("missing.csv") + + def test_negative_retries_are_rejected(self) -> None: + """Test that a negative amount of retries is rejected.""" + with self.assertRaises(InvalidArgumentError): + self.build_api(retries=-1) + + +class TestAsyncAmazonCreatorsApiOptions(unittest.IsolatedAsyncioTestCase): + """Tests for the options of AsyncAmazonCreatorsApi.""" + + def build_client( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + payload: dict | None = None, + ) -> AsyncMock: + """Prepare the mocked HTTP client with a successful response.""" + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.json.return_value = payload or { + "searchResult": {"totalResultCount": 1} + } + + 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.clear_token = MagicMock() + mock_token_manager_class.return_value = mock_token_manager + + return mock_client + + def build_api(self, **options: object) -> AsyncAmazonCreatorsApi: + """Build an async API client with the given options.""" + return AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + retries=0, + **options, # type: ignore[arg-type] + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_search_items_forwards_availability( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that the availability filter is sent to the API.""" + mock_client = self.build_client( + mock_http_client_class, + mock_token_manager_class, + ) + + await self.build_api().search_items( + keywords="laptop", + availability=Availability.INCLUDEOUTOFSTOCK, + ) + + body = mock_client.post.await_args.args[2] + self.assertEqual(body["availability"], "IncludeOutOfStock") + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_search_items_without_criteria( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a search without criteria does not reach the API.""" + mock_client = self.build_client( + mock_http_client_class, + mock_token_manager_class, + ) + + with self.assertRaises(InvalidArgumentError): + await self.build_api().search_items() + + mock_client.post.assert_not_awaited() + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_request_body_uses_the_names_of_the_api( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that the body is built from the models of the SDK.""" + mock_client = self.build_client( + mock_http_client_class, + mock_token_manager_class, + ) + + await self.build_api().search_items( + keywords="laptop", + browse_node_id="123", + sort_by=SortBy.PRICE_COLON_LOW_TO_HIGH, + resources=[SearchItemsResource.ITEM_INFO_DOT_TITLE], + ) + + body = mock_client.post.await_args.args[2] + self.assertEqual(body["browseNodeId"], "123") + self.assertEqual(body["sortBy"], "Price:LowToHigh") + self.assertEqual(body["resources"], ["itemInfo.title"]) + self.assertNotIn("actor", body) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_invalid_value_does_not_reach_the_api( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that a value rejected by the API is caught before sending it.""" + mock_client = self.build_client( + mock_http_client_class, + mock_token_manager_class, + ) + + with self.assertRaises(InvalidArgumentError): + await self.build_api().search_items(keywords="laptop", min_reviews_rating=5) + + mock_client.post.assert_not_awaited() + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_errors_report_the_request_id( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that the identifier given by Amazon is part of the error.""" + response = MagicMock() + response.status_code = 400 + response.headers = {"x-amzn-RequestId": "abc-123"} + response.text = '{"message": "invalid"}' + + 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 + + with self.assertRaises(InvalidArgumentError) as context: + await self.build_api().search_items(keywords="laptop") + + self.assertIn("abc-123", str(context.exception)) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_custom_host_and_auth_endpoint( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test that the endpoints of the API can be replaced.""" + self.build_client(mock_http_client_class, mock_token_manager_class) + + api = self.build_api( + host="https://example.com", + auth_endpoint="https://example.com/token", + ) + await api.search_items(keywords="laptop") + + self.assertEqual( + mock_token_manager_class.call_args.kwargs["auth_endpoint"], + "https://example.com/token", + ) + self.assertEqual( + mock_http_client_class.call_args.kwargs["host"], + "https://example.com", + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index c05fb25..a80acbc 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -8,23 +8,41 @@ from unittest import mock from unittest.mock import MagicMock +import urllib3 + from amazon_creatorsapi import AmazonCreatorsApi +from amazon_creatorsapi.core.auth import TimeoutOAuth2TokenManager from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT from amazon_creatorsapi.errors import ( + AccessDeniedError, AssociateValidationError, + AuthenticationError, InvalidArgumentError, ItemsNotFoundError, RequestError, + ResourceNotFoundError, TooManyRequestsError, ) from creatorsapi_python_sdk.exceptions import ApiException +from creatorsapi_python_sdk.models.availability import Availability +from creatorsapi_python_sdk.models.browse_node import BrowseNode +from creatorsapi_python_sdk.models.browse_nodes_result import BrowseNodesResult from creatorsapi_python_sdk.models.delivery_flag import DeliveryFlag +from creatorsapi_python_sdk.models.error_data import ErrorData from creatorsapi_python_sdk.models.feed_type import FeedType from creatorsapi_python_sdk.models.get_browse_nodes_resource import ( GetBrowseNodesResource, ) +from creatorsapi_python_sdk.models.get_browse_nodes_response_content import ( + GetBrowseNodesResponseContent, +) from creatorsapi_python_sdk.models.get_items_resource import GetItemsResource +from creatorsapi_python_sdk.models.get_items_response_content import ( + GetItemsResponseContent, +) from creatorsapi_python_sdk.models.get_variations_resource import GetVariationsResource +from creatorsapi_python_sdk.models.item import Item +from creatorsapi_python_sdk.models.items_result import ItemsResult from creatorsapi_python_sdk.models.report_type import ReportType from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource @@ -112,6 +130,7 @@ def test_throttling_disabled(self, _mock_client: MagicMock) -> None: tag=self.tag, country=self.country, throttling=0, + retries=0, ) start_time = time.time() api._throttle() @@ -129,7 +148,7 @@ def test_throttling_sleeps(self, _mock_client: MagicMock) -> None: country=self.country, throttling=0.2, ) - api._last_query_time = time.time() + api._last_query_time = time.monotonic() start_time = time.time() api._throttle() elapsed_time = time.time() - start_time @@ -156,6 +175,7 @@ def test_get_items( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.get_items(["B0DLFMFBJW"]) self.assertIsInstance(result, list) @@ -182,6 +202,7 @@ def test_search_items( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.search_items(keywords="laptop") self.assertIsNotNone(result) @@ -208,6 +229,7 @@ def test_search_items_with_delivery_flags( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.search_items( @@ -243,6 +265,7 @@ def test_get_items_no_results( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.get_items(["B0DLFMFBJW"]) @@ -268,6 +291,7 @@ def test_get_items_items_none( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.get_items(["B0DLFMFBJW"]) @@ -291,6 +315,7 @@ def test_get_items_api_exception( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.get_items(["B0DLFMFBJW"]) @@ -316,6 +341,7 @@ def test_search_items_no_results( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.search_items(keywords="nonexistent") @@ -341,6 +367,7 @@ def test_search_items_api_exception( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.search_items(keywords="laptop") @@ -366,6 +393,7 @@ def test_get_variations( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.get_variations("B0DLFMFBJW") self.assertIsNotNone(result) @@ -392,6 +420,7 @@ def test_get_variations_no_results( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.get_variations("B0DLFMFBJW") @@ -417,6 +446,7 @@ def test_get_variations_api_exception( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.get_variations("B0DLFMFBJW") @@ -442,6 +472,7 @@ def test_get_browse_nodes( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.get_browse_nodes(["123456"]) self.assertIsInstance(result, list) @@ -468,6 +499,7 @@ def test_get_browse_nodes_no_results( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.get_browse_nodes(["123456"]) @@ -493,6 +525,7 @@ def test_get_browse_nodes_browse_nodes_none( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.get_browse_nodes(["123456"]) @@ -518,6 +551,7 @@ def test_get_browse_nodes_api_exception( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.get_browse_nodes(["123456"]) @@ -541,6 +575,7 @@ def test_handle_api_exception_not_found( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(ItemsNotFoundError): api.get_items(["B0DLFMFBJW"]) @@ -566,6 +601,7 @@ def test_handle_api_exception_too_many_requests( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(TooManyRequestsError): api.get_items(["B0DLFMFBJW"]) @@ -591,6 +627,7 @@ def test_handle_api_exception_invalid_parameter_value( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(InvalidArgumentError): api.get_items(["B0DLFMFBJW"]) @@ -616,6 +653,7 @@ def test_handle_api_exception_invalid_partner_tag( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(InvalidArgumentError): api.get_items(["B0DLFMFBJW"]) @@ -641,6 +679,7 @@ def test_handle_api_exception_invalid_associate( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(AssociateValidationError): api.get_items(["B0DLFMFBJW"]) @@ -666,6 +705,7 @@ def test_handle_api_exception_generic_error( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.get_items(["B0DLFMFBJW"]) @@ -691,6 +731,7 @@ def test_handle_api_exception_no_body( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.get_items(["B0DLFMFBJW"]) @@ -717,6 +758,7 @@ def test_handle_api_exception_no_reason( tag=self.tag, country=self.country, throttling=0, + retries=0, ) with self.assertRaises(RequestError): api.get_items(["B0DLFMFBJW"]) @@ -742,6 +784,7 @@ def test_get_items_with_explicit_resources( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.get_items( ["B0DLFMFBJW"], @@ -770,6 +813,7 @@ def test_search_items_with_explicit_resources( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.search_items( keywords="laptop", @@ -798,6 +842,7 @@ def test_get_variations_with_explicit_resources( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.get_variations( "B0DLFMFBJW", @@ -826,6 +871,7 @@ def test_get_browse_nodes_with_explicit_resources( tag=self.tag, country=self.country, throttling=0, + retries=0, ) result = api.get_browse_nodes( ["123456"], @@ -842,6 +888,7 @@ def _build_api(self) -> AmazonCreatorsApi: tag=self.tag, country=self.country, throttling=0, + retries=0, ) @mock.patch("amazon_creatorsapi.api.DefaultApi") @@ -942,7 +989,7 @@ def test_get_feed_api_exception( mock_api_class.return_value = mock_api mock_api.get_feed.side_effect = ApiException(status=404) - with self.assertRaises(ItemsNotFoundError): + with self.assertRaises(ResourceNotFoundError): self._build_api().get_feed("missing-feed") @mock.patch("amazon_creatorsapi.api.DefaultApi") @@ -1070,6 +1117,7 @@ def test_get_items_uses_default_timeout( tag=self.tag, country=self.country, throttling=0, + retries=0, ) api.get_items(["B0DLFMFBJW"]) @@ -1100,6 +1148,7 @@ def test_get_items_forwards_custom_timeout( tag=self.tag, country=self.country, throttling=0, + retries=0, timeout=15.0, ) api.get_items(["B0DLFMFBJW"]) @@ -1130,6 +1179,7 @@ def test_get_items_with_timeout_disabled( tag=self.tag, country=self.country, throttling=0, + retries=0, timeout=None, ) api.get_items(["B0DLFMFBJW"]) @@ -1157,6 +1207,7 @@ def test_search_items_forwards_custom_timeout( tag=self.tag, country=self.country, throttling=0, + retries=0, timeout=5.0, ) api.search_items(keywords="laptop") @@ -1187,6 +1238,7 @@ def test_get_variations_forwards_custom_timeout( tag=self.tag, country=self.country, throttling=0, + retries=0, timeout=3.5, ) api.get_variations("B0DLFMFBJW") @@ -1217,6 +1269,7 @@ def test_get_browse_nodes_forwards_custom_timeout( tag=self.tag, country=self.country, throttling=0, + retries=0, timeout=7.5, ) api.get_browse_nodes(["123456"]) @@ -1244,6 +1297,7 @@ def test_feed_and_report_methods_forward_timeout( tag=self.tag, country=self.country, throttling=0, + retries=0, timeout=9.0, ) api.list_feeds() @@ -1258,3 +1312,550 @@ def test_feed_and_report_methods_forward_timeout( mock_api.get_report, ): self.assertEqual(call.call_args.kwargs["_request_timeout"], 9.0) + + +class TestAmazonCreatorsApiItems(unittest.TestCase): + """Tests for the items returned by AmazonCreatorsApi.""" + + def setUp(self) -> None: + self.credential_id = "test_credential_id" + self.credential_secret = "test_credential_secret" + self.version = "2.2" + self.tag = "test-tag" + self.country: CountryCode = "ES" + + def build_api(self, mock_api_class: MagicMock) -> AmazonCreatorsApi: + """Build an API client with a mocked SDK.""" + return AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + retries=0, + ) + + def build_response( + self, + asins: list[str], + errors: list[ErrorData] | None = None, + ) -> GetItemsResponseContent: + """Build a get items response holding the given items and errors.""" + return GetItemsResponseContent( + itemsResult=ItemsResult(items=[Item(asin=asin) for asin in asins]), + errors=errors, + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_keeps_requested_order( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that items are returned in the order they were requested.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = self.build_response( + ["B000000002", "B000000001"], + ) + + api = self.build_api(mock_api_class) + result = api.get_items(["B000000001", "B000000002"]) + + self.assertEqual([item.asin for item in result], ["B000000001", "B000000002"]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_splits_requests_over_the_limit( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that more items than the limit are split into several calls.""" + item_ids = [f"B0000000{index:02d}" for index in range(12)] + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = [ + self.build_response(item_ids[:10]), + self.build_response(item_ids[10:]), + ] + + api = self.build_api(mock_api_class) + result = api.get_items(item_ids) + + self.assertEqual(mock_api.get_items.call_count, 2) + self.assertEqual([item.asin for item in result], item_ids) + requests = [ + call.kwargs["get_items_request_content"].item_ids + for call in mock_api.get_items.call_args_list + ] + self.assertEqual([len(request) for request in requests], [10, 2]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_removes_duplicates( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that duplicated items are requested and returned only once.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = self.build_response(["B000000001"]) + + api = self.build_api(mock_api_class) + result = api.get_items(["B000000001", "B000000001"]) + + request = mock_api.get_items.call_args.kwargs["get_items_request_content"] + self.assertEqual(request.item_ids, ["B000000001"]) + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_exposes_partial_errors( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that the partial errors of the response are available.""" + error = ErrorData(code="ItemNotFound", message="Item not found") + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = self.build_response( + ["B000000001"], + errors=[error], + ) + + api = self.build_api(mock_api_class) + result = api.get_items(["B000000001", "B000000002"]) + + self.assertEqual(result.errors, [error]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_include_unavailable( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that missing items are returned when they are requested.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = self.build_response(["B000000001"]) + + api = self.build_api(mock_api_class) + result = api.get_items( + ["B000000001", "B000000002"], + include_unavailable=True, + ) + + self.assertEqual([item.asin for item in result], ["B000000001", "B000000002"]) + self.assertIsNone(result[1].item_info) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_not_found_reports_partial_errors( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that partial errors are reported when nothing is found.""" + error = ErrorData(code="InvalidItemId", message="Item id is invalid") + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.return_value = GetItemsResponseContent(errors=[error]) + + api = self.build_api(mock_api_class) + + with self.assertRaises(ItemsNotFoundError) as context: + api.get_items(["B000000001"]) + + self.assertIn("InvalidItemId", str(context.exception)) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_invalid_parameter_raises_library_error( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a value rejected by the API raises a library error.""" + api = self.build_api(mock_api_class) + + with self.assertRaises(InvalidArgumentError): + api.get_items(["B000000001"], languages_of_preference=["es_ES", "en_US"]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_browse_nodes_exposes_partial_errors( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that browse nodes expose the partial errors of the response.""" + error = ErrorData(code="InvalidBrowseNodeId", message="Invalid browse node") + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_browse_nodes.return_value = GetBrowseNodesResponseContent( + browseNodesResult=BrowseNodesResult(browseNodes=[BrowseNode(id="123")]), + errors=[error], + ) + + api = self.build_api(mock_api_class) + result = api.get_browse_nodes(["123", "456"]) + + self.assertEqual([node.id for node in result], ["123"]) + self.assertEqual(result.errors, [error]) + + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_token_manager_receives_the_timeout( + self, + mock_client_class: MagicMock, + ) -> None: + """Test that the token manager is replaced by one with a timeout.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + timeout=12.0, + ) + + token_manager = mock_client._token_manager + self.assertIsInstance(token_manager, TimeoutOAuth2TokenManager) + self.assertEqual(token_manager._timeout, 12.0) + self.assertEqual(api.timeout, 12.0) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_without_items_raises_library_error( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that requesting no items raises an invalid argument error.""" + api = self.build_api(mock_api_class) + + with self.assertRaises(InvalidArgumentError): + api.get_items([]) + + +class TestAmazonCreatorsApiRetries(unittest.TestCase): + """Tests for the retries of AmazonCreatorsApi.""" + + def setUp(self) -> None: + self.credential_id = "test_credential_id" + self.credential_secret = "test_credential_secret" + self.version = "2.2" + self.tag = "test-tag" + self.country: CountryCode = "ES" + + def build_api(self, retries: int = 2) -> AmazonCreatorsApi: + """Build an API client with the given amount of retries.""" + return AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + retries=retries, + ) + + def build_response(self) -> GetItemsResponseContent: + """Build a successful get items response.""" + return GetItemsResponseContent( + itemsResult=ItemsResult(items=[Item(asin="B000000001")]), + ) + + @mock.patch("amazon_creatorsapi.api.get_retry_delay", return_value=0) + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_retries_server_errors( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + _mock_delay: MagicMock, + ) -> None: + """Test that a server error is retried until it succeeds.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = [ + ApiException(status=500), + self.build_response(), + ] + + result = self.build_api().get_items(["B000000001"]) + + self.assertEqual(mock_api.get_items.call_count, 2) + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @mock.patch("amazon_creatorsapi.api.get_retry_delay", return_value=0) + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_stops_after_the_configured_retries( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + _mock_delay: MagicMock, + ) -> None: + """Test that the error is raised once the retries are exhausted.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = ApiException(status=429) + + with self.assertRaises(TooManyRequestsError): + self.build_api(retries=2).get_items(["B000000001"]) + + self.assertEqual(mock_api.get_items.call_count, 3) + + @mock.patch("amazon_creatorsapi.api.get_retry_delay", return_value=0) + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_honours_the_retry_after_header( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + mock_delay: MagicMock, + ) -> None: + """Test that the headers of the response reach the delay.""" + error = ApiException(status=429) + error.headers = {"Retry-After": "5"} + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = [error, self.build_response()] + + self.build_api().get_items(["B000000001"]) + + mock_delay.assert_called_once_with(0, {"Retry-After": "5"}) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_does_not_retry_client_errors( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a rejected request is not retried.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = ApiException(status=400) + + with self.assertRaises(InvalidArgumentError): + self.build_api().get_items(["B000000001"]) + + mock_api.get_items.assert_called_once() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_refreshes_the_token_once( + self, + mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that an expired token is refreshed and the request repeated.""" + mock_client = MagicMock() + mock_client_class.return_value = mock_client + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = [ + ApiException(status=401), + self.build_response(), + ] + + result = self.build_api(retries=0).get_items(["B000000001"]) + + mock_client.token_manager.clear_token.assert_called_once() + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_unauthorized_twice_raises_authentication_error( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a token that stays invalid raises an authentication error.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = ApiException(status=401) + + with self.assertRaises(AuthenticationError): + self.build_api(retries=0).get_items(["B000000001"]) + + self.assertEqual(mock_api.get_items.call_count, 2) + + @mock.patch("amazon_creatorsapi.api.get_retry_delay", return_value=0) + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_connection_errors_are_wrapped( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + _mock_delay: MagicMock, + ) -> None: + """Test that a connection failure raises a request error.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = urllib3.exceptions.TimeoutError( + "Read timed out", + ) + + with self.assertRaises(RequestError): + self.build_api(retries=1).get_items(["B000000001"]) + + self.assertEqual(mock_api.get_items.call_count, 2) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_forbidden_raises_access_denied( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a forbidden request raises an access denied error.""" + error = ApiException(status=403) + error.body = '{"message": "Not eligible", "reason": "AssociateNotEligible"}' + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = error + + with self.assertRaises(AccessDeniedError): + self.build_api().get_items(["B000000001"]) + + mock_api.get_items.assert_called_once() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_unauthorized_without_token_manager( + self, + mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that the request is repeated even without a cached token.""" + mock_client = MagicMock() + mock_client.token_manager = None + mock_client_class.return_value = mock_client + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = [ + ApiException(status=401), + self.build_response(), + ] + + result = self.build_api(retries=0).get_items(["B000000001"]) + + self.assertEqual([item.asin for item in result], ["B000000001"]) + + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_negative_retries_are_rejected(self, _mock_client: MagicMock) -> None: + """Test that a negative amount of retries is rejected.""" + with self.assertRaises(InvalidArgumentError): + self.build_api(retries=-1) + + +class TestAmazonCreatorsApiOptions(unittest.TestCase): + """Tests for the options of AmazonCreatorsApi.""" + + def setUp(self) -> None: + self.credential_id = "test_credential_id" + self.credential_secret = "test_credential_secret" + self.version = "2.2" + self.tag = "test-tag" + self.country: CountryCode = "ES" + + def build_api(self, **options: object) -> AmazonCreatorsApi: + """Build an API client with the given options.""" + return AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + retries=0, + **options, # type: ignore[arg-type] + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_search_items_forwards_availability( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that the availability filter reaches the SDK request.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.search_items.return_value = MagicMock(search_result=MagicMock()) + + self.build_api().search_items( + keywords="laptop", + availability=Availability.INCLUDEOUTOFSTOCK, + ) + + request = mock_api.search_items.call_args.kwargs["search_items_request_content"] + self.assertEqual(request.availability, Availability.INCLUDEOUTOFSTOCK) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_search_items_without_criteria( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that a search without criteria does not reach the API.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + + with self.assertRaises(InvalidArgumentError): + self.build_api().search_items() + + mock_api.search_items.assert_not_called() + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_errors_report_the_request_id( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test that the identifier given by Amazon is part of the error.""" + error = ApiException(status=400) + error.headers = {"x-amzn-RequestId": "abc-123"} + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_api.get_items.side_effect = error + + with self.assertRaises(InvalidArgumentError) as context: + self.build_api().get_items(["B000000001"]) + + self.assertIn("abc-123", str(context.exception)) + + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_custom_host_and_auth_endpoint(self, mock_client_class: MagicMock) -> None: + """Test that the endpoints of the API can be replaced.""" + self.build_api( + host="https://example.com", + auth_endpoint="https://example.com/token", + ) + + options = mock_client_class.call_args.kwargs + self.assertEqual(options["host"], "https://example.com") + self.assertEqual(options["auth_endpoint"], "https://example.com/token") + + def test_every_client_has_its_own_configuration(self) -> None: + """Test that the configuration is not shared between clients.""" + first = self.build_api() + second = self.build_api() + + self.assertIsNot( + first._api_client.configuration, + second._api_client.configuration, + ) diff --git a/tests/amazon_creatorsapi/core/__init__.py b/tests/amazon_creatorsapi/core/__init__.py new file mode 100644 index 0000000..c494af7 --- /dev/null +++ b/tests/amazon_creatorsapi/core/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the core utilities.""" diff --git a/tests/amazon_creatorsapi/core/auth_test.py b/tests/amazon_creatorsapi/core/auth_test.py new file mode 100644 index 0000000..2483a40 --- /dev/null +++ b/tests/amazon_creatorsapi/core/auth_test.py @@ -0,0 +1,121 @@ +"""Unit tests for the OAuth2 token manager with timeout.""" + +from __future__ import annotations + +import unittest +from unittest import mock +from unittest.mock import MagicMock + +import requests + +from amazon_creatorsapi.core.auth import TimeoutOAuth2TokenManager +from amazon_creatorsapi.errors import AuthenticationError +from creatorsapi_python_sdk.auth.oauth2_config import OAuth2Config + + +class TestTimeoutOAuth2TokenManager(unittest.TestCase): + """Tests for TimeoutOAuth2TokenManager class.""" + + def setUp(self) -> None: + self.config = OAuth2Config("test_id", "test_secret", "2.2", None) + self.manager = TimeoutOAuth2TokenManager(self.config, timeout=7.0) + + def build_response(self, status_code: int = 200, **json_data: object) -> MagicMock: + """Build a fake response for the auth endpoint.""" + response = MagicMock() + response.status_code = status_code + response.text = "response body" + response.json.return_value = json_data or { + "access_token": "test_token", + "expires_in": 3600, + } + return response + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_token_request_uses_the_timeout(self, mock_post: MagicMock) -> None: + """Test that the timeout is sent with the token request.""" + mock_post.return_value = self.build_response() + + token = self.manager.refresh_token() + + self.assertEqual(token, "test_token") + self.assertEqual(mock_post.call_args.kwargs["timeout"], 7.0) + self.assertIn("data", mock_post.call_args.kwargs) + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_lwa_token_request_uses_json_body(self, mock_post: MagicMock) -> None: + """Test that LWA versions send the credentials as JSON.""" + manager = TimeoutOAuth2TokenManager( + OAuth2Config("test_id", "test_secret", "3.1", None), + timeout=None, + ) + mock_post.return_value = self.build_response() + + manager.refresh_token() + + self.assertIn("json", mock_post.call_args.kwargs) + self.assertIsNone(mock_post.call_args.kwargs["timeout"]) + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_token_is_cached(self, mock_post: MagicMock) -> None: + """Test that a valid token is reused instead of requested again.""" + mock_post.return_value = self.build_response() + + self.assertEqual(self.manager.get_token(), "test_token") + self.assertEqual(self.manager.get_token(), "test_token") + mock_post.assert_called_once() + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_error_status_raises_authentication_error( + self, + mock_post: MagicMock, + ) -> None: + """Test that a failed token request raises an authentication error.""" + mock_post.return_value = self.build_response(status_code=401) + + with self.assertRaises(AuthenticationError) as context: + self.manager.refresh_token() + + self.assertIn("401", str(context.exception)) + self.assertIsNone(self.manager.access_token) + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_missing_token_raises_authentication_error( + self, + mock_post: MagicMock, + ) -> None: + """Test that a response without token raises an authentication error.""" + mock_post.return_value = self.build_response(token_type="Bearer") + + with self.assertRaises(AuthenticationError) as context: + self.manager.refresh_token() + + self.assertIn("No access token", str(context.exception)) + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_request_error_raises_authentication_error( + self, + mock_post: MagicMock, + ) -> None: + """Test that a timed out request raises an authentication error.""" + mock_post.side_effect = requests.Timeout("timed out") + + with self.assertRaises(AuthenticationError) as context: + self.manager.refresh_token() + + self.assertIn("token request failed", str(context.exception)) + + @mock.patch("amazon_creatorsapi.core.auth.requests.post") + def test_invalid_json_raises_authentication_error( + self, + mock_post: MagicMock, + ) -> None: + """Test that an unparseable response raises an authentication error.""" + response = self.build_response() + response.json.side_effect = ValueError("no json") + mock_post.return_value = response + + with self.assertRaises(AuthenticationError) as context: + self.manager.refresh_token() + + self.assertIn("parse OAuth2 token response", str(context.exception)) diff --git a/tests/amazon_creatorsapi/core/error_handling_test.py b/tests/amazon_creatorsapi/core/error_handling_test.py new file mode 100644 index 0000000..b09b14b --- /dev/null +++ b/tests/amazon_creatorsapi/core/error_handling_test.py @@ -0,0 +1,157 @@ +"""Unit tests for the error handling utilities.""" + +from __future__ import annotations + +import json +import unittest + +from amazon_creatorsapi.core.error_handling import ( + get_request_id, + handle_api_error, + parse_error_body, +) +from amazon_creatorsapi.errors import ( + AccessDeniedError, + AssociateValidationError, + AuthenticationError, + InvalidArgumentError, + ItemsNotFoundError, + RequestError, + ResourceNotFoundError, + TooManyRequestsError, +) + + +class TestParseErrorBody(unittest.TestCase): + """Tests for parse_error_body function.""" + + def test_parses_a_json_object(self) -> None: + """Test that a JSON object is returned as a dictionary.""" + self.assertEqual(parse_error_body('{"reason": "Other"}'), {"reason": "Other"}) + + def test_ignores_anything_else(self) -> None: + """Test that a body that is not a JSON object is ignored.""" + self.assertEqual(parse_error_body("error"), {}) + self.assertEqual(parse_error_body("[1, 2]"), {}) + self.assertEqual(parse_error_body(""), {}) + + +class TestHandleApiError(unittest.TestCase): + """Tests for handle_api_error function.""" + + def build_body(self, **data: object) -> str: + """Build the body of an error response.""" + return json.dumps(data) + + def test_validation_error(self) -> None: + """Test that a rejected request raises an invalid argument error.""" + body = self.build_body( + type="ValidationException", + message="Request is not valid", + reason="FieldValidationFailed", + fieldList=[{"name": "itemIds", "message": "must not be empty"}], + ) + + with self.assertRaises(InvalidArgumentError) as context: + handle_api_error(400, body) + + message = str(context.exception) + self.assertIn("FieldValidationFailed", message) + self.assertIn("itemIds: must not be empty", message) + + def test_invalid_associate(self) -> None: + """Test that an invalid associate raises its own error.""" + body = self.build_body(message="Invalid associate", reason="InvalidAssociate") + + with self.assertRaises(AssociateValidationError): + handle_api_error(400, body) + + def test_invalid_associate_without_json_body(self) -> None: + """Test that the reason is found even when the body is not JSON.""" + with self.assertRaises(AssociateValidationError): + handle_api_error(400, "InvalidAssociate for this marketplace") + + def test_invalid_partner_tag(self) -> None: + """Test that an invalid partner tag raises an invalid argument error.""" + body = self.build_body(message="Invalid tag", reason="InvalidPartnerTag") + + with self.assertRaises(InvalidArgumentError) as context: + handle_api_error(400, body) + + self.assertIn("InvalidPartnerTag", str(context.exception)) + + def test_unauthorized(self) -> None: + """Test that an expired token raises an authentication error.""" + body = self.build_body(message="Token expired", reason="TokenExpired") + + with self.assertRaises(AuthenticationError) as context: + handle_api_error(401, body) + + self.assertIn("TokenExpired", str(context.exception)) + + def test_access_denied(self) -> None: + """Test that a forbidden request raises an access denied error.""" + body = self.build_body(message="Not eligible", reason="AssociateNotEligible") + + with self.assertRaises(AccessDeniedError) as context: + handle_api_error(403, body) + + self.assertIn("AssociateNotEligible", str(context.exception)) + + def test_not_found_defaults_to_items(self) -> None: + """Test that a missing resource raises the items error by default.""" + with self.assertRaises(ItemsNotFoundError): + handle_api_error(404, "") + + def test_not_found_can_be_customized(self) -> None: + """Test that a missing feed or report raises its own error.""" + body = self.build_body( + message="Not found", + resourceId="report.csv", + resourceType="Report", + ) + + with self.assertRaises(ResourceNotFoundError) as context: + handle_api_error(404, body, ResourceNotFoundError) + + self.assertIn("report.csv", str(context.exception)) + + def test_throttled(self) -> None: + """Test that a throttled request raises a too many requests error.""" + with self.assertRaises(TooManyRequestsError): + handle_api_error(429, self.build_body(message="Slow down")) + + def test_server_error(self) -> None: + """Test that an unexpected status raises a request error.""" + with self.assertRaises(RequestError) as context: + handle_api_error(500, self.build_body(message="Internal error")) + + self.assertIn("500", str(context.exception)) + self.assertIn("Internal error", str(context.exception)) + + def test_body_without_details(self) -> None: + """Test that an unparseable body is kept in the message.""" + with self.assertRaises(RequestError) as context: + handle_api_error(502, "Bad gateway") + + self.assertIn("Bad gateway", str(context.exception)) + + +class TestGetRequestId(unittest.TestCase): + """Tests for get_request_id function.""" + + def test_reads_the_header(self) -> None: + """Test that the identifier of the request is found.""" + self.assertEqual(get_request_id({"x-amzn-RequestId": "abc-123"}), "abc-123") + + def test_missing_header(self) -> None: + """Test that a response without the header has no identifier.""" + self.assertIsNone(get_request_id({"Content-Type": "application/json"})) + self.assertIsNone(get_request_id(None)) + + def test_request_id_is_reported(self) -> None: + """Test that the identifier is part of the message of the error.""" + with self.assertRaises(RequestError) as context: + handle_api_error(500, "", headers={"x-amzn-requestid": "abc-123"}) + + self.assertIn("abc-123", str(context.exception)) diff --git a/tests/amazon_creatorsapi/core/items_test.py b/tests/amazon_creatorsapi/core/items_test.py new file mode 100644 index 0000000..fb090a3 --- /dev/null +++ b/tests/amazon_creatorsapi/core/items_test.py @@ -0,0 +1,70 @@ +"""Unit tests for the item utilities.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.items import ( + get_item_chunks, + get_unique_items, + sort_items, +) +from creatorsapi_python_sdk.models.item import Item + + +class TestGetUniqueItems(unittest.TestCase): + """Tests for get_unique_items function.""" + + def test_removes_duplicates_keeping_order(self) -> None: + """Test that duplicates are removed and the order is kept.""" + result = get_unique_items(["B000000001", "B000000002", "B000000001"]) + self.assertEqual(result, ["B000000001", "B000000002"]) + + def test_empty_list(self) -> None: + """Test that an empty list is returned untouched.""" + self.assertEqual(get_unique_items([]), []) + + +class TestGetItemChunks(unittest.TestCase): + """Tests for get_item_chunks function.""" + + def test_single_chunk(self) -> None: + """Test that a small list produces a single chunk.""" + item_ids = [f"B00000000{index}" for index in range(3)] + self.assertEqual(list(get_item_chunks(item_ids)), [item_ids]) + + def test_splits_over_the_limit(self) -> None: + """Test that a list over the limit is split into several chunks.""" + item_ids = [f"B0000000{index:02d}" for index in range(23)] + chunks = list(get_item_chunks(item_ids)) + self.assertEqual([len(chunk) for chunk in chunks], [10, 10, 3]) + self.assertEqual([item for chunk in chunks for item in chunk], item_ids) + + def test_empty_list(self) -> None: + """Test that an empty list produces no chunks.""" + self.assertEqual(list(get_item_chunks([])), []) + + +class TestSortItems(unittest.TestCase): + """Tests for sort_items function.""" + + def setUp(self) -> None: + self.item_ids = ["B000000001", "B000000002", "B000000003"] + + def test_sorts_by_requested_order(self) -> None: + """Test that items follow the order of the requested identifiers.""" + items = [Item(asin="B000000003"), Item(asin="B000000001")] + result = sort_items(items, self.item_ids, include_unavailable=False) + self.assertEqual([item.asin for item in result], ["B000000001", "B000000003"]) + + def test_includes_unavailable_items(self) -> None: + """Test that missing items are added when they are requested.""" + items = [Item(asin="B000000002")] + result = sort_items(items, self.item_ids, include_unavailable=True) + self.assertEqual([item.asin for item in result], self.item_ids) + self.assertIsNone(result[0].item_info) + + def test_ignores_items_without_asin(self) -> None: + """Test that items without ASIN are not returned.""" + result = sort_items([Item()], self.item_ids, include_unavailable=False) + self.assertEqual(result, []) diff --git a/tests/amazon_creatorsapi/core/requests_test.py b/tests/amazon_creatorsapi/core/requests_test.py new file mode 100644 index 0000000..c98a47c --- /dev/null +++ b/tests/amazon_creatorsapi/core/requests_test.py @@ -0,0 +1,54 @@ +"""Unit tests for the request body utilities.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.requests import get_request_body +from creatorsapi_python_sdk.models.availability import Availability +from creatorsapi_python_sdk.models.search_items_request_content import ( + SearchItemsRequestContent, +) +from creatorsapi_python_sdk.models.search_items_resource import SearchItemsResource +from creatorsapi_python_sdk.models.sort_by import SortBy + + +class TestGetRequestBody(unittest.TestCase): + """Tests for get_request_body function.""" + + def test_uses_the_names_of_the_api(self) -> None: + """Test that the body holds the names expected by the API.""" + request = SearchItemsRequestContent( + partnerTag="test-tag", + keywords="laptop", + browseNodeId="123", + ) + + body = get_request_body(request) + + self.assertEqual(body["partnerTag"], "test-tag") + self.assertEqual(body["browseNodeId"], "123") + + def test_drops_the_values_not_provided(self) -> None: + """Test that the values left out are not sent to the API.""" + request = SearchItemsRequestContent(partnerTag="test-tag", keywords="laptop") + + body = get_request_body(request) + + self.assertEqual(sorted(body), ["keywords", "partnerTag"]) + + def test_serializes_the_enums(self) -> None: + """Test that enums are sent as the values of the API.""" + request = SearchItemsRequestContent( + partnerTag="test-tag", + keywords="laptop", + sortBy=SortBy.PRICE_COLON_LOW_TO_HIGH, + availability=Availability.INCLUDEOUTOFSTOCK, + resources=[SearchItemsResource.ITEM_INFO_DOT_TITLE], + ) + + body = get_request_body(request) + + self.assertEqual(body["sortBy"], "Price:LowToHigh") + self.assertEqual(body["availability"], "IncludeOutOfStock") + self.assertEqual(body["resources"], ["itemInfo.title"]) diff --git a/tests/amazon_creatorsapi/core/results_test.py b/tests/amazon_creatorsapi/core/results_test.py new file mode 100644 index 0000000..5a098b1 --- /dev/null +++ b/tests/amazon_creatorsapi/core/results_test.py @@ -0,0 +1,31 @@ +"""Unit tests for the result containers.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.results import ResultList +from creatorsapi_python_sdk.models.error_data import ErrorData + + +class TestResultList(unittest.TestCase): + """Tests for ResultList class.""" + + def setUp(self) -> None: + self.error = ErrorData(code="ItemNotFound", message="Item not found") + + def test_behaves_like_a_list(self) -> None: + """Test that the container is a regular list of results.""" + result = ResultList([1, 2, 3]) + self.assertIsInstance(result, list) + self.assertEqual(result, [1, 2, 3]) + self.assertEqual(len(result), 3) + + def test_keeps_errors(self) -> None: + """Test that partial errors are available in the errors attribute.""" + result = ResultList([1], errors=[self.error]) + self.assertEqual(result.errors, [self.error]) + + def test_empty_errors_by_default(self) -> None: + """Test that the errors attribute defaults to an empty list.""" + self.assertEqual(ResultList().errors, []) diff --git a/tests/amazon_creatorsapi/core/retry_test.py b/tests/amazon_creatorsapi/core/retry_test.py new file mode 100644 index 0000000..14ffa7a --- /dev/null +++ b/tests/amazon_creatorsapi/core/retry_test.py @@ -0,0 +1,69 @@ +"""Unit tests for the retry utilities.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.retry import ( + MAX_BACKOFF, + get_retry_after, + get_retry_delay, + is_retryable, +) + + +class TestIsRetryable(unittest.TestCase): + """Tests for is_retryable function.""" + + def test_retryable_statuses(self) -> None: + """Test that throttling and server errors are retried.""" + for status_code in (429, 500, 502, 503, 504): + self.assertTrue(is_retryable(status_code)) + + def test_not_retryable_statuses(self) -> None: + """Test that client errors and successes are not retried.""" + for status_code in (200, 400, 401, 403, 404, None): + self.assertFalse(is_retryable(status_code)) + + +class TestGetRetryAfter(unittest.TestCase): + """Tests for get_retry_after function.""" + + def test_reads_the_header(self) -> None: + """Test that the header value is read as seconds.""" + self.assertEqual(get_retry_after({"Retry-After": "5"}), 5.0) + + def test_header_is_case_insensitive(self) -> None: + """Test that the header is found whatever its case is.""" + self.assertEqual(get_retry_after({"retry-after": "2"}), 2.0) + + def test_missing_header(self) -> None: + """Test that no header means no requested wait.""" + self.assertIsNone(get_retry_after({})) + 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"})) + + +class TestGetRetryDelay(unittest.TestCase): + """Tests for get_retry_delay function.""" + + def test_delay_grows_with_every_attempt(self) -> None: + """Test that the wait time doubles on every attempt.""" + delays = [get_retry_delay(attempt) for attempt in range(3)] + self.assertEqual(delays, [1.0, 2.0, 4.0]) + + def test_delay_is_capped(self) -> None: + """Test that the wait time never goes over the maximum.""" + self.assertEqual(get_retry_delay(20), MAX_BACKOFF) + + def test_retry_after_takes_precedence(self) -> None: + """Test that the wait requested by Amazon is honoured.""" + self.assertEqual(get_retry_delay(0, {"Retry-After": "7"}), 7.0) + + def test_retry_after_is_capped(self) -> None: + """Test that the requested wait never goes over the maximum.""" + self.assertEqual(get_retry_delay(0, {"Retry-After": "600"}), MAX_BACKOFF) diff --git a/tests/amazon_creatorsapi/core/validation_test.py b/tests/amazon_creatorsapi/core/validation_test.py new file mode 100644 index 0000000..a59cd4e --- /dev/null +++ b/tests/amazon_creatorsapi/core/validation_test.py @@ -0,0 +1,93 @@ +"""Unit tests for the validation utilities.""" + +from __future__ import annotations + +import unittest + +from amazon_creatorsapi.core.validation import ( + build_request, + validate_retries, + validate_search_criteria, + validate_timeout, +) +from amazon_creatorsapi.errors import InvalidArgumentError +from creatorsapi_python_sdk.models.get_items_request_content import ( + GetItemsRequestContent, +) +from creatorsapi_python_sdk.models.search_items_request_content import ( + SearchItemsRequestContent, +) + + +class TestBuildRequest(unittest.TestCase): + """Tests for build_request function.""" + + def test_builds_the_request(self) -> None: + """Test that a valid request is built with its values.""" + request = build_request( + GetItemsRequestContent, + partnerTag="test-tag", + itemIds=["B0DLFMFBJW"], + ) + self.assertEqual(request.partner_tag, "test-tag") + self.assertEqual(request.item_ids, ["B0DLFMFBJW"]) + + def test_invalid_value_raises_library_error(self) -> None: + """Test that a rejected value raises an invalid argument error.""" + with self.assertRaises(InvalidArgumentError) as context: + build_request( + SearchItemsRequestContent, + partnerTag="test-tag", + keywords="laptop", + minReviewsRating=5, + ) + self.assertIn("minReviewsRating", str(context.exception)) + + +class TestValidateTimeout(unittest.TestCase): + """Tests for validate_timeout function.""" + + def test_none_is_allowed(self) -> None: + """Test that None disables the timeout.""" + self.assertIsNone(validate_timeout(None)) + + def test_returns_a_float(self) -> None: + """Test that the timeout is returned as a float.""" + self.assertEqual(validate_timeout(5), 5.0) + + def test_zero_is_rejected(self) -> None: + """Test that a timeout of zero is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_timeout(0) + + +class TestValidateRetries(unittest.TestCase): + """Tests for validate_retries function.""" + + def test_returns_the_amount(self) -> None: + """Test that a valid amount of retries is returned.""" + self.assertEqual(validate_retries(2), 2) + + def test_zero_disables_retries(self) -> None: + """Test that no retries is a valid value.""" + self.assertEqual(validate_retries(0), 0) + + def test_negative_is_rejected(self) -> None: + """Test that a negative amount of retries is rejected.""" + with self.assertRaises(InvalidArgumentError): + validate_retries(-1) + + +class TestValidateSearchCriteria(unittest.TestCase): + """Tests for validate_search_criteria function.""" + + def test_accepts_one_criterion(self) -> None: + """Test that a single criterion is enough to search.""" + validate_search_criteria(keywords="laptop", brand=None) + + def test_rejects_a_search_without_criteria(self) -> None: + """Test that a search without criteria is rejected.""" + with self.assertRaises(InvalidArgumentError) as context: + validate_search_criteria(keywords=None, brand=None) + + self.assertIn("keywords, brand", str(context.exception))