diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0ef926..4b809c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }}) diff --git a/README.md b/README.md index 321ac94..f860e4e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # AmazonAPIWrapper [![PyPI version](https://img.shields.io/pypi/v/AmazonAPIWrapper.svg)](https://pypi.org/project/AmazonAPIWrapper/) -[![Python Versions](https://img.shields.io/pypi/pyversions/AmazonAPIWrapper.svg)](https://pypi.org/project/AmazonAPIWrapper/) +[![Python Versions](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://pypi.org/project/AmazonAPIWrapper/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![CI](https://github.com/lv10/amazonapi/actions/workflows/ci.yml/badge.svg)](https://github.com/lv10/amazonapi/actions/workflows/ci.yml) diff --git a/amazon/auth/oauth.py b/amazon/auth/oauth.py index 9eea69c..2379360 100644 --- a/amazon/auth/oauth.py +++ b/amazon/auth/oauth.py @@ -5,7 +5,7 @@ import asyncio import threading import time -from dataclasses import dataclass +from dataclasses import dataclass, field import httpx @@ -16,7 +16,7 @@ class OAuthToken: """OAuth 2.0 Access Token container.""" - access_token: str + access_token: str = field(repr=False) token_type: str expires_at: float # Epoch timestamp in seconds scope: str | None = None @@ -29,6 +29,16 @@ def is_expired(self, buffer_seconds: float = 300.0) -> bool: """ return time.time() >= (self.expires_at - buffer_seconds) + def __repr__(self) -> str: + if len(self.access_token) > 8: + masked = f"{self.access_token[:4]}...{self.access_token[-4:]}" + else: + masked = "***" + return ( + f"OAuthToken(access_token={masked!r}, token_type={self.token_type!r}, " + f"expires_at={self.expires_at}, scope={self.scope!r})" + ) + class OAuthTokenManager: """Thread-safe and coroutine-safe manager for OAuth 2.0 access tokens.""" @@ -40,6 +50,7 @@ def __init__( token_url: str, scope: str = "creatorsapi::default", buffer_seconds: float = 300.0, + timeout: float = 15.0, ) -> None: """Initialize OAuthTokenManager. @@ -49,20 +60,31 @@ def __init__( token_url: Regional OAuth 2.0 token endpoint (e.g. https://api.amazon.com/auth/o2/token). scope: OAuth scope (default: "creatorsapi::default"). buffer_seconds: Refresh buffer window in seconds before token expires. + timeout: HTTP request timeout in seconds when creating standalone clients. """ self.credential_id = credential_id.strip() self.credential_secret = credential_secret.strip() self.token_url = token_url.strip() self.scope = scope.strip() self.buffer_seconds = buffer_seconds + self.timeout = timeout self._cached_token: OAuthToken | None = None self._sync_lock = threading.Lock() self._async_lock: asyncio.Lock | None = None + self._async_lock_init_lock = threading.Lock() + + def __repr__(self) -> str: + return ( + f"OAuthTokenManager(credential_id={self.credential_id!r}, credential_secret='***', " + f"token_url={self.token_url!r}, scope={self.scope!r})" + ) def _get_async_lock(self) -> asyncio.Lock: if self._async_lock is None: - self._async_lock = asyncio.Lock() + with self._async_lock_init_lock: + if self._async_lock is None: + self._async_lock = asyncio.Lock() return self._async_lock def _build_token_payload(self) -> dict[str, str]: @@ -75,7 +97,7 @@ def _build_token_payload(self) -> dict[str, str]: def _parse_token_response(self, response: httpx.Response) -> OAuthToken: if response.status_code != 200: - error_details = response.text + error_details = response.text[:2048] if len(response.text) > 2048 else response.text try: data = response.json() error_msg = data.get("error_description") or data.get("error") or error_details @@ -124,7 +146,7 @@ def get_token(self, client: httpx.Client | None = None) -> str: payload = self._build_token_payload() should_close = False if client is None: - client = httpx.Client(timeout=15.0) + client = httpx.Client(timeout=self.timeout) should_close = True try: @@ -149,13 +171,14 @@ async def get_token_async(self, client: httpx.AsyncClient | None = None) -> str: Bearer access token string. """ async with self._get_async_lock(): - if self._cached_token and not self._cached_token.is_expired(self.buffer_seconds): - return self._cached_token.access_token + with self._sync_lock: + if self._cached_token and not self._cached_token.is_expired(self.buffer_seconds): + return self._cached_token.access_token payload = self._build_token_payload() should_close = False if client is None: - client = httpx.AsyncClient(timeout=15.0) + client = httpx.AsyncClient(timeout=self.timeout) should_close = True try: @@ -164,8 +187,10 @@ async def get_token_async(self, client: httpx.AsyncClient | None = None) -> str: data=payload, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) - self._cached_token = self._parse_token_response(resp) - return self._cached_token.access_token + token = self._parse_token_response(resp) + with self._sync_lock: + self._cached_token = token + return token.access_token finally: if should_close: await client.aclose() diff --git a/amazon/auth/sigv4.py b/amazon/auth/sigv4.py index a4ec13d..5def4d2 100644 --- a/amazon/auth/sigv4.py +++ b/amazon/auth/sigv4.py @@ -41,6 +41,12 @@ def __init__( self.aws_region = aws_region.strip() self.service = service.strip() + def __repr__(self) -> str: + return ( + f"SigV4Signer(access_key={self.access_key!r}, secret_key='***', " + f"aws_region={self.aws_region!r}, service={self.service!r})" + ) + def sign( self, host: str, diff --git a/amazon/clients/base.py b/amazon/clients/base.py index e9667c2..e3dd532 100644 --- a/amazon/clients/base.py +++ b/amazon/clients/base.py @@ -2,7 +2,10 @@ from __future__ import annotations +import datetime +import email.utils import logging +import random import httpx @@ -17,6 +20,8 @@ logger = logging.getLogger("amazonapi") +MAX_ERROR_BODY_LENGTH = 4096 + DEFAULT_ITEM_RESOURCES: list[str] = [ "ItemInfo.Title", "ItemInfo.ByLineInfo", @@ -52,11 +57,59 @@ ] +def parse_retry_after(response: httpx.Response, default: float) -> float: + """Parse HTTP Retry-After header if present, returning delay in seconds. + + Supports integer seconds and HTTP-date formats. + """ + retry_header = response.headers.get("retry-after") or response.headers.get("Retry-After") + if not retry_header: + return default + + retry_header = retry_header.strip() + try: + # Try integer seconds + seconds = float(retry_header) + return max(0.0, seconds) + except ValueError: + pass + + try: + # Try HTTP-date format (RFC 7231) + target_date = email.utils.parsedate_to_datetime(retry_header) + now = datetime.datetime.now(datetime.timezone.utc) + delta = (target_date - now).total_seconds() + return max(0.0, delta) + except Exception: + return default + + +def calculate_backoff(retry_count: int, base_delay: float, max_delay: float = 60.0) -> float: + """Calculate exponential backoff with full jitter to avoid thundering herds. + + Args: + retry_count: Attempt number (1-based index). + base_delay: Initial base delay in seconds. + max_delay: Maximum delay cap in seconds. + + Returns: + Random jittered delay in seconds between 0 and min(max_delay, base_delay * 2^(retry_count-1)). + """ + delay_ceiling = min(max_delay, base_delay * (2 ** max(0, retry_count - 1))) + return random.uniform(0.0, delay_ceiling) + + def map_http_error(response: httpx.Response) -> AmazonAPIError: - """Map HTTP response to specific AmazonAPIError subclass.""" + """Map HTTP response to specific AmazonAPIError subclass with bounded memory footprint.""" status = response.status_code error_code = None - message = response.text + raw_text = response.text + truncated_text = ( + raw_text[:MAX_ERROR_BODY_LENGTH] + "... [truncated]" + if len(raw_text) > MAX_ERROR_BODY_LENGTH + else raw_text + ) + message = truncated_text try: data = response.json() @@ -80,7 +133,7 @@ def map_http_error(response: httpx.Response) -> AmazonAPIError: message=f"Rate limit exceeded: {message}", status_code=status, error_code=error_code or "TooManyRequests", - response_body=response.text, + response_body=truncated_text, headers=headers_dict, ) elif status in (401, 403) or error_code in ("InvalidClientTokenId", "MissingClientTokenId", "AccessDeniedException"): @@ -88,7 +141,7 @@ def map_http_error(response: httpx.Response) -> AmazonAPIError: message=f"Authentication failed: {message}", status_code=status, error_code=error_code or "Unauthorized", - response_body=response.text, + response_body=truncated_text, headers=headers_dict, ) elif status == 400 or error_code in ("AWS.MissingParameters", "AWS.InvalidParameterValue", "InvalidParameterValue"): @@ -96,7 +149,7 @@ def map_http_error(response: httpx.Response) -> AmazonAPIError: message=f"Bad request: {message}", status_code=status, error_code=error_code or "BadRequest", - response_body=response.text, + response_body=truncated_text, headers=headers_dict, ) elif status == 404 or error_code in ("ResourceNotFound", "NoExactMatches"): @@ -104,7 +157,7 @@ def map_http_error(response: httpx.Response) -> AmazonAPIError: message=f"Resource not found: {message}", status_code=status, error_code=error_code or "NotFound", - response_body=response.text, + response_body=truncated_text, headers=headers_dict, ) elif status >= 500 or error_code == "InternalError": @@ -112,7 +165,7 @@ def map_http_error(response: httpx.Response) -> AmazonAPIError: message=f"Amazon server error (HTTP {status}): {message}", status_code=status, error_code=error_code or "InternalError", - response_body=response.text, + response_body=truncated_text, headers=headers_dict, ) else: @@ -120,6 +173,6 @@ def map_http_error(response: httpx.Response) -> AmazonAPIError: message=f"API request failed with status {status}: {message}", status_code=status, error_code=error_code, - response_body=response.text, + response_body=truncated_text, headers=headers_dict, ) diff --git a/amazon/clients/creators.py b/amazon/clients/creators.py index bedec79..183666b 100644 --- a/amazon/clients/creators.py +++ b/amazon/clients/creators.py @@ -4,6 +4,7 @@ import asyncio import logging +import threading import time from typing import Any, cast @@ -14,7 +15,9 @@ DEFAULT_BROWSE_NODE_RESOURCES, DEFAULT_ITEM_RESOURCES, DEFAULT_VARIATION_RESOURCES, + calculate_backoff, map_http_error, + parse_retry_after, ) from amazon.exceptions import AmazonBadRequestError from amazon.marketplaces import Marketplace, MarketplaceInfo, resolve_marketplace @@ -24,6 +27,76 @@ logger = logging.getLogger("amazonapi") CREATORS_API_BASE_URL = "https://creatorsapi.amazon/catalog/v1" +ALLOWED_CREATORS_ENDPOINTS = frozenset({"getItems", "searchItems", "getVariations", "getBrowseNodes"}) + + +def _validate_item_ids(item_ids: str | list[str]) -> list[str]: + if isinstance(item_ids, str): + ids = [item_ids] + elif isinstance(item_ids, (list, tuple)): + ids = list(item_ids) + else: + raise AmazonBadRequestError("item_ids must be a string or list of strings") + + cleaned = [str(i).strip() for i in ids if str(i).strip()] + if not cleaned: + raise AmazonBadRequestError("item_ids list cannot be empty") + if len(cleaned) > 10: + raise AmazonBadRequestError("A maximum of 10 item_ids can be requested per call") + return cleaned + + +def _validate_asin(asin: str) -> str: + if not asin or not isinstance(asin, str) or not asin.strip(): + raise AmazonBadRequestError("asin cannot be empty") + return asin.strip() + + +def _validate_browse_node_ids(browse_node_ids: str | int | list[str | int]) -> list[str]: + if isinstance(browse_node_ids, (str, int)): + ids = [browse_node_ids] + elif isinstance(browse_node_ids, (list, tuple)): + ids = list(browse_node_ids) + else: + raise AmazonBadRequestError("browse_node_ids must be a string, integer, or list") + + cleaned = [str(n).strip() for n in ids if str(n).strip()] + if not cleaned: + raise AmazonBadRequestError("browse_node_ids list cannot be empty") + if len(cleaned) > 10: + raise AmazonBadRequestError("A maximum of 10 browse_node_ids can be requested per call") + return cleaned + + +def _validate_search_params( + item_count: int, + item_page: int, + min_price: int | None = None, + max_price: int | None = None, + min_reviews_rating: int | None = None, + min_saving_percent: int | None = None, +) -> None: + if not (1 <= item_count <= 10): + raise AmazonBadRequestError(f"item_count must be between 1 and 10, got {item_count}") + if not (1 <= item_page <= 10): + raise AmazonBadRequestError(f"item_page must be between 1 and 10, got {item_page}") + if min_price is not None and min_price < 0: + raise AmazonBadRequestError("min_price cannot be negative") + if max_price is not None and max_price < 0: + raise AmazonBadRequestError("max_price cannot be negative") + if min_price is not None and max_price is not None and min_price > max_price: + raise AmazonBadRequestError(f"min_price ({min_price}) cannot be greater than max_price ({max_price})") + if min_reviews_rating is not None and not (1 <= min_reviews_rating <= 5): + raise AmazonBadRequestError(f"min_reviews_rating must be between 1 and 5, got {min_reviews_rating}") + if min_saving_percent is not None and not (1 <= min_saving_percent <= 100): + raise AmazonBadRequestError(f"min_saving_percent must be between 1 and 100, got {min_saving_percent}") + + +def _validate_variations_params(variation_count: int, variation_page: int) -> None: + if not (1 <= variation_count <= 10): + raise AmazonBadRequestError(f"variation_count must be between 1 and 10, got {variation_count}") + if not (1 <= variation_page <= 10): + raise AmazonBadRequestError(f"variation_page must be between 1 and 10, got {variation_page}") class AmazonCreatorsAPI: @@ -57,14 +130,24 @@ def __init__( credential_id=credential_id, credential_secret=credential_secret, token_url=self.marketplace_info.token_url, + timeout=timeout, ) self._client: httpx.Client | None = None + self._client_lock = threading.Lock() + + def __repr__(self) -> str: + return ( + f"AmazonCreatorsAPI(credential_id={self.token_manager.credential_id!r}, " + f"credential_secret='***', marketplace={self.marketplace_info.country_code!r})" + ) @property def client(self) -> httpx.Client: - """Get or initialize the underlying httpx.Client.""" + """Get or initialize the underlying httpx.Client safely.""" if self._client is None or self._client.is_closed: - self._client = httpx.Client(timeout=self.timeout) + with self._client_lock: + if self._client is None or self._client.is_closed: + self._client = httpx.Client(timeout=self.timeout) return self._client def __enter__(self) -> AmazonCreatorsAPI: @@ -75,10 +158,14 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: def close(self) -> None: """Close the underlying HTTP client session.""" - if self._client and not self._client.is_closed: - self._client.close() + with self._client_lock: + if self._client and not self._client.is_closed: + self._client.close() def _execute_request(self, endpoint: str, payload: dict[str, Any], marketplace: str | Marketplace | None = None) -> dict[str, Any]: + if endpoint not in ALLOWED_CREATORS_ENDPOINTS: + raise AmazonBadRequestError(f"Invalid Creators API endpoint: {endpoint}") + target_mp = resolve_marketplace(marketplace) if marketplace else self.marketplace_info url = f"{CREATORS_API_BASE_URL}/{endpoint.lstrip('/')}" @@ -100,12 +187,15 @@ def _execute_request(self, endpoint: str, payload: dict[str, Any], marketplace: if response.status_code == 401 and retries < self.max_retries: self.token_manager.clear_cache() retries += 1 - time.sleep(self.retry_delay) + delay = calculate_backoff(retries, self.retry_delay) + time.sleep(delay) continue - if response.status_code == 429 and retries < self.max_retries: + if response.status_code in (429, 502, 503, 504) and retries < self.max_retries: retries += 1 - time.sleep(self.retry_delay * (2 ** (retries - 1))) + default_delay = calculate_backoff(retries, self.retry_delay) + delay = parse_retry_after(response, default=default_delay) + time.sleep(delay) continue raise map_http_error(response) @@ -113,7 +203,8 @@ def _execute_request(self, endpoint: str, payload: dict[str, Any], marketplace: except httpx.RequestError as exc: if retries < self.max_retries: retries += 1 - time.sleep(self.retry_delay * (2 ** (retries - 1))) + delay = calculate_backoff(retries, self.retry_delay) + time.sleep(delay) continue raise exc @@ -135,17 +226,10 @@ def get_items( Returns: GetItemsResult model or raw dictionary. """ - if isinstance(item_ids, str): - item_ids = [item_ids] - - if not item_ids: - raise AmazonBadRequestError("item_ids list cannot be empty") - - if len(item_ids) > 10: - raise AmazonBadRequestError("A maximum of 10 item_ids can be requested per call") + validated_item_ids = _validate_item_ids(item_ids) payload: dict[str, Any] = { - "itemIds": item_ids, + "itemIds": validated_item_ids, "resources": resources if resources is not None else DEFAULT_ITEM_RESOURCES, } @@ -198,6 +282,15 @@ def search_items( Returns: SearchResult model or raw dictionary. """ + _validate_search_params( + item_count=item_count, + item_page=item_page, + min_price=min_price, + max_price=max_price, + min_reviews_rating=min_reviews_rating, + min_saving_percent=min_saving_percent, + ) + payload: dict[str, Any] = { "searchIndex": search_index, "itemCount": item_count, @@ -255,11 +348,11 @@ def get_variations( Returns: GetVariationsResult model or raw dictionary. """ - if not asin: - raise AmazonBadRequestError("asin cannot be empty") + validated_asin = _validate_asin(asin) + _validate_variations_params(variation_count=variation_count, variation_page=variation_page) payload: dict[str, Any] = { - "asin": asin, + "asin": validated_asin, "variationCount": variation_count, "variationPage": variation_page, "resources": resources if resources is not None else DEFAULT_VARIATION_RESOURCES, @@ -286,18 +379,10 @@ def get_browse_nodes( Returns: BrowseNodesResult model or raw dictionary. """ - if isinstance(browse_node_ids, (str, int)): - browse_node_ids = [browse_node_ids] - - node_ids_str = [str(n) for n in browse_node_ids] - if not node_ids_str: - raise AmazonBadRequestError("browse_node_ids list cannot be empty") - - if len(node_ids_str) > 10: - raise AmazonBadRequestError("A maximum of 10 browse_node_ids can be requested per call") + validated_node_ids = _validate_browse_node_ids(browse_node_ids) payload: dict[str, Any] = { - "browseNodeIds": node_ids_str, + "browseNodeIds": validated_node_ids, "resources": resources if resources is not None else DEFAULT_BROWSE_NODE_RESOURCES, } @@ -327,14 +412,24 @@ def __init__( credential_id=credential_id, credential_secret=credential_secret, token_url=self.marketplace_info.token_url, + timeout=timeout, ) self._client: httpx.AsyncClient | None = None + self._client_lock = threading.Lock() + + def __repr__(self) -> str: + return ( + f"AsyncAmazonCreatorsAPI(credential_id={self.token_manager.credential_id!r}, " + f"credential_secret='***', marketplace={self.marketplace_info.country_code!r})" + ) @property def client(self) -> httpx.AsyncClient: - """Get or initialize the underlying httpx.AsyncClient.""" + """Get or initialize the underlying httpx.AsyncClient safely.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout) + with self._client_lock: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=self.timeout) return self._client async def __aenter__(self) -> AsyncAmazonCreatorsAPI: @@ -345,12 +440,16 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: async def close(self) -> None: """Close the underlying HTTP client session.""" - if self._client and not self._client.is_closed: - await self._client.aclose() + with self._client_lock: + if self._client and not self._client.is_closed: + await self._client.aclose() async def _execute_request( self, endpoint: str, payload: dict[str, Any], marketplace: str | Marketplace | None = None ) -> dict[str, Any]: + if endpoint not in ALLOWED_CREATORS_ENDPOINTS: + raise AmazonBadRequestError(f"Invalid Creators API endpoint: {endpoint}") + target_mp = resolve_marketplace(marketplace) if marketplace else self.marketplace_info url = f"{CREATORS_API_BASE_URL}/{endpoint.lstrip('/')}" @@ -372,12 +471,15 @@ async def _execute_request( if response.status_code == 401 and retries < self.max_retries: self.token_manager.clear_cache() retries += 1 - await asyncio.sleep(self.retry_delay) + delay = calculate_backoff(retries, self.retry_delay) + await asyncio.sleep(delay) continue - if response.status_code == 429 and retries < self.max_retries: + if response.status_code in (429, 502, 503, 504) and retries < self.max_retries: retries += 1 - await asyncio.sleep(self.retry_delay * (2 ** (retries - 1))) + default_delay = calculate_backoff(retries, self.retry_delay) + delay = parse_retry_after(response, default=default_delay) + await asyncio.sleep(delay) continue raise map_http_error(response) @@ -385,7 +487,8 @@ async def _execute_request( except httpx.RequestError as exc: if retries < self.max_retries: retries += 1 - await asyncio.sleep(self.retry_delay * (2 ** (retries - 1))) + delay = calculate_backoff(retries, self.retry_delay) + await asyncio.sleep(delay) continue raise exc @@ -397,17 +500,10 @@ async def get_items( raw: bool = False, ) -> GetItemsResult | dict[str, Any]: """Retrieve detailed product information for up to 10 ASINs asynchronously.""" - if isinstance(item_ids, str): - item_ids = [item_ids] - - if not item_ids: - raise AmazonBadRequestError("item_ids list cannot be empty") - - if len(item_ids) > 10: - raise AmazonBadRequestError("A maximum of 10 item_ids can be requested per call") + validated_item_ids = _validate_item_ids(item_ids) payload: dict[str, Any] = { - "itemIds": item_ids, + "itemIds": validated_item_ids, "resources": resources if resources is not None else DEFAULT_ITEM_RESOURCES, } @@ -436,6 +532,15 @@ async def search_items( raw: bool = False, ) -> SearchResult | dict[str, Any]: """Search products across the Amazon catalog asynchronously.""" + _validate_search_params( + item_count=item_count, + item_page=item_page, + min_price=min_price, + max_price=max_price, + min_reviews_rating=min_reviews_rating, + min_saving_percent=min_saving_percent, + ) + payload: dict[str, Any] = { "searchIndex": search_index, "itemCount": item_count, @@ -481,11 +586,11 @@ async def get_variations( raw: bool = False, ) -> GetVariationsResult | dict[str, Any]: """Retrieve variation items for a parent ASIN asynchronously.""" - if not asin: - raise AmazonBadRequestError("asin cannot be empty") + validated_asin = _validate_asin(asin) + _validate_variations_params(variation_count=variation_count, variation_page=variation_page) payload: dict[str, Any] = { - "asin": asin, + "asin": validated_asin, "variationCount": variation_count, "variationPage": variation_page, "resources": resources if resources is not None else DEFAULT_VARIATION_RESOURCES, @@ -502,18 +607,10 @@ async def get_browse_nodes( raw: bool = False, ) -> BrowseNodesResult | dict[str, Any]: """Retrieve category browse node information for up to 10 IDs asynchronously.""" - if isinstance(browse_node_ids, (str, int)): - browse_node_ids = [browse_node_ids] - - node_ids_str = [str(n) for n in browse_node_ids] - if not node_ids_str: - raise AmazonBadRequestError("browse_node_ids list cannot be empty") - - if len(node_ids_str) > 10: - raise AmazonBadRequestError("A maximum of 10 browse_node_ids can be requested per call") + validated_node_ids = _validate_browse_node_ids(browse_node_ids) payload: dict[str, Any] = { - "browseNodeIds": node_ids_str, + "browseNodeIds": validated_node_ids, "resources": resources if resources is not None else DEFAULT_BROWSE_NODE_RESOURCES, } diff --git a/amazon/clients/paapi5.py b/amazon/clients/paapi5.py index 8ecb5da..1ef7e83 100644 --- a/amazon/clients/paapi5.py +++ b/amazon/clients/paapi5.py @@ -5,6 +5,7 @@ import asyncio import json import logging +import threading import time from typing import Any, cast @@ -15,7 +16,9 @@ DEFAULT_BROWSE_NODE_RESOURCES, DEFAULT_ITEM_RESOURCES, DEFAULT_VARIATION_RESOURCES, + calculate_backoff, map_http_error, + parse_retry_after, ) from amazon.exceptions import AmazonBadRequestError from amazon.marketplaces import Marketplace, MarketplaceInfo, resolve_marketplace @@ -24,6 +27,77 @@ logger = logging.getLogger("amazonapi") +ALLOWED_PAAPI5_OPERATIONS = frozenset({"GetItems", "SearchItems", "GetVariations", "GetBrowseNodes"}) + + +def _validate_item_ids(item_ids: str | list[str]) -> list[str]: + if isinstance(item_ids, str): + ids = [item_ids] + elif isinstance(item_ids, (list, tuple)): + ids = list(item_ids) + else: + raise AmazonBadRequestError("item_ids must be a string or list of strings") + + cleaned = [str(i).strip() for i in ids if str(i).strip()] + if not cleaned: + raise AmazonBadRequestError("item_ids list cannot be empty") + if len(cleaned) > 10: + raise AmazonBadRequestError("A maximum of 10 item_ids can be requested per call") + return cleaned + + +def _validate_asin(asin: str) -> str: + if not asin or not isinstance(asin, str) or not asin.strip(): + raise AmazonBadRequestError("asin cannot be empty") + return asin.strip() + + +def _validate_browse_node_ids(browse_node_ids: str | int | list[str | int]) -> list[str]: + if isinstance(browse_node_ids, (str, int)): + ids = [browse_node_ids] + elif isinstance(browse_node_ids, (list, tuple)): + ids = list(browse_node_ids) + else: + raise AmazonBadRequestError("browse_node_ids must be a string, integer, or list") + + cleaned = [str(n).strip() for n in ids if str(n).strip()] + if not cleaned: + raise AmazonBadRequestError("browse_node_ids list cannot be empty") + if len(cleaned) > 10: + raise AmazonBadRequestError("A maximum of 10 browse_node_ids can be requested per call") + return cleaned + + +def _validate_search_params( + item_count: int, + item_page: int, + min_price: int | None = None, + max_price: int | None = None, + min_reviews_rating: int | None = None, + min_saving_percent: int | None = None, +) -> None: + if not (1 <= item_count <= 10): + raise AmazonBadRequestError(f"item_count must be between 1 and 10, got {item_count}") + if not (1 <= item_page <= 10): + raise AmazonBadRequestError(f"item_page must be between 1 and 10, got {item_page}") + if min_price is not None and min_price < 0: + raise AmazonBadRequestError("min_price cannot be negative") + if max_price is not None and max_price < 0: + raise AmazonBadRequestError("max_price cannot be negative") + if min_price is not None and max_price is not None and min_price > max_price: + raise AmazonBadRequestError(f"min_price ({min_price}) cannot be greater than max_price ({max_price})") + if min_reviews_rating is not None and not (1 <= min_reviews_rating <= 5): + raise AmazonBadRequestError(f"min_reviews_rating must be between 1 and 5, got {min_reviews_rating}") + if min_saving_percent is not None and not (1 <= min_saving_percent <= 100): + raise AmazonBadRequestError(f"min_saving_percent must be between 1 and 100, got {min_saving_percent}") + + +def _validate_variations_params(variation_count: int, variation_page: int) -> None: + if not (1 <= variation_count <= 10): + raise AmazonBadRequestError(f"variation_count must be between 1 and 10, got {variation_count}") + if not (1 <= variation_page <= 10): + raise AmazonBadRequestError(f"variation_page must be between 1 and 10, got {variation_page}") + class AmazonPAAPI5: """Synchronous client for Amazon PA-API 5.0 using AWS SigV4.""" @@ -66,12 +140,21 @@ def __init__( aws_region=self.marketplace_info.aws_region, ) self._client: httpx.Client | None = None + self._client_lock = threading.Lock() + + def __repr__(self) -> str: + return ( + f"AmazonPAAPI5(access_key={self.access_key!r}, secret_key='***', " + f"associate_tag={self.associate_tag!r}, marketplace={self.marketplace_info.country_code!r})" + ) @property def client(self) -> httpx.Client: - """Get or initialize the underlying httpx.Client.""" + """Get or initialize the underlying httpx.Client safely.""" if self._client is None or self._client.is_closed: - self._client = httpx.Client(timeout=self.timeout) + with self._client_lock: + if self._client is None or self._client.is_closed: + self._client = httpx.Client(timeout=self.timeout) return self._client def __enter__(self) -> AmazonPAAPI5: @@ -82,8 +165,9 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: def close(self) -> None: """Close the underlying HTTP client session.""" - if self._client and not self._client.is_closed: - self._client.close() + with self._client_lock: + if self._client and not self._client.is_closed: + self._client.close() def _execute_request( self, @@ -91,6 +175,9 @@ def _execute_request( payload: dict[str, Any], marketplace: str | Marketplace | None = None, ) -> dict[str, Any]: + if operation not in ALLOWED_PAAPI5_OPERATIONS: + raise AmazonBadRequestError(f"Invalid PA-API 5.0 operation: {operation}") + target_mp = resolve_marketplace(marketplace) if marketplace else self.marketplace_info host = target_mp.paapi_host path = f"/paapi5/{operation.lower()}" @@ -110,9 +197,11 @@ def _execute_request( if response.status_code == 200: return cast(dict[str, Any], response.json()) - if response.status_code == 429 and retries < self.max_retries: + if response.status_code in (429, 502, 503, 504) and retries < self.max_retries: retries += 1 - time.sleep(self.retry_delay * (2 ** (retries - 1))) + default_delay = calculate_backoff(retries, self.retry_delay) + delay = parse_retry_after(response, default=default_delay) + time.sleep(delay) continue raise map_http_error(response) @@ -120,7 +209,8 @@ def _execute_request( except httpx.RequestError as exc: if retries < self.max_retries: retries += 1 - time.sleep(self.retry_delay * (2 ** (retries - 1))) + delay = calculate_backoff(retries, self.retry_delay) + time.sleep(delay) continue raise exc @@ -132,17 +222,10 @@ def get_items( raw: bool = False, ) -> GetItemsResult | dict[str, Any]: """Retrieve detailed product information for up to 10 ASINs via PA-API 5.0.""" - if isinstance(item_ids, str): - item_ids = [item_ids] - - if not item_ids: - raise AmazonBadRequestError("item_ids list cannot be empty") - - if len(item_ids) > 10: - raise AmazonBadRequestError("A maximum of 10 item_ids can be requested per call") + validated_item_ids = _validate_item_ids(item_ids) payload: dict[str, Any] = { - "ItemIds": item_ids, + "ItemIds": validated_item_ids, "Resources": resources if resources is not None else DEFAULT_ITEM_RESOURCES, } @@ -171,6 +254,15 @@ def search_items( raw: bool = False, ) -> SearchResult | dict[str, Any]: """Search products via PA-API 5.0.""" + _validate_search_params( + item_count=item_count, + item_page=item_page, + min_price=min_price, + max_price=max_price, + min_reviews_rating=min_reviews_rating, + min_saving_percent=min_saving_percent, + ) + payload: dict[str, Any] = { "SearchIndex": search_index, "ItemCount": item_count, @@ -216,11 +308,11 @@ def get_variations( raw: bool = False, ) -> GetVariationsResult | dict[str, Any]: """Retrieve variation items via PA-API 5.0.""" - if not asin: - raise AmazonBadRequestError("asin cannot be empty") + validated_asin = _validate_asin(asin) + _validate_variations_params(variation_count=variation_count, variation_page=variation_page) payload: dict[str, Any] = { - "ASIN": asin, + "ASIN": validated_asin, "VariationCount": variation_count, "VariationPage": variation_page, "Resources": resources if resources is not None else DEFAULT_VARIATION_RESOURCES, @@ -237,18 +329,10 @@ def get_browse_nodes( raw: bool = False, ) -> BrowseNodesResult | dict[str, Any]: """Retrieve category browse node information via PA-API 5.0.""" - if isinstance(browse_node_ids, (str, int)): - browse_node_ids = [browse_node_ids] - - node_ids_str = [str(n) for n in browse_node_ids] - if not node_ids_str: - raise AmazonBadRequestError("browse_node_ids list cannot be empty") - - if len(node_ids_str) > 10: - raise AmazonBadRequestError("A maximum of 10 browse_node_ids can be requested per call") + validated_node_ids = _validate_browse_node_ids(browse_node_ids) payload: dict[str, Any] = { - "BrowseNodeIds": node_ids_str, + "BrowseNodeIds": validated_node_ids, "Resources": resources if resources is not None else DEFAULT_BROWSE_NODE_RESOURCES, } @@ -286,12 +370,21 @@ def __init__( aws_region=self.marketplace_info.aws_region, ) self._client: httpx.AsyncClient | None = None + self._client_lock = threading.Lock() + + def __repr__(self) -> str: + return ( + f"AsyncAmazonPAAPI5(access_key={self.access_key!r}, secret_key='***', " + f"associate_tag={self.associate_tag!r}, marketplace={self.marketplace_info.country_code!r})" + ) @property def client(self) -> httpx.AsyncClient: - """Get or initialize the underlying httpx.AsyncClient.""" + """Get or initialize the underlying httpx.AsyncClient safely.""" if self._client is None or self._client.is_closed: - self._client = httpx.AsyncClient(timeout=self.timeout) + with self._client_lock: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(timeout=self.timeout) return self._client async def __aenter__(self) -> AsyncAmazonPAAPI5: @@ -302,8 +395,9 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: async def close(self) -> None: """Close the underlying HTTP client session.""" - if self._client and not self._client.is_closed: - await self._client.aclose() + with self._client_lock: + if self._client and not self._client.is_closed: + await self._client.aclose() async def _execute_request( self, @@ -311,6 +405,9 @@ async def _execute_request( payload: dict[str, Any], marketplace: str | Marketplace | None = None, ) -> dict[str, Any]: + if operation not in ALLOWED_PAAPI5_OPERATIONS: + raise AmazonBadRequestError(f"Invalid PA-API 5.0 operation: {operation}") + target_mp = resolve_marketplace(marketplace) if marketplace else self.marketplace_info host = target_mp.paapi_host path = f"/paapi5/{operation.lower()}" @@ -330,9 +427,11 @@ async def _execute_request( if response.status_code == 200: return cast(dict[str, Any], response.json()) - if response.status_code == 429 and retries < self.max_retries: + if response.status_code in (429, 502, 503, 504) and retries < self.max_retries: retries += 1 - await asyncio.sleep(self.retry_delay * (2 ** (retries - 1))) + default_delay = calculate_backoff(retries, self.retry_delay) + delay = parse_retry_after(response, default=default_delay) + await asyncio.sleep(delay) continue raise map_http_error(response) @@ -340,7 +439,8 @@ async def _execute_request( except httpx.RequestError as exc: if retries < self.max_retries: retries += 1 - await asyncio.sleep(self.retry_delay * (2 ** (retries - 1))) + delay = calculate_backoff(retries, self.retry_delay) + await asyncio.sleep(delay) continue raise exc @@ -352,17 +452,10 @@ async def get_items( raw: bool = False, ) -> GetItemsResult | dict[str, Any]: """Retrieve detailed product information for up to 10 ASINs asynchronously.""" - if isinstance(item_ids, str): - item_ids = [item_ids] - - if not item_ids: - raise AmazonBadRequestError("item_ids list cannot be empty") - - if len(item_ids) > 10: - raise AmazonBadRequestError("A maximum of 10 item_ids can be requested per call") + validated_item_ids = _validate_item_ids(item_ids) payload: dict[str, Any] = { - "ItemIds": item_ids, + "ItemIds": validated_item_ids, "Resources": resources if resources is not None else DEFAULT_ITEM_RESOURCES, } @@ -391,6 +484,15 @@ async def search_items( raw: bool = False, ) -> SearchResult | dict[str, Any]: """Search products asynchronously.""" + _validate_search_params( + item_count=item_count, + item_page=item_page, + min_price=min_price, + max_price=max_price, + min_reviews_rating=min_reviews_rating, + min_saving_percent=min_saving_percent, + ) + payload: dict[str, Any] = { "SearchIndex": search_index, "ItemCount": item_count, @@ -436,11 +538,11 @@ async def get_variations( raw: bool = False, ) -> GetVariationsResult | dict[str, Any]: """Retrieve variation items asynchronously.""" - if not asin: - raise AmazonBadRequestError("asin cannot be empty") + validated_asin = _validate_asin(asin) + _validate_variations_params(variation_count=variation_count, variation_page=variation_page) payload: dict[str, Any] = { - "ASIN": asin, + "ASIN": validated_asin, "VariationCount": variation_count, "VariationPage": variation_page, "Resources": resources if resources is not None else DEFAULT_VARIATION_RESOURCES, @@ -457,18 +559,10 @@ async def get_browse_nodes( raw: bool = False, ) -> BrowseNodesResult | dict[str, Any]: """Retrieve category browse node information asynchronously.""" - if isinstance(browse_node_ids, (str, int)): - browse_node_ids = [browse_node_ids] - - node_ids_str = [str(n) for n in browse_node_ids] - if not node_ids_str: - raise AmazonBadRequestError("browse_node_ids list cannot be empty") - - if len(node_ids_str) > 10: - raise AmazonBadRequestError("A maximum of 10 browse_node_ids can be requested per call") + validated_node_ids = _validate_browse_node_ids(browse_node_ids) payload: dict[str, Any] = { - "BrowseNodeIds": node_ids_str, + "BrowseNodeIds": validated_node_ids, "Resources": resources if resources is not None else DEFAULT_BROWSE_NODE_RESOURCES, } diff --git a/amazon/clients/unified.py b/amazon/clients/unified.py index 0244161..2f9dbf3 100644 --- a/amazon/clients/unified.py +++ b/amazon/clients/unified.py @@ -74,6 +74,10 @@ def __init__( "or PA-API credentials (access_key, secret_key, associate_tag)." ) + def __repr__(self) -> str: + mode = "CreatorsAPI" if self._is_creators else "PAAPI5" + return f"AmazonAPI(mode={mode!r}, backend={self._backend!r})" + def __enter__(self) -> AmazonAPI: return self @@ -246,6 +250,10 @@ def __init__( "or PA-API credentials (access_key, secret_key, associate_tag)." ) + def __repr__(self) -> str: + mode = "CreatorsAPI" if self._is_creators else "PAAPI5" + return f"AsyncAmazonAPI(mode={mode!r}, backend={self._backend!r})" + async def __aenter__(self) -> AsyncAmazonAPI: return self diff --git a/amazon/models/common.py b/amazon/models/common.py index 2f964c8..97cdb6b 100644 --- a/amazon/models/common.py +++ b/amazon/models/common.py @@ -36,8 +36,16 @@ class Price: def from_dict(cls, data: dict[str, Any] | None) -> Price | None: if not data: return None + amt_raw = data.get("Amount") or data.get("amount") + amt: float | None = None + if amt_raw is not None: + try: + amt = float(amt_raw) + except (ValueError, TypeError): + amt = None + return cls( - amount=data.get("Amount") or data.get("amount"), + amount=amt, currency=data.get("Currency") or data.get("currency"), display_amount=data.get("DisplayAmount") or data.get("displayAmount"), raw=data, @@ -57,10 +65,26 @@ class Image: def from_dict(cls, data: dict[str, Any] | None) -> Image | None: if not data: return None + h_raw = data.get("Height") or data.get("height") + h: int | None = None + if h_raw is not None: + try: + h = int(h_raw) + except (ValueError, TypeError): + h = None + + w_raw = data.get("Width") or data.get("width") + w: int | None = None + if w_raw is not None: + try: + w = int(w_raw) + except (ValueError, TypeError): + w = None + return cls( url=data.get("URL") or data.get("url") or "", - height=data.get("Height") or data.get("height"), - width=data.get("Width") or data.get("width"), + height=h, + width=w, raw=data, ) @@ -99,14 +123,30 @@ class PaginationInfo: def from_dict(cls, data: dict[str, Any] | None) -> PaginationInfo | None: if not data: return None + total_raw = ( + data.get("TotalResultCount") + or data.get("totalResultCount") + or data.get("TotalResults") + or data.get("totalResults") + ) + total_count: int | None = None + if total_raw is not None: + try: + total_count = int(total_raw) + except (ValueError, TypeError): + total_count = None + + pages_raw = data.get("TotalPages") or data.get("totalPages") + total_pages: int | None = None + if pages_raw is not None: + try: + total_pages = int(pages_raw) + except (ValueError, TypeError): + total_pages = None + return cls( - total_result_count=( - data.get("TotalResultCount") - or data.get("totalResultCount") - or data.get("TotalResults") - or data.get("totalResults") - ), - total_pages=data.get("TotalPages") or data.get("totalPages"), + total_result_count=total_count, + total_pages=total_pages, search_url=( data.get("SearchURL") or data.get("searchUrl") diff --git a/amazon/models/items.py b/amazon/models/items.py index f3a859c..7e19105 100644 --- a/amazon/models/items.py +++ b/amazon/models/items.py @@ -285,9 +285,25 @@ def from_dict(cls, data: dict[str, Any] | None) -> VariationSummary | None: lowest = Price.from_dict(price_range.get("LowestPrice") or price_range.get("lowestPrice")) highest = Price.from_dict(price_range.get("HighestPrice") or price_range.get("highestPrice")) + page_raw = data.get("PageCount") or data.get("pageCount") + page_count: int | None = None + if page_raw is not None: + try: + page_count = int(page_raw) + except (ValueError, TypeError): + page_count = None + + var_raw = data.get("VariationCount") or data.get("variationCount") + var_count: int | None = None + if var_raw is not None: + try: + var_count = int(var_raw) + except (ValueError, TypeError): + var_count = None + return cls( - page_count=data.get("PageCount") or data.get("pageCount"), - variation_count=data.get("VariationCount") or data.get("variationCount"), + page_count=page_count, + variation_count=var_count, lowest_price=lowest, highest_price=highest, raw=data, diff --git a/tests/test_security_concurrency.py b/tests/test_security_concurrency.py new file mode 100644 index 0000000..3e953a3 --- /dev/null +++ b/tests/test_security_concurrency.py @@ -0,0 +1,64 @@ +"""Security tests: Concurrency and race condition safety in OAuth token manager.""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor + +import pytest +import respx + +from amazon.auth.oauth import OAuthTokenManager +from tests.conftest import MOCK_OAUTH_TOKEN_RESPONSE + + +@respx.mock +def test_oauth_token_manager_thread_safety() -> None: + token_route = respx.post("https://api.amazon.com/auth/o2/token").respond( + status_code=200, + json=MOCK_OAUTH_TOKEN_RESPONSE, + ) + + manager = OAuthTokenManager( + credential_id="test-id", + credential_secret="test-secret", + token_url="https://api.amazon.com/auth/o2/token", + ) + + def fetch_token() -> str: + return manager.get_token() + + with ThreadPoolExecutor(max_workers=8) as executor: + results = list(executor.map(lambda _: fetch_token(), range(20))) + + for r in results: + assert r == "mock-access-token-123456" + + # Only 1 network request should have been made due to caching and synchronization + assert token_route.call_count == 1 + + +@respx.mock +@pytest.mark.asyncio +async def test_oauth_token_manager_coroutine_safety() -> None: + token_route = respx.post("https://api.amazon.com/auth/o2/token").respond( + status_code=200, + json=MOCK_OAUTH_TOKEN_RESPONSE, + ) + + manager = OAuthTokenManager( + credential_id="test-id", + credential_secret="test-secret", + token_url="https://api.amazon.com/auth/o2/token", + ) + + async def fetch_token() -> str: + return await manager.get_token_async() + + tasks = [fetch_token() for _ in range(20)] + results = await asyncio.gather(*tasks) + + for r in results: + assert r == "mock-access-token-123456" + + assert token_route.call_count == 1 diff --git a/tests/test_security_error_bounding.py b/tests/test_security_error_bounding.py new file mode 100644 index 0000000..06bc1ed --- /dev/null +++ b/tests/test_security_error_bounding.py @@ -0,0 +1,20 @@ +"""Security tests: Bounded error memory footprint and response truncation.""" + +from __future__ import annotations + +import httpx + +from amazon.clients.base import MAX_ERROR_BODY_LENGTH, map_http_error +from amazon.exceptions import AmazonServerError + + +def test_map_http_error_truncates_huge_payload() -> None: + # 50,000 characters payload (e.g. huge HTML crash dump) + huge_text = "" + ("A" * 50000) + "" + resp = httpx.Response(500, text=huge_text) + + err = map_http_error(resp) + assert isinstance(err, AmazonServerError) + assert len(err.response_body) <= MAX_ERROR_BODY_LENGTH + 50 + assert "... [truncated]" in err.response_body + assert "... [truncated]" in err.message diff --git a/tests/test_security_repr.py b/tests/test_security_repr.py new file mode 100644 index 0000000..53b6487 --- /dev/null +++ b/tests/test_security_repr.py @@ -0,0 +1,108 @@ +"""Security tests: Credential protection and string representation masking.""" + +from __future__ import annotations + +import time + +from amazon.auth.oauth import OAuthToken, OAuthTokenManager +from amazon.auth.sigv4 import SigV4Signer +from amazon.clients.creators import AmazonCreatorsAPI, AsyncAmazonCreatorsAPI +from amazon.clients.paapi5 import AmazonPAAPI5, AsyncAmazonPAAPI5 +from amazon.clients.unified import AmazonAPI, AsyncAmazonAPI + + +def test_oauth_token_repr_masks_access_token() -> None: + secret_token = "at-secret-oauth-bearer-token-987654321" + token = OAuthToken( + access_token=secret_token, + token_type="bearer", + expires_at=time.time() + 3600, + ) + repr_str = repr(token) + assert secret_token not in repr_str + assert "at-s...4321" in repr_str + + short_token = OAuthToken( + access_token="short", + token_type="bearer", + expires_at=time.time() + 3600, + ) + assert repr(short_token) == "OAuthToken(access_token='***', token_type='bearer', expires_at=" + str(short_token.expires_at) + ", scope=None)" + + +def test_oauth_token_manager_repr_masks_client_secret() -> None: + manager = OAuthTokenManager( + credential_id="my-client-id", + credential_secret="super-sensitive-secret-key-123", + token_url="https://api.amazon.com/auth/o2/token", + ) + repr_str = repr(manager) + assert "super-sensitive-secret-key-123" not in repr_str + assert "credential_secret='***'" in repr_str + assert "my-client-id" in repr_str + + +def test_sigv4_signer_repr_masks_secret_key() -> None: + signer = SigV4Signer( + access_key="AKIA123456789EXAMPLE", + secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + aws_region="us-east-1", + ) + repr_str = repr(signer) + assert "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" not in repr_str + assert "secret_key='***'" in repr_str + assert "AKIA123456789EXAMPLE" in repr_str + + +def test_creators_clients_repr_masks_credentials() -> None: + sync_client = AmazonCreatorsAPI( + credential_id="cred-123", + credential_secret="secret-abc", + ) + assert "secret-abc" not in repr(sync_client) + assert "credential_secret='***'" in repr(sync_client) + sync_client.close() + + async_client = AsyncAmazonCreatorsAPI( + credential_id="cred-123", + credential_secret="secret-abc", + ) + assert "secret-abc" not in repr(async_client) + assert "credential_secret='***'" in repr(async_client) + + +def test_paapi5_clients_repr_masks_credentials() -> None: + sync_client = AmazonPAAPI5( + access_key="AKIAEXAMPLE", + secret_key="secret-aws-key", + associate_tag="tag-20", + ) + assert "secret-aws-key" not in repr(sync_client) + assert "secret_key='***'" in repr(sync_client) + sync_client.close() + + async_client = AsyncAmazonPAAPI5( + access_key="AKIAEXAMPLE", + secret_key="secret-aws-key", + associate_tag="tag-20", + ) + assert "secret-aws-key" not in repr(async_client) + assert "secret_key='***'" in repr(async_client) + + +def test_unified_clients_repr_masks_credentials() -> None: + api = AmazonAPI( + credential_id="cred-123", + credential_secret="secret-abc", + ) + assert "secret-abc" not in repr(api) + assert "credential_secret='***'" in repr(api) + api.close() + + async_api = AsyncAmazonAPI( + access_key="AKIAEXAMPLE", + secret_key="secret-aws-key", + associate_tag="tag-20", + ) + assert "secret-aws-key" not in repr(async_api) + assert "secret_key='***'" in repr(async_api) diff --git a/tests/test_security_retry.py b/tests/test_security_retry.py new file mode 100644 index 0000000..c7cf3a4 --- /dev/null +++ b/tests/test_security_retry.py @@ -0,0 +1,73 @@ +"""Security tests: Retry backoff with jitter and Retry-After header parsing.""" + +from __future__ import annotations + +import datetime + +import httpx +import pytest +import respx + +from amazon.clients.base import calculate_backoff, parse_retry_after +from amazon.clients.creators import AmazonCreatorsAPI +from amazon.clients.paapi5 import AmazonPAAPI5 +from amazon.exceptions import AmazonThrottlingError +from tests.conftest import MOCK_GET_ITEMS_CREATORS_RESPONSE + + +def test_parse_retry_after() -> None: + # Integer seconds + resp_int = httpx.Response(429, headers={"Retry-After": "5"}) + assert parse_retry_after(resp_int, default=1.0) == 5.0 + + # Missing header returns default + resp_none = httpx.Response(429) + assert parse_retry_after(resp_none, default=2.5) == 2.5 + + # HTTP-date format in future + future_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10) + http_date = future_time.strftime("%a, %d %b %Y %H:%M:%S GMT") + resp_date = httpx.Response(429, headers={"Retry-After": http_date}) + delay = parse_retry_after(resp_date, default=1.0) + assert 8.0 <= delay <= 12.0 + + # Malformed header returns default + resp_invalid = httpx.Response(429, headers={"Retry-After": "not-a-valid-date-or-number"}) + assert parse_retry_after(resp_invalid, default=3.0) == 3.0 + + +def test_calculate_backoff_jitter() -> None: + for attempt in range(1, 6): + delay = calculate_backoff(retry_count=attempt, base_delay=1.0, max_delay=30.0) + ceiling = min(30.0, 1.0 * (2 ** (attempt - 1))) + assert 0.0 <= delay <= ceiling + + +@respx.mock +def test_creators_retry_on_503_and_recovery(creators_credentials: dict[str, str]) -> None: + route = respx.post("https://creatorsapi.amazon/catalog/v1/getItems").mock( + side_effect=[ + httpx.Response(503, text="Service Unavailable", headers={"Retry-After": "0"}), + httpx.Response(200, json=MOCK_GET_ITEMS_CREATORS_RESPONSE), + ] + ) + + client = AmazonCreatorsAPI(**creators_credentials, max_retries=2, retry_delay=0.01) + res = client.get_items(item_ids="B0041OSCBU") + assert res.item.asin == "B0041OSCBU" + assert route.call_count == 2 + client.close() + + +@respx.mock +def test_paapi5_retry_on_429_exhaustion(paapi5_credentials: dict[str, str]) -> None: + respx.post("https://webservices.amazon.com/paapi5/getitems").respond( + status_code=429, + text="Rate limit exceeded", + headers={"Retry-After": "0"}, + ) + + client = AmazonPAAPI5(**paapi5_credentials, max_retries=2, retry_delay=0.01) + with pytest.raises(AmazonThrottlingError): + client.get_items(item_ids="B0041OSCBU") + client.close() diff --git a/tests/test_security_validation.py b/tests/test_security_validation.py new file mode 100644 index 0000000..8bed520 --- /dev/null +++ b/tests/test_security_validation.py @@ -0,0 +1,120 @@ +"""Security tests: Input validation, parameter bounds checking, and operation allowlisting.""" + +from __future__ import annotations + +import pytest + +from amazon.clients.creators import AmazonCreatorsAPI +from amazon.clients.paapi5 import AmazonPAAPI5 +from amazon.exceptions import AmazonBadRequestError + + +def test_creators_input_validation(creators_credentials: dict[str, str]) -> None: + client = AmazonCreatorsAPI(**creators_credentials) + + # Empty / whitespace / invalid item_ids + with pytest.raises(AmazonBadRequestError, match="item_ids list cannot be empty"): + client.get_items(item_ids=[]) + + with pytest.raises(AmazonBadRequestError, match="item_ids list cannot be empty"): + client.get_items(item_ids=[" ", ""]) + + with pytest.raises(AmazonBadRequestError, match="maximum of 10 item_ids"): + client.get_items(item_ids=[f"ASIN{i}" for i in range(11)]) + + # Empty asin in get_variations + with pytest.raises(AmazonBadRequestError, match="asin cannot be empty"): + client.get_variations(asin="") + + with pytest.raises(AmazonBadRequestError, match="asin cannot be empty"): + client.get_variations(asin=" ") + + # Invalid variations count / page + with pytest.raises(AmazonBadRequestError, match="variation_count must be between 1 and 10"): + client.get_variations(asin="B0041OSCBU", variation_count=0) + + with pytest.raises(AmazonBadRequestError, match="variation_page must be between 1 and 10"): + client.get_variations(asin="B0041OSCBU", variation_page=11) + + # Search params validation + with pytest.raises(AmazonBadRequestError, match="item_count must be between 1 and 10"): + client.search_items(keywords="test", item_count=0) + + with pytest.raises(AmazonBadRequestError, match="item_page must be between 1 and 10"): + client.search_items(keywords="test", item_page=15) + + with pytest.raises(AmazonBadRequestError, match="min_price cannot be negative"): + client.search_items(keywords="test", min_price=-10) + + with pytest.raises(AmazonBadRequestError, match="max_price cannot be negative"): + client.search_items(keywords="test", max_price=-5) + + with pytest.raises(AmazonBadRequestError, match="cannot be greater than max_price"): + client.search_items(keywords="test", min_price=5000, max_price=1000) + + with pytest.raises(AmazonBadRequestError, match="min_reviews_rating must be between 1 and 5"): + client.search_items(keywords="test", min_reviews_rating=6) + + with pytest.raises(AmazonBadRequestError, match="min_saving_percent must be between 1 and 100"): + client.search_items(keywords="test", min_saving_percent=150) + + # Browse node IDs validation + with pytest.raises(AmazonBadRequestError, match="browse_node_ids list cannot be empty"): + client.get_browse_nodes(browse_node_ids=[]) + + with pytest.raises(AmazonBadRequestError, match="maximum of 10 browse_node_ids"): + client.get_browse_nodes(browse_node_ids=list(range(11))) + + # Endpoint injection protection + with pytest.raises(AmazonBadRequestError, match="Invalid Creators API endpoint"): + client._execute_request(endpoint="../../admin", payload={}) + + client.close() + + +def test_paapi5_input_validation(paapi5_credentials: dict[str, str]) -> None: + client = AmazonPAAPI5(**paapi5_credentials) + + # Empty / whitespace / invalid item_ids + with pytest.raises(AmazonBadRequestError, match="item_ids list cannot be empty"): + client.get_items(item_ids=[]) + + with pytest.raises(AmazonBadRequestError, match="item_ids list cannot be empty"): + client.get_items(item_ids=["", " "]) + + with pytest.raises(AmazonBadRequestError, match="maximum of 10 item_ids"): + client.get_items(item_ids=[f"ASIN{i}" for i in range(11)]) + + # Empty asin + with pytest.raises(AmazonBadRequestError, match="asin cannot be empty"): + client.get_variations(asin=" ") + + # Invalid variations count / page + with pytest.raises(AmazonBadRequestError, match="variation_count must be between 1 and 10"): + client.get_variations(asin="B0041OSCBU", variation_count=15) + + with pytest.raises(AmazonBadRequestError, match="variation_page must be between 1 and 10"): + client.get_variations(asin="B0041OSCBU", variation_page=0) + + # Search params validation + with pytest.raises(AmazonBadRequestError, match="item_count must be between 1 and 10"): + client.search_items(keywords="test", item_count=20) + + with pytest.raises(AmazonBadRequestError, match="min_price cannot be negative"): + client.search_items(keywords="test", min_price=-100) + + with pytest.raises(AmazonBadRequestError, match="cannot be greater than max_price"): + client.search_items(keywords="test", min_price=2000, max_price=500) + + # Browse node IDs validation + with pytest.raises(AmazonBadRequestError, match="browse_node_ids list cannot be empty"): + client.get_browse_nodes(browse_node_ids=[]) + + with pytest.raises(AmazonBadRequestError, match="maximum of 10 browse_node_ids"): + client.get_browse_nodes(browse_node_ids=[str(i) for i in range(12)]) + + # Operation injection protection + with pytest.raises(AmazonBadRequestError, match="Invalid PA-API 5.0 operation"): + client._execute_request(operation="ArbitraryOperation\r\nInjected-Header: 1", payload={}) + + client.close()