Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `get_asin` and `errors` are available directly in `amazon_creatorsapi`
- The identifier that Amazon gives to a request is part of the message of the error, so it can be reported to Amazon support
- `py.typed` marker, so the type hints of the package are used by type checkers
- `close` method and context manager support in `AmazonCreatorsApi`, to release the connections of a client that is not going to be reused

### Changed

- `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
- `throttling` is validated like the rest of the options, so a negative or invalid value raises `InvalidArgumentError` instead of being accepted or failing with a `TypeError`
- `Retry-After` is also honoured when Amazon sends it as a date instead of an amount of seconds
- Both clients resolve the auth endpoint with the same list of versions, so a new version only has to be added once

### Fixed

- Examples in the documentation that used names that do not exist, such as `SortBy.PRICE_LOW_TO_HIGH` or `GetItemsResource.ITEMINFO_TITLE`
- Documented limits of `item_count`, `min_reviews_rating` and `variation_page`, which did not match the ones accepted by the API
- `auth_endpoint` is enough to use a version that the library does not know about in `AsyncAmazonCreatorsApi`, which rejected it even with a custom endpoint
- A token response that does not hold JSON raises `AuthenticationError` in `AsyncAmazonCreatorsApi`, instead of the error of the JSON parser
- Errors reported by the transport, such as an invalid certificate, keep their reason instead of being reported as `Request failed with status 0`
- An ASIN longer than ten characters in a URL is rejected instead of being trimmed to a different item
- `get_items` raises `ItemsNotFoundError` when the response holds no requested item, instead of returning an empty list
- Threads sharing a client ask for a single token when the cached one expires, instead of one for every thread

### Removed

Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=4) # M
amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=0) # No wait time between requests
```

### Closing the client

The client keeps a pool of connections open, so it is meant to be created once and reused. Close it, or use it as a context manager, when it is not going to be used again:

```python
with AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY) as amazon:
items = amazon.get_items(["B01N5IB20Q"])
```

### Timeout

Timeout value represents the number of seconds to wait for a response before failing, being the default value 30 seconds. Use `None` to wait indefinitely.
Expand All @@ -196,7 +205,7 @@ amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, retries=0) # Fail

### Custom Endpoints

The base URL of the API and the one used to get the OAuth2 token can be replaced, which is useful to run the tests of a project against a mock server:
The base URL of the API and the one used to get the OAuth2 token can be replaced, which is useful to run the tests of a project against a mock server. Providing `auth_endpoint` also makes any `version` valid, so a new one can be used before the library knows about it:

```python
api = AmazonCreatorsApi(
Expand Down
53 changes: 23 additions & 30 deletions amazon_creatorsapi/aio/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
get_unique_items,
sort_items,
)
from amazon_creatorsapi.core.oauth import get_auth_endpoint
from amazon_creatorsapi.core.parsers import get_asin, get_items_ids
from amazon_creatorsapi.core.requests import get_request_body
from amazon_creatorsapi.core.resources import get_all_resources
Expand All @@ -35,6 +36,7 @@
validate_and_get_marketplace,
validate_retries,
validate_search_criteria,
validate_throttling,
validate_timeout,
)
from amazon_creatorsapi.errors import (
Expand All @@ -48,7 +50,7 @@
try:
import httpx

from .auth import VERSION_ENDPOINTS, AsyncOAuth2TokenManager
from .auth import AsyncOAuth2TokenManager
from .client import AsyncHttpClient, AsyncHttpResponse
except ImportError as exc: # pragma: no cover
msg = (
Expand Down Expand Up @@ -176,9 +178,10 @@ class AsyncAmazonCreatorsApi:

Raises:
InvalidArgumentError: If neither country nor marketplace is provided,
if timeout is not greater than zero, or if retries is negative.
ValueError: If version is not supported (valid versions: 2.1, 2.2, 2.3,
3.1, 3.2, 3.3).
if timeout is not greater than zero, if throttling is negative or
if retries is negative.
ValueError: If the version is not supported and no auth_endpoint is
given (valid versions: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3).

"""

Expand All @@ -197,19 +200,20 @@ def __init__(
auth_endpoint: str | None = None,
) -> None:
"""Initialize the async Amazon Creators API client."""
# Validate version early to fail fast (before token manager initialization)
self._validate_version(version)
# Resolve the endpoint early to fail fast on an unsupported version,
# which a custom endpoint makes valid
endpoint = get_auth_endpoint(version, auth_endpoint)

self._credential_id = credential_id
self._credential_secret = credential_secret
self._version = version
self._last_query_time = time.monotonic() - throttling
self.host = host
self._throttle_lock: asyncio.Lock | None = None
self.tag = tag
self.throttling = float(throttling)
self.throttling = validate_throttling(throttling)
self.timeout = validate_timeout(timeout)
self.retries = validate_retries(retries)
self._last_query_time = time.monotonic() - self.throttling

# Determine marketplace from country or direct value
self.marketplace = validate_and_get_marketplace(country, marketplace)
Expand All @@ -220,26 +224,11 @@ def __init__(
credential_id=credential_id,
credential_secret=credential_secret,
version=version,
auth_endpoint=auth_endpoint,
auth_endpoint=endpoint,
timeout=self.timeout,
)
self._owns_client = False

def _validate_version(self, version: str) -> None:
"""Validate that the API version is supported.

Args:
version: API version to validate.

Raises:
ValueError: If version is not in the list of supported versions.

"""
if version not in VERSION_ENDPOINTS:
supported = ", ".join(VERSION_ENDPOINTS.keys())
msg = f"Unsupported version: {version}. Supported versions are: {supported}"
raise ValueError(msg)

async def __aenter__(self) -> Self:
"""Enter async context manager, creating a persistent HTTP client."""
self._http_client = AsyncHttpClient(host=self.host, timeout=self.timeout)
Expand Down Expand Up @@ -273,7 +262,8 @@ async def get_items(

Duplicated items are requested only once, and the request is split into
as many API calls as needed when it goes over the limit of items that
Amazon accepts at once.
Amazon accepts at once. A call that keeps failing after the retries
raises, discarding the items returned by the previous calls.

Args:
items: One or more items, using ASIN or Amazon product URL.
Expand Down Expand Up @@ -329,14 +319,17 @@ async def get_items(
if items_result.get("items"):
found_items.extend(self._deserialize_items(items_result["items"]))

if not found_items and not include_unavailable:
sorted_items = sort_items(
found_items,
item_ids,
include_unavailable=include_unavailable,
)

if not sorted_items and not include_unavailable:
msg = f"No items have been found{format_errors(errors)}"
raise ItemsNotFoundError(msg)

return ResultList(
sort_items(found_items, item_ids, include_unavailable=include_unavailable),
errors=errors,
)
return ResultList(sorted_items, errors=errors)

async def search_items(
self,
Expand Down
82 changes: 50 additions & 32 deletions amazon_creatorsapi/aio/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@

import asyncio
import time

from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT
from typing import Any

from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT, HTTP_OK
from amazon_creatorsapi.core.oauth import (
COGNITO_SCOPE,
DEFAULT_EXPIRATION,
GRANT_TYPE,
LWA_SCOPE,
TOKEN_EXPIRATION_BUFFER,
VERSION_ENDPOINTS,
get_auth_endpoint,
get_scope,
is_lwa,
)
from amazon_creatorsapi.errors import AuthenticationError

try:
Expand All @@ -21,25 +33,18 @@
raise ImportError(msg) from exc


# OAuth2 constants
COGNITO_SCOPE = "creatorsapi/default"
LWA_SCOPE = "creatorsapi::default"
# Backward-compatible alias for existing v2.x users.
SCOPE = COGNITO_SCOPE
GRANT_TYPE = "client_credentials"

# Token expiration buffer in seconds (refresh 30s before actual expiration)
TOKEN_EXPIRATION_BUFFER = 30

# Version to auth endpoint mapping
VERSION_ENDPOINTS = {
"2.1": "https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token",
"2.2": "https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token",
"2.3": "https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token",
"3.1": "https://api.amazon.com/auth/o2/token",
"3.2": "https://api.amazon.co.uk/auth/o2/token",
"3.3": "https://api.amazon.co.jp/auth/o2/token",
}
__all__ = [
"COGNITO_SCOPE",
"GRANT_TYPE",
"LWA_SCOPE",
"SCOPE",
"TOKEN_EXPIRATION_BUFFER",
"VERSION_ENDPOINTS",
"AsyncOAuth2TokenManager",
]


class AsyncOAuth2TokenManager:
Expand Down Expand Up @@ -98,23 +103,15 @@ def _determine_auth_endpoint(
ValueError: If version is not supported and no custom endpoint provided.

"""
if auth_endpoint and auth_endpoint.strip():
return auth_endpoint

if version not in VERSION_ENDPOINTS:
supported = ", ".join(VERSION_ENDPOINTS.keys())
msg = f"Unsupported version: {version}. Supported versions are: {supported}"
raise ValueError(msg)

return VERSION_ENDPOINTS[version]
return get_auth_endpoint(version, auth_endpoint)

def is_lwa(self) -> bool:
"""Return whether this token manager uses the LWA auth flow."""
return self._version.startswith("3.")
return is_lwa(self._version)

def get_scope(self) -> str:
"""Return the version-appropriate OAuth2 scope."""
return LWA_SCOPE if self.is_lwa() else COGNITO_SCOPE
return get_scope(self._version)

@property
def lock(self) -> asyncio.Lock:
Expand Down Expand Up @@ -205,15 +202,15 @@ async def refresh_token(self) -> str:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)

if response.status_code != 200: # noqa: PLR2004
if response.status_code != HTTP_OK:
self.clear_token()
msg = (
f"OAuth2 token request failed with status {response.status_code}: "
f"{response.text}"
)
raise AuthenticationError(msg)

data = response.json()
data = self._parse_token_response(response)

if "access_token" not in data:
self.clear_token()
Expand All @@ -222,7 +219,7 @@ async def refresh_token(self) -> str:

self._access_token = data["access_token"]
# Set expiration time with buffer to avoid edge cases
expires_in = data.get("expires_in", 3600)
expires_in = data.get("expires_in", DEFAULT_EXPIRATION)
self._expires_at = time.time() + expires_in - TOKEN_EXPIRATION_BUFFER

except httpx.RequestError as exc:
Expand All @@ -236,6 +233,27 @@ async def refresh_token(self) -> str:
raise AuthenticationError(msg)
return self._access_token

def _parse_token_response(self, response: httpx.Response) -> dict[str, Any]:
"""Parse the token response as JSON.

Args:
response: Response from the auth endpoint.

Returns:
The parsed response body.

Raises:
AuthenticationError: If the response is not valid JSON.

"""
try:
data: dict[str, Any] = response.json()
except ValueError as error:
self.clear_token()
msg = f"Failed to parse OAuth2 token response: {error}"
raise AuthenticationError(msg) from error
return data

def clear_token(self) -> None:
"""Clear the cached token, forcing a refresh on the next get_token() call."""
self._access_token = None
Expand Down
Loading
Loading