diff --git a/CHANGELOG.md b/CHANGELOG.md index 24c7005..769354f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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). +## [6.4.0] - 2026-08-06 + +### Added + +- `timeout` parameter in `AmazonCreatorsApi` and `AsyncAmazonCreatorsApi` to set the request timeout in seconds, or `None` to wait indefinitely + +### Changed + +- `AmazonCreatorsApi` requests now time out after 30 seconds instead of waiting indefinitely, matching the timeout already used by `AsyncAmazonCreatorsApi`. Pass `timeout=None` to restore the previous behavior + ## [6.3.0] - 2026-05-15 ### Added diff --git a/README.md b/README.md index 6887537..7b10c81 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,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 ``` +### 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. + +```python +amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=10) # Fails after 10 seconds +amazon = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=0.5) # Fails after half a second +``` + ### Async Support For async/await applications, use the async version of the API with `httpx`: diff --git a/amazon_creatorsapi/aio/api.py b/amazon_creatorsapi/aio/api.py index 19e15bf..ae29fc1 100644 --- a/amazon_creatorsapi/aio/api.py +++ b/amazon_creatorsapi/aio/api.py @@ -12,7 +12,7 @@ from typing_extensions import Self -from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING +from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING, DEFAULT_TIMEOUT from amazon_creatorsapi.core.error_handling import handle_api_error from amazon_creatorsapi.core.parsers import get_asin, get_items_ids from amazon_creatorsapi.core.resources import get_all_resources @@ -104,6 +104,8 @@ class AsyncAmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. 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. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -121,6 +123,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + timeout: float | None = DEFAULT_TIMEOUT, ) -> None: """Initialize the async Amazon Creators API client.""" # Validate version early to fail fast (before token manager initialization) @@ -133,6 +136,7 @@ def __init__( self._throttle_lock: asyncio.Lock | None = None self.tag = tag self.throttling = float(throttling) + self.timeout = timeout # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) @@ -163,7 +167,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) + self._http_client = AsyncHttpClient(host=API_HOST, timeout=self.timeout) await self._http_client.__aenter__() self._owns_client = True return self @@ -498,7 +502,7 @@ async def _make_request( if self._http_client is not None: response = await self._http_client.post(endpoint, headers, body) else: - async with AsyncHttpClient(host=API_HOST) as client: + async with AsyncHttpClient(host=API_HOST, timeout=self.timeout) as client: response = await client.post(endpoint, headers, body) # Handle errors diff --git a/amazon_creatorsapi/aio/client.py b/amazon_creatorsapi/aio/client.py index 8ec143b..20d486c 100644 --- a/amazon_creatorsapi/aio/client.py +++ b/amazon_creatorsapi/aio/client.py @@ -12,6 +12,8 @@ from typing_extensions import Self +from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT + if TYPE_CHECKING: from types import TracebackType @@ -26,7 +28,6 @@ DEFAULT_HOST = "https://creatorsapi.amazon" -DEFAULT_TIMEOUT = 30.0 VERSION = version("python-amazon-paapi") USER_AGENT = f"python-amazon-paapi/{VERSION} (async)" @@ -64,14 +65,15 @@ class AsyncHttpClient: Args: host: Base URL for API requests. Defaults to Amazon Creators API. - timeout: Request timeout in seconds. Defaults to 30. + timeout: Request timeout in seconds, or None to wait indefinitely. + Defaults to 30. """ def __init__( self, host: str = DEFAULT_HOST, - timeout: float = DEFAULT_TIMEOUT, + timeout: float | None = DEFAULT_TIMEOUT, ) -> None: """Initialize the async HTTP client.""" self._host = host diff --git a/amazon_creatorsapi/api.py b/amazon_creatorsapi/api.py index 70dc472..5f0101f 100644 --- a/amazon_creatorsapi/api.py +++ b/amazon_creatorsapi/api.py @@ -8,7 +8,7 @@ import time from typing import TYPE_CHECKING, NoReturn -from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING +from amazon_creatorsapi.core.constants import DEFAULT_THROTTLING, DEFAULT_TIMEOUT from amazon_creatorsapi.core.error_handling import handle_api_error from amazon_creatorsapi.core.parsers import get_asin, get_items_ids from amazon_creatorsapi.core.resources import get_all_resources @@ -58,6 +58,8 @@ class AmazonCreatorsApi: country: Country code (e.g., "ES", "US"). Used to determine marketplace. marketplace: Marketplace URL (e.g., "www.amazon.es"). Overrides country. 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. Raises: InvalidArgumentError: If neither country nor marketplace is provided. @@ -83,6 +85,7 @@ def __init__( country: CountryCode | None = None, marketplace: str | None = None, throttling: float = DEFAULT_THROTTLING, + timeout: float | None = DEFAULT_TIMEOUT, ) -> None: """Initialize the Amazon Creators API client.""" self._credential_id = credential_id @@ -91,6 +94,7 @@ def __init__( self._last_query_time = time.time() - throttling self.tag = tag self.throttling = float(throttling) + self.timeout = timeout # Determine marketplace from country or direct value self.marketplace = validate_and_get_marketplace(country, marketplace) @@ -148,6 +152,7 @@ def get_items( response = self._api.get_items( x_marketplace=self.marketplace, get_items_request_content=request, + _request_timeout=self.timeout, ) except ApiException as exc: self._handle_api_exception(exc) @@ -248,6 +253,7 @@ def search_items( 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) @@ -308,6 +314,7 @@ def get_variations( 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) @@ -354,6 +361,7 @@ def get_browse_nodes( 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) diff --git a/amazon_creatorsapi/core/constants.py b/amazon_creatorsapi/core/constants.py index 3c44745..f502ae7 100644 --- a/amazon_creatorsapi/core/constants.py +++ b/amazon_creatorsapi/core/constants.py @@ -1,6 +1,7 @@ """Constants for the Amazon Creators API.""" DEFAULT_THROTTLING = 1 +DEFAULT_TIMEOUT = 30.0 # HTTP status codes HTTP_NOT_FOUND = 404 diff --git a/docs/conf.py b/docs/conf.py index 91bf73b..db06254 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 = "6.3.0" +release = "6.4.0" # -- General configuration --------------------------------------------------- diff --git a/docs/pages/usage-guide.md b/docs/pages/usage-guide.md index 0a2ba08..a949eed 100644 --- a/docs/pages/usage-guide.md +++ b/docs/pages/usage-guide.md @@ -100,6 +100,15 @@ api = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=4) # Make api = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, throttling=0) # No wait time between requests ``` +## 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. + +```python +api = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=10) # Fails after 10 seconds +api = AmazonCreatorsApi(ID, SECRET, VERSION, TAG, COUNTRY, timeout=0.5) # Fails after half a second +``` + ## Async Support For async/await applications, install with async support: diff --git a/pyproject.toml b/pyproject.toml index 4b8f0e0..88d747c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "python-amazon-paapi" -version = "6.3.0" +version = "6.4.0" description = "Amazon Product Advertising API 5.0 wrapper for Python" readme = "README.md" requires-python = ">=3.9" diff --git a/tests/amazon_creatorsapi/aio/api_test.py b/tests/amazon_creatorsapi/aio/api_test.py index 0427d55..3332c9b 100644 --- a/tests/amazon_creatorsapi/aio/api_test.py +++ b/tests/amazon_creatorsapi/aio/api_test.py @@ -6,6 +6,8 @@ from amazon_creatorsapi.aio import ( AsyncAmazonCreatorsApi, ) +from amazon_creatorsapi.aio.api import API_HOST +from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT from amazon_creatorsapi.errors import ( AssociateValidationError, InvalidArgumentError, @@ -69,6 +71,33 @@ def test_with_custom_throttling(self, mock_token_manager: MagicMock) -> None: self.assertEqual(api.throttling, 2.5) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_with_default_timeout(self, mock_token_manager: MagicMock) -> None: + """Test initialization uses the default timeout value.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="US", + ) + + self.assertEqual(api.timeout, DEFAULT_TIMEOUT) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + def test_with_custom_timeout(self, mock_token_manager: MagicMock) -> None: + """Test initialization with custom timeout value.""" + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="US", + timeout=5.0, + ) + + self.assertEqual(api.timeout, 5.0) + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") def test_accepts_lwa_version(self, mock_token_manager: MagicMock) -> None: """Test initialization accepts an LWA-backed 3.x version.""" @@ -1340,5 +1369,114 @@ async def test_request_uses_lwa_authorization_header( self.assertEqual(headers["Authorization"], "Bearer test_token") +class TestAsyncAmazonCreatorsApiTimeout(unittest.IsolatedAsyncioTestCase): + """Tests for the timeout given to the underlying HTTP client.""" + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_context_manager_client_uses_default_timeout( + self, + mock_http_client_class: MagicMock, + mock_token_manager: MagicMock, + ) -> None: + """Test the persistent client is created with the default timeout.""" + mock_http_client_class.return_value = AsyncMock() + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + ): + pass + + mock_http_client_class.assert_called_once_with( + host=API_HOST, + timeout=DEFAULT_TIMEOUT, + ) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_context_manager_client_uses_custom_timeout( + self, + mock_http_client_class: MagicMock, + mock_token_manager: MagicMock, + ) -> None: + """Test the persistent client is created with a custom timeout.""" + mock_http_client_class.return_value = AsyncMock() + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + timeout=5.0, + ): + pass + + mock_http_client_class.assert_called_once_with(host=API_HOST, timeout=5.0) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_request_without_context_manager_uses_custom_timeout( + self, + mock_http_client_class: MagicMock, + mock_token_manager_class: MagicMock, + ) -> None: + """Test the temporary client is created with a custom timeout.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "itemsResult": {"items": [{"ASIN": "B0DLFMFBJW"}]} + } + + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_client.__aenter__.return_value = mock_client + mock_http_client_class.return_value = mock_client + + mock_token_manager = AsyncMock() + mock_token_manager.get_token.return_value = "test_token" + mock_token_manager_class.return_value = mock_token_manager + + api = AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + throttling=0, + timeout=5.0, + ) + + await api.get_items(["B0DLFMFBJW"]) + + mock_http_client_class.assert_called_once_with(host=API_HOST, timeout=5.0) + + @patch("amazon_creatorsapi.aio.api.AsyncOAuth2TokenManager") + @patch("amazon_creatorsapi.aio.api.AsyncHttpClient") + async def test_client_receives_disabled_timeout( + self, + mock_http_client_class: MagicMock, + mock_token_manager: MagicMock, + ) -> None: + """Test a None timeout is passed to the HTTP client to disable it.""" + mock_http_client_class.return_value = AsyncMock() + + async with AsyncAmazonCreatorsApi( + credential_id="test_id", + credential_secret="test_secret", + version="2.2", + tag="test-tag", + country="ES", + timeout=None, + ): + pass + + mock_http_client_class.assert_called_once_with(host=API_HOST, timeout=None) + + if __name__ == "__main__": unittest.main() diff --git a/tests/amazon_creatorsapi/aio/client_test.py b/tests/amazon_creatorsapi/aio/client_test.py index 3c8a605..e71ebd3 100644 --- a/tests/amazon_creatorsapi/aio/client_test.py +++ b/tests/amazon_creatorsapi/aio/client_test.py @@ -32,6 +32,11 @@ async def test_init_custom(self) -> None: self.assertEqual(client._host, host) self.assertEqual(client._timeout, timeout) + async def test_init_timeout_disabled(self) -> None: + """Test the timeout can be disabled with None.""" + client = AsyncHttpClient(timeout=None) + self.assertIsNone(client._timeout) + @patch("amazon_creatorsapi.aio.client.httpx.AsyncClient") async def test_context_manager(self, mock_client_cls: MagicMock) -> None: """Test context manager creates and closes client.""" diff --git a/tests/amazon_creatorsapi/api_test.py b/tests/amazon_creatorsapi/api_test.py index c471f51..bd5b55e 100644 --- a/tests/amazon_creatorsapi/api_test.py +++ b/tests/amazon_creatorsapi/api_test.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock from amazon_creatorsapi import AmazonCreatorsApi +from amazon_creatorsapi.core.constants import DEFAULT_TIMEOUT from amazon_creatorsapi.errors import ( AssociateValidationError, InvalidArgumentError, @@ -829,3 +830,180 @@ def test_get_browse_nodes_with_explicit_resources( resources=[GetBrowseNodesResource.BROWSE_NODES_DOT_ANCESTOR], ) self.assertIsInstance(result, list) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_uses_default_timeout( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_items forwards the default timeout to the SDK.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_response = MagicMock() + mock_response.items_result.items = [MagicMock()] + mock_api.get_items.return_value = mock_response + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + ) + api.get_items(["B0DLFMFBJW"]) + + self.assertEqual(api.timeout, DEFAULT_TIMEOUT) + self.assertEqual( + mock_api.get_items.call_args.kwargs["_request_timeout"], + DEFAULT_TIMEOUT, + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_forwards_custom_timeout( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_items forwards a custom timeout to the SDK.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_response = MagicMock() + mock_response.items_result.items = [MagicMock()] + mock_api.get_items.return_value = mock_response + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + timeout=15.0, + ) + api.get_items(["B0DLFMFBJW"]) + + self.assertEqual( + mock_api.get_items.call_args.kwargs["_request_timeout"], + 15.0, + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_items_with_timeout_disabled( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_items sends no timeout to the SDK when it is disabled.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_response = MagicMock() + mock_response.items_result.items = [MagicMock()] + mock_api.get_items.return_value = mock_response + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + timeout=None, + ) + api.get_items(["B0DLFMFBJW"]) + + self.assertIsNone(mock_api.get_items.call_args.kwargs["_request_timeout"]) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_search_items_forwards_custom_timeout( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test search_items forwards a custom timeout to the SDK.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_response = MagicMock() + mock_response.search_result = MagicMock() + mock_api.search_items.return_value = mock_response + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + timeout=5.0, + ) + api.search_items(keywords="laptop") + + self.assertEqual( + mock_api.search_items.call_args.kwargs["_request_timeout"], + 5.0, + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_variations_forwards_custom_timeout( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_variations forwards a custom timeout to the SDK.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_response = MagicMock() + mock_response.variations_result = MagicMock() + mock_api.get_variations.return_value = mock_response + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + timeout=3.5, + ) + api.get_variations("B0DLFMFBJW") + + self.assertEqual( + mock_api.get_variations.call_args.kwargs["_request_timeout"], + 3.5, + ) + + @mock.patch("amazon_creatorsapi.api.DefaultApi") + @mock.patch("amazon_creatorsapi.api.ApiClient") + def test_get_browse_nodes_forwards_custom_timeout( + self, + _mock_client_class: MagicMock, + mock_api_class: MagicMock, + ) -> None: + """Test get_browse_nodes forwards a custom timeout to the SDK.""" + mock_api = MagicMock() + mock_api_class.return_value = mock_api + mock_response = MagicMock() + mock_response.browse_nodes_result.browse_nodes = [MagicMock()] + mock_api.get_browse_nodes.return_value = mock_response + + api = AmazonCreatorsApi( + credential_id=self.credential_id, + credential_secret=self.credential_secret, + version=self.version, + tag=self.tag, + country=self.country, + throttling=0, + timeout=7.5, + ) + api.get_browse_nodes(["123456"]) + + self.assertEqual( + mock_api.get_browse_nodes.call_args.kwargs["_request_timeout"], + 7.5, + )