From 9af53e37edc4596c9e40e6d069770c7970be2367 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Mon, 20 Jul 2026 21:17:55 +0200 Subject: [PATCH 1/2] fix: Honor explicit timeout timedelta larger than timeout_max --- src/apify_client/http_clients/_base.py | 7 +++++-- tests/unit/test_client_timeouts.py | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index 7645228f..e06fb5d1 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -198,7 +198,8 @@ def _compute_timeout(self, timeout: Timeout, *, attempt: int) -> int | float | N """Resolve a timeout tier and compute the timeout for a request attempt with exponential increase. For `no_timeout`, returns `None` to indicate no timeout. For tier literals and explicit `timedelta` values, - doubles the timeout with each attempt but caps at `timeout_max`. + doubles the timeout with each attempt, capping exponential growth at `timeout_max` but never below the + resolved base timeout (so an explicit `timedelta` larger than `timeout_max` is honored). Args: timeout: The timeout specification to resolve (tier literal or explicit `timedelta`). @@ -219,7 +220,9 @@ def _compute_timeout(self, timeout: Timeout, *, attempt: int) -> int | float | N else: resolved = timeout - new_timeout = min(resolved * (2 ** (attempt - 1)), self._timeout_max) + # `timeout_max` caps exponential growth across retries, but must never shrink the resolved base + # timeout itself - an explicit `timedelta` larger than `timeout_max` overrides it for the call. + new_timeout = min(resolved * (2 ** (attempt - 1)), max(self._timeout_max, resolved)) return to_seconds(new_timeout) def _prepare_request_call( diff --git a/tests/unit/test_client_timeouts.py b/tests/unit/test_client_timeouts.py index 2e87279e..98477c18 100644 --- a/tests/unit/test_client_timeouts.py +++ b/tests/unit/test_client_timeouts.py @@ -149,6 +149,15 @@ def test_compute_timeout_no_timeout_returns_none() -> None: assert client._compute_timeout('no_timeout', attempt=1) is None +def test_compute_timeout_explicit_timedelta_above_max_not_clamped() -> None: + """Test an explicit timedelta larger than timeout_max is honored, not clamped.""" + client = ImpitHttpClient(timeout_max=timedelta(seconds=360)) + + assert client._compute_timeout(timedelta(minutes=30), attempt=1) == 1800.0 + # Exponential growth stays bounded by the explicit timedelta itself. + assert client._compute_timeout(timedelta(minutes=30), attempt=2) == 1800.0 + + async def test_dynamic_timeout_async_client(monkeypatch: pytest.MonkeyPatch) -> None: """Tests timeout values for request with retriable errors. From 2ecbeeaf045e2d08773b7cfe4f521aae34b5c8c2 Mon Sep 17 00:00:00 2001 From: Vlada Dusek Date: Tue, 21 Jul 2026 08:31:06 +0200 Subject: [PATCH 2/2] docs: Clarify timeout_max honors a base timeout larger than the cap --- docs/02_concepts/11_timeouts.mdx | 2 +- src/apify_client/_apify_client.py | 4 ++-- src/apify_client/http_clients/_base.py | 2 +- src/apify_client/http_clients/_impit.py | 4 ++-- tests/unit/test_client_timeouts.py | 9 +++++++++ 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/02_concepts/11_timeouts.mdx b/docs/02_concepts/11_timeouts.mdx index 55b937de..8b1c3a32 100644 --- a/docs/02_concepts/11_timeouts.mdx +++ b/docs/02_concepts/11_timeouts.mdx @@ -25,7 +25,7 @@ Every client method has a pre-assigned tier that matches the expected duration o ## Configuring default timeouts -You can override the default values for each tier in the `ApifyClient` or `ApifyClientAsync` constructor. The `timeout_max` parameter sets an upper cap on the timeout for any individual API request, limiting exponential growth during retries. +You can override the default values for each tier in the `ApifyClient` or `ApifyClientAsync` constructor. The `timeout_max` parameter caps the exponential timeout growth during retries. A base timeout that's already larger than `timeout_max`, whether an explicit `timedelta` or a tier configured above the cap, is honored as-is. diff --git a/src/apify_client/_apify_client.py b/src/apify_client/_apify_client.py index 391906c0..f15572dd 100644 --- a/src/apify_client/_apify_client.py +++ b/src/apify_client/_apify_client.py @@ -146,7 +146,7 @@ def __init__( timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...). timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...). timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). - timeout_max: Maximum timeout cap for exponential timeout growth across retries. + timeout_max: Caps exponential timeout growth across retries. A larger base timeout is honored, not clamped. headers: Additional HTTP headers to include in all API requests. compression: Compression algorithm for request bodies. Pass a string literal to select an algorithm, or an `HttpCompressor` instance for finer-grained control. @@ -508,7 +508,7 @@ def __init__( timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...). timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...). timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). - timeout_max: Maximum timeout cap for exponential timeout growth across retries. + timeout_max: Caps exponential timeout growth across retries. A larger base timeout is honored, not clamped. headers: Additional HTTP headers to include in all API requests. compression: Compression algorithm for request bodies. Pass a string literal to select an algorithm, or an `HttpCompressor` instance for finer-grained control. diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index e06fb5d1..e669cdef 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -110,7 +110,7 @@ def __init__( timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...). timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...). timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). - timeout_max: Maximum timeout cap for exponential timeout growth across retries. + timeout_max: Caps exponential timeout growth across retries. A larger base timeout is honored, not clamped. max_retries: Maximum number of retries for failed requests. min_delay_between_retries: Minimum delay between retries. statistics: Statistics tracker for API calls. Created automatically if not provided. diff --git a/src/apify_client/http_clients/_impit.py b/src/apify_client/http_clients/_impit.py index c3ef7212..b01a3c58 100644 --- a/src/apify_client/http_clients/_impit.py +++ b/src/apify_client/http_clients/_impit.py @@ -83,7 +83,7 @@ def __init__( timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...). timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...). timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). - timeout_max: Maximum timeout cap for exponential timeout growth across retries. + timeout_max: Caps exponential timeout growth across retries. A larger base timeout is honored, not clamped. max_retries: Maximum number of retry attempts for failed requests. min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. @@ -332,7 +332,7 @@ def __init__( timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...). timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...). timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...). - timeout_max: Maximum timeout cap for exponential timeout growth across retries. + timeout_max: Caps exponential timeout growth across retries. A larger base timeout is honored, not clamped. max_retries: Maximum number of retry attempts for failed requests. min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt). statistics: Statistics tracker for API calls. Created automatically if not provided. diff --git a/tests/unit/test_client_timeouts.py b/tests/unit/test_client_timeouts.py index 98477c18..47d29592 100644 --- a/tests/unit/test_client_timeouts.py +++ b/tests/unit/test_client_timeouts.py @@ -158,6 +158,15 @@ def test_compute_timeout_explicit_timedelta_above_max_not_clamped() -> None: assert client._compute_timeout(timedelta(minutes=30), attempt=2) == 1800.0 +def test_compute_timeout_tier_above_max_not_clamped() -> None: + """Test a configured tier larger than timeout_max is honored, not clamped.""" + client = ImpitHttpClient(timeout_long=timedelta(seconds=600), timeout_max=timedelta(seconds=360)) + + assert client._compute_timeout('long', attempt=1) == 600.0 + # Exponential growth stays bounded by the tier's base value itself. + assert client._compute_timeout('long', attempt=2) == 600.0 + + async def test_dynamic_timeout_async_client(monkeypatch: pytest.MonkeyPatch) -> None: """Tests timeout values for request with retriable errors.