Skip to content

Commit f1c77b1

Browse files
vdusekclaude
andcommitted
refactor: Standardize timeout handling with Timeout type alias
- Add `Timeout` type alias (`timedelta | Literal['no_timeout'] | None`) to `_consts.py` and export it from the public API - Accept `None` on abstract `HttpClient.call()` / `HttpClientAsync.call()` so the HTTP client resolves its own default timeout internally - Move `_calculate_timeout` into the impit HTTP client implementation since exponential timeout growth on retries is client-specific logic - Add `timeout` parameter to all public resource client methods for user-controllable per-request HTTP timeouts - Add timeout parameter to base methods `_list()`, `_create()`, and `_get_or_create()` which previously lacked it - Rename domain-specific Actor/Task run timeout from `timeout` to `run_timeout` to avoid ambiguity with the HTTP request timeout - Update docstrings to document three-state timeout semantics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 841d760 commit f1c77b1

37 files changed

+1525
-405
lines changed

src/apify_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from importlib import metadata
22

33
from ._apify_client import ApifyClient, ApifyClientAsync
4+
from ._consts import Timeout
45
from ._http_clients import (
56
HttpClient,
67
HttpClientAsync,
@@ -19,5 +20,6 @@
1920
'HttpResponse',
2021
'ImpitHttpClient',
2122
'ImpitHttpClientAsync',
23+
'Timeout',
2224
'__version__',
2325
]

src/apify_client/_apify_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
DEFAULT_API_URL,
1010
DEFAULT_MAX_RETRIES,
1111
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
12-
DEFAULT_TIMEOUT,
12+
DEFAULT_REQUEST_TIMEOUT,
1313
)
1414
from apify_client._docs import docs_group
1515
from apify_client._http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync
@@ -114,7 +114,7 @@ def __init__(
114114
api_public_url: str | None = DEFAULT_API_URL,
115115
max_retries: int = DEFAULT_MAX_RETRIES,
116116
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
117-
timeout: timedelta = DEFAULT_TIMEOUT,
117+
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
118118
headers: dict[str, str] | None = None,
119119
) -> None:
120120
"""Initialize the Apify API client.
@@ -455,7 +455,7 @@ def __init__(
455455
api_public_url: str | None = DEFAULT_API_URL,
456456
max_retries: int = DEFAULT_MAX_RETRIES,
457457
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
458-
timeout: timedelta = DEFAULT_TIMEOUT,
458+
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
459459
headers: dict[str, str] | None = None,
460460
) -> None:
461461
"""Initialize the Apify API client.

src/apify_client/_consts.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from __future__ import annotations
22

33
from datetime import timedelta
4-
from typing import Any
4+
from typing import Any, Literal
55

66
from apify_client._models import ActorJobStatus
77

8+
Timeout = timedelta | Literal['no_timeout'] | None
9+
"""Type for the `timeout` parameter on resource client methods.
10+
11+
`None` uses the timeout configured on the HTTP client, a `timedelta` overrides it for this call,
12+
and `'no_timeout'` disables the timeout entirely.
13+
"""
14+
815
JsonSerializable = str | int | float | bool | None | dict[str, Any] | list[Any]
916
"""Type for representing json-serializable values. It's close enough to the real thing supported by json.parse.
1017
It was suggested in a discussion with (and approved by) Guido van Rossum, so I'd consider it correct enough.
@@ -16,8 +23,8 @@
1623
API_VERSION = 'v2'
1724
"""Current Apify API version."""
1825

19-
DEFAULT_TIMEOUT = timedelta(seconds=360)
20-
"""Default request timeout."""
26+
DEFAULT_REQUEST_TIMEOUT = timedelta(seconds=30)
27+
"""Default timeout for individual API requests."""
2128

2229
DEFAULT_MAX_RETRIES = 8
2330
"""Default maximum number of retries for failed requests."""
@@ -31,12 +38,6 @@
3138
DEFAULT_WAIT_WHEN_JOB_NOT_EXIST = timedelta(seconds=3)
3239
"""How long to wait for a job to exist before giving up."""
3340

34-
FAST_OPERATION_TIMEOUT = timedelta(seconds=5)
35-
"""Timeout for fast, idempotent operations (e.g., GET, DELETE)."""
36-
37-
STANDARD_OPERATION_TIMEOUT = timedelta(seconds=30)
38-
"""Timeout for operations that may take longer (e.g., list operations, batch operations)."""
39-
4041
TERMINAL_STATUSES = frozenset(
4142
{
4243
ActorJobStatus.SUCCEEDED,

src/apify_client/_http_clients/_base.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@
1010
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
1111
from urllib.parse import urlencode
1212

13-
from apify_client._consts import DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT
13+
from apify_client._consts import (
14+
DEFAULT_MAX_RETRIES,
15+
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
16+
DEFAULT_REQUEST_TIMEOUT,
17+
Timeout,
18+
)
1419
from apify_client._docs import docs_group
1520
from apify_client._statistics import ClientStatistics
16-
from apify_client._utils import to_seconds
1721

1822
if TYPE_CHECKING:
1923
from collections.abc import AsyncIterator, Iterator, Mapping
2024

21-
from apify_client._consts import JsonSerializable
25+
from apify_client._consts import JsonSerializable, Timeout
2226

2327

2428
@docs_group('HTTP clients')
@@ -85,7 +89,7 @@ def __init__(
8589
self,
8690
*,
8791
token: str | None = None,
88-
timeout: timedelta = DEFAULT_TIMEOUT,
92+
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
8993
max_retries: int = DEFAULT_MAX_RETRIES,
9094
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
9195
statistics: ClientStatistics | None = None,
@@ -192,12 +196,6 @@ def _build_url_with_params(self, url: str, params: dict[str, Any] | None = None)
192196

193197
return f'{url}?{query_string}'
194198

195-
def _calculate_timeout(self, attempt: int, timeout: timedelta | None = None) -> float:
196-
"""Calculate timeout for a request attempt with exponential increase, bounded by client timeout."""
197-
timeout_secs = to_seconds(timeout or self._timeout)
198-
client_timeout_secs = to_seconds(self._timeout)
199-
return min(client_timeout_secs, timeout_secs * 2 ** (attempt - 1))
200-
201199

202200
@docs_group('HTTP clients')
203201
class HttpClient(HttpClientBase, ABC):
@@ -219,7 +217,7 @@ def call(
219217
data: str | bytes | bytearray | None = None,
220218
json: Any = None,
221219
stream: bool | None = None,
222-
timeout: timedelta | None = None,
220+
timeout: Timeout = None,
223221
) -> HttpResponse:
224222
"""Make an HTTP request.
225223
@@ -231,7 +229,8 @@ def call(
231229
data: Raw request body data. Cannot be used together with json.
232230
json: JSON-serializable data for the request body. Cannot be used together with data.
233231
stream: Whether to stream the response body.
234-
timeout: Timeout for this specific request.
232+
timeout: Timeout for the API HTTP request. `None` uses the timeout configured on the client,
233+
a `timedelta` overrides it for this call, and `'no_timeout'` disables the timeout entirely.
235234
236235
Returns:
237236
The HTTP response object.
@@ -240,7 +239,6 @@ def call(
240239
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
241240
ValueError: If both json and data are provided.
242241
"""
243-
...
244242

245243

246244
@docs_group('HTTP clients')
@@ -262,7 +260,7 @@ async def call(
262260
data: str | bytes | bytearray | None = None,
263261
json: Any = None,
264262
stream: bool | None = None,
265-
timeout: timedelta | None = None,
263+
timeout: Timeout = None,
266264
) -> HttpResponse:
267265
"""Make an HTTP request.
268266
@@ -274,7 +272,8 @@ async def call(
274272
data: Raw request body data. Cannot be used together with json.
275273
json: JSON-serializable data for the request body. Cannot be used together with data.
276274
stream: Whether to stream the response body.
277-
timeout: Timeout for this specific request.
275+
timeout: Timeout for the API HTTP request. `None` uses the timeout configured on the client,
276+
a `timedelta` overrides it for this call, and `'no_timeout'` disables the timeout entirely.
278277
279278
Returns:
280279
The HTTP response object.
@@ -283,4 +282,3 @@ async def call(
283282
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
284283
ValueError: If both json and data are provided.
285284
"""
286-
...

src/apify_client/_http_clients/_impit.py

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@
1010

1111
import impit
1212

13-
from apify_client._consts import DEFAULT_MAX_RETRIES, DEFAULT_MIN_DELAY_BETWEEN_RETRIES, DEFAULT_TIMEOUT
13+
from apify_client._consts import (
14+
DEFAULT_MAX_RETRIES,
15+
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
16+
DEFAULT_REQUEST_TIMEOUT,
17+
Timeout,
18+
)
1419
from apify_client._docs import docs_group
1520
from apify_client._http_clients import HttpClient, HttpClientAsync
1621
from apify_client._logging import log_context, logger_name
@@ -20,7 +25,7 @@
2025
if TYPE_CHECKING:
2126
from collections.abc import Awaitable, Callable
2227

23-
from apify_client._consts import JsonSerializable
28+
from apify_client._consts import JsonSerializable, Timeout
2429
from apify_client._http_clients import HttpResponse
2530
from apify_client._statistics import ClientStatistics
2631

@@ -45,6 +50,24 @@ def _is_retryable_error(exc: Exception) -> bool:
4550
)
4651

4752

53+
def _compute_timeout(
54+
client_timeout: timedelta,
55+
attempt: int,
56+
timeout: Timeout,
57+
) -> float:
58+
"""Compute timeout for a request attempt with exponential increase, bounded by client timeout.
59+
60+
For `'no_timeout'`, returns 0 (impit interprets this as no timeout). For `None`, uses the client-level timeout.
61+
For `timedelta` values, doubles the timeout with each attempt but caps at the client-level timeout.
62+
"""
63+
if timeout == 'no_timeout':
64+
return 0
65+
66+
timeout_secs = to_seconds(timeout or client_timeout)
67+
client_timeout_secs = to_seconds(client_timeout)
68+
return min(client_timeout_secs, timeout_secs * 2 ** (attempt - 1))
69+
70+
4871
@docs_group('HTTP clients')
4972
class ImpitHttpClient(HttpClient):
5073
"""Synchronous HTTP client for the Apify API built on top of [Impit](https://github.com/apify/impit).
@@ -58,7 +81,7 @@ def __init__(
5881
self,
5982
*,
6083
token: str | None = None,
61-
timeout: timedelta = DEFAULT_TIMEOUT,
84+
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
6285
max_retries: int = DEFAULT_MAX_RETRIES,
6386
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
6487
statistics: ClientStatistics | None = None,
@@ -99,7 +122,7 @@ def call(
99122
data: str | bytes | bytearray | None = None,
100123
json: JsonSerializable | None = None,
101124
stream: bool | None = None,
102-
timeout: timedelta | None = None,
125+
timeout: Timeout = None,
103126
) -> HttpResponse:
104127
"""Make an HTTP request with automatic retry and exponential backoff.
105128
@@ -111,7 +134,8 @@ def call(
111134
data: Raw request body data. Cannot be used together with json.
112135
json: JSON-serializable data for the request body. Cannot be used together with data.
113136
stream: Whether to stream the response body.
114-
timeout: Timeout override for this request.
137+
timeout: Timeout for the API HTTP request. `None` uses the timeout configured on the client,
138+
a `timedelta` overrides it for this call, and `'no_timeout'` disables the timeout entirely.
115139
116140
Returns:
117141
The HTTP response object.
@@ -154,7 +178,7 @@ def _make_request(
154178
params: dict[str, Any] | None,
155179
content: bytes | None,
156180
stream: bool | None,
157-
timeout: timedelta | None,
181+
timeout: Timeout,
158182
) -> impit.Response:
159183
"""Execute a single HTTP request attempt.
160184
@@ -167,7 +191,7 @@ def _make_request(
167191
params: Query parameters.
168192
content: Request body content.
169193
stream: Whether to stream the response.
170-
timeout: Timeout override for this request.
194+
timeout: Timeout for this request.
171195
172196
Returns:
173197
The HTTP response object.
@@ -188,7 +212,7 @@ def _make_request(
188212
url=url_with_params,
189213
headers=headers,
190214
content=content,
191-
timeout=self._calculate_timeout(attempt, timeout),
215+
timeout=_compute_timeout(self._timeout, attempt, timeout),
192216
stream=stream or False,
193217
)
194218

@@ -284,7 +308,7 @@ def __init__(
284308
self,
285309
*,
286310
token: str | None = None,
287-
timeout: timedelta = DEFAULT_TIMEOUT,
311+
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
288312
max_retries: int = DEFAULT_MAX_RETRIES,
289313
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
290314
statistics: ClientStatistics | None = None,
@@ -325,7 +349,7 @@ async def call(
325349
data: str | bytes | bytearray | None = None,
326350
json: JsonSerializable | None = None,
327351
stream: bool | None = None,
328-
timeout: timedelta | None = None,
352+
timeout: Timeout = None,
329353
) -> HttpResponse:
330354
"""Make an HTTP request with automatic retry and exponential backoff.
331355
@@ -337,7 +361,8 @@ async def call(
337361
data: Raw request body data. Cannot be used together with json.
338362
json: JSON-serializable data for the request body. Cannot be used together with data.
339363
stream: Whether to stream the response body.
340-
timeout: Timeout override for this request.
364+
timeout: Timeout for the API HTTP request. `None` uses the timeout configured on the client,
365+
a `timedelta` overrides it for this call, and `'no_timeout'` disables the timeout entirely.
341366
342367
Returns:
343368
The HTTP response object.
@@ -380,7 +405,7 @@ async def _make_request(
380405
params: dict[str, Any] | None,
381406
content: bytes | None,
382407
stream: bool | None,
383-
timeout: timedelta | None,
408+
timeout: Timeout,
384409
) -> impit.Response:
385410
"""Execute a single HTTP request attempt.
386411
@@ -393,7 +418,7 @@ async def _make_request(
393418
params: Query parameters.
394419
content: Request body content.
395420
stream: Whether to stream the response.
396-
timeout: Timeout override for this request.
421+
timeout: Timeout for this request.
397422
398423
Returns:
399424
The HTTP response object.
@@ -414,7 +439,7 @@ async def _make_request(
414439
url=url_with_params,
415440
headers=headers,
416441
content=content,
417-
timeout=self._calculate_timeout(attempt, timeout),
442+
timeout=_compute_timeout(self._timeout, attempt, timeout),
418443
stream=stream or False,
419444
)
420445

0 commit comments

Comments
 (0)