Skip to content

Commit 7bf700a

Browse files
committed
Introduce tiered timeout system with per-endpoint configuration
1 parent 21f2665 commit 7bf700a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1512
-450
lines changed

docs/02_concepts/code/05_retries_async.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
async def main() -> None:
99
apify_client = ApifyClientAsync(
1010
token=TOKEN,
11-
max_retries=8,
11+
max_retries=4,
1212
min_delay_between_retries=timedelta(milliseconds=500),
13-
timeout=timedelta(seconds=360),
13+
timeout_short=timedelta(seconds=5),
14+
timeout_medium=timedelta(seconds=30),
15+
timeout_long=timedelta(seconds=360),
16+
timeout_max=timedelta(seconds=360),
1417
)

docs/02_concepts/code/05_retries_sync.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
def main() -> None:
99
apify_client = ApifyClient(
1010
token=TOKEN,
11-
max_retries=8,
11+
max_retries=4,
1212
min_delay_between_retries=timedelta(milliseconds=500),
13-
timeout=timedelta(seconds=360),
13+
timeout_short=timedelta(seconds=5),
14+
timeout_medium=timedelta(seconds=30),
15+
timeout_long=timedelta(seconds=360),
16+
timeout_max=timedelta(seconds=360),
1417
)

src/apify_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ImpitHttpClient,
99
ImpitHttpClientAsync,
1010
)
11+
from ._types import Timeout
1112

1213
__version__ = metadata.version('apify-client')
1314

@@ -19,5 +20,6 @@
1920
'HttpResponse',
2021
'ImpitHttpClient',
2122
'ImpitHttpClientAsync',
23+
'Timeout',
2224
'__version__',
2325
]

src/apify_client/_apify_client.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
DEFAULT_API_URL,
1010
DEFAULT_MAX_RETRIES,
1111
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
12-
DEFAULT_TIMEOUT,
12+
DEFAULT_TIMEOUT_LONG,
13+
DEFAULT_TIMEOUT_MAX,
14+
DEFAULT_TIMEOUT_MEDIUM,
15+
DEFAULT_TIMEOUT_SHORT,
1316
)
1417
from apify_client._docs import docs_group
1518
from apify_client._http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync
@@ -114,7 +117,10 @@ def __init__(
114117
api_public_url: str | None = DEFAULT_API_URL,
115118
max_retries: int = DEFAULT_MAX_RETRIES,
116119
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
117-
timeout: timedelta = DEFAULT_TIMEOUT,
120+
timeout_short: timedelta = DEFAULT_TIMEOUT_SHORT,
121+
timeout_medium: timedelta = DEFAULT_TIMEOUT_MEDIUM,
122+
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
123+
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
118124
headers: dict[str, str] | None = None,
119125
) -> None:
120126
"""Initialize the Apify API client.
@@ -132,7 +138,10 @@ def __init__(
132138
max_retries: How many times to retry a failed request at most.
133139
min_delay_between_retries: How long will the client wait between retrying requests
134140
(increases exponentially from this value).
135-
timeout: The socket timeout of the HTTP requests sent to the Apify API.
141+
timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...).
142+
timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...).
143+
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
144+
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
136145
headers: Additional HTTP headers to include in all API requests.
137146
"""
138147
# We need to do this because of mocking in tests and default mutable arguments.
@@ -191,7 +200,10 @@ def __init__(
191200
# Configuration for the default HTTP client (used if a custom client is not provided).
192201
self._max_retries = max_retries
193202
self._min_delay_between_retries = min_delay_between_retries
194-
self._timeout = timeout
203+
self._timeout_short = timeout_short
204+
self._timeout_medium = timeout_medium
205+
self._timeout_long = timeout_long
206+
self._timeout_max = timeout_max
195207
self._headers = headers
196208

197209
@classmethod
@@ -249,7 +261,10 @@ def http_client(self) -> HttpClient:
249261
if self._http_client is None:
250262
self._http_client = ImpitHttpClient(
251263
token=self._token,
252-
timeout=self._timeout,
264+
timeout_short=self._timeout_short,
265+
timeout_medium=self._timeout_medium,
266+
timeout_long=self._timeout_long,
267+
timeout_max=self._timeout_max,
253268
max_retries=self._max_retries,
254269
min_delay_between_retries=self._min_delay_between_retries,
255270
statistics=self._statistics,
@@ -455,7 +470,10 @@ def __init__(
455470
api_public_url: str | None = DEFAULT_API_URL,
456471
max_retries: int = DEFAULT_MAX_RETRIES,
457472
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
458-
timeout: timedelta = DEFAULT_TIMEOUT,
473+
timeout_short: timedelta = DEFAULT_TIMEOUT_SHORT,
474+
timeout_medium: timedelta = DEFAULT_TIMEOUT_MEDIUM,
475+
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
476+
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
459477
headers: dict[str, str] | None = None,
460478
) -> None:
461479
"""Initialize the Apify API client.
@@ -473,7 +491,10 @@ def __init__(
473491
max_retries: How many times to retry a failed request at most.
474492
min_delay_between_retries: How long will the client wait between retrying requests
475493
(increases exponentially from this value).
476-
timeout: The socket timeout of the HTTP requests sent to the Apify API.
494+
timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...).
495+
timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...).
496+
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
497+
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
477498
headers: Additional HTTP headers to include in all API requests.
478499
"""
479500
# We need to do this because of mocking in tests and default mutable arguments.
@@ -532,7 +553,10 @@ def __init__(
532553
# Configuration for the default HTTP client (used if a custom client is not provided).
533554
self._max_retries = max_retries
534555
self._min_delay_between_retries = min_delay_between_retries
535-
self._timeout = timeout
556+
self._timeout_short = timeout_short
557+
self._timeout_medium = timeout_medium
558+
self._timeout_long = timeout_long
559+
self._timeout_max = timeout_max
536560
self._headers = headers
537561

538562
@classmethod
@@ -590,7 +614,10 @@ def http_client(self) -> HttpClientAsync:
590614
if self._http_client is None:
591615
self._http_client = ImpitHttpClientAsync(
592616
token=self._token,
593-
timeout=self._timeout,
617+
timeout_short=self._timeout_short,
618+
timeout_medium=self._timeout_medium,
619+
timeout_long=self._timeout_long,
620+
timeout_max=self._timeout_max,
594621
max_retries=self._max_retries,
595622
min_delay_between_retries=self._min_delay_between_retries,
596623
statistics=self._statistics,

src/apify_client/_consts.py

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,28 @@
11
from __future__ import annotations
22

33
from datetime import timedelta
4-
from typing import Any
54

65
from apify_client._models import ActorJobStatus
76

8-
JsonSerializable = str | int | float | bool | None | dict[str, Any] | list[Any]
9-
"""Type for representing json-serializable values. It's close enough to the real thing supported by json.parse.
10-
It was suggested in a discussion with (and approved by) Guido van Rossum, so I'd consider it correct enough.
11-
"""
12-
137
DEFAULT_API_URL = 'https://api.apify.com'
148
"""Default base URL for the Apify API."""
159

1610
API_VERSION = 'v2'
1711
"""Current Apify API version."""
1812

19-
DEFAULT_TIMEOUT = timedelta(seconds=360)
20-
"""Default request timeout."""
13+
DEFAULT_TIMEOUT_SHORT = timedelta(seconds=5)
14+
"""Default timeout for fast CRUD operations (e.g., get, update, delete)."""
15+
16+
DEFAULT_TIMEOUT_MEDIUM = timedelta(seconds=30)
17+
"""Default timeout for batch, list, and data transfer operations."""
18+
19+
DEFAULT_TIMEOUT_LONG = timedelta(seconds=360)
20+
"""Default timeout for long-polling, streaming, and other heavy operations."""
2121

22-
DEFAULT_MAX_RETRIES = 8
22+
DEFAULT_TIMEOUT_MAX = timedelta(seconds=360)
23+
"""Default maximum timeout cap for individual API requests (limits exponential growth)."""
24+
25+
DEFAULT_MAX_RETRIES = 4
2326
"""Default maximum number of retries for failed requests."""
2427

2528
DEFAULT_MIN_DELAY_BETWEEN_RETRIES = timedelta(milliseconds=500)
@@ -31,12 +34,6 @@
3134
DEFAULT_WAIT_WHEN_JOB_NOT_EXIST = timedelta(seconds=3)
3235
"""How long to wait for a job to exist before giving up."""
3336

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-
4037
TERMINAL_STATUSES = frozenset(
4138
{
4239
ActorJobStatus.SUCCEEDED,

src/apify_client/_http_clients/_base.py

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,22 @@
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_TIMEOUT_LONG,
17+
DEFAULT_TIMEOUT_MAX,
18+
DEFAULT_TIMEOUT_MEDIUM,
19+
DEFAULT_TIMEOUT_SHORT,
20+
)
1421
from apify_client._docs import docs_group
1522
from apify_client._statistics import ClientStatistics
1623
from apify_client._utils import to_seconds
1724

1825
if TYPE_CHECKING:
1926
from collections.abc import AsyncIterator, Iterator, Mapping
2027

21-
from apify_client._consts import JsonSerializable
28+
from apify_client._types import JsonSerializable, Timeout
2229

2330

2431
@docs_group('HTTP clients')
@@ -85,7 +92,10 @@ def __init__(
8592
self,
8693
*,
8794
token: str | None = None,
88-
timeout: timedelta = DEFAULT_TIMEOUT,
95+
timeout_short: timedelta = DEFAULT_TIMEOUT_SHORT,
96+
timeout_medium: timedelta = DEFAULT_TIMEOUT_MEDIUM,
97+
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
98+
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
8999
max_retries: int = DEFAULT_MAX_RETRIES,
90100
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
91101
statistics: ClientStatistics | None = None,
@@ -95,13 +105,19 @@ def __init__(
95105
96106
Args:
97107
token: Apify API token for authentication.
98-
timeout: Request timeout.
108+
timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...).
109+
timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...).
110+
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
111+
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
99112
max_retries: Maximum number of retries for failed requests.
100113
min_delay_between_retries: Minimum delay between retries.
101114
statistics: Statistics tracker for API calls. Created automatically if not provided.
102115
headers: Additional HTTP headers to include in all requests.
103116
"""
104-
self._timeout = timeout
117+
self._timeout_short = timeout_short
118+
self._timeout_medium = timeout_medium
119+
self._timeout_long = timeout_long
120+
self._timeout_max = timeout_max
105121
self._max_retries = max_retries
106122
self._min_delay_between_retries = min_delay_between_retries
107123
self._statistics = statistics or ClientStatistics()
@@ -149,6 +165,34 @@ def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
149165

150166
return parsed_params
151167

168+
def _compute_timeout(self, timeout: Timeout, attempt: int) -> int | float | None:
169+
"""Resolve a timeout tier and compute the timeout for a request attempt with exponential increase.
170+
171+
For `'no_timeout'`, returns `None`. For tier literals and explicit `timedelta` values, doubles the timeout
172+
with each attempt but caps at `timeout_max`.
173+
174+
Args:
175+
timeout: The timeout specification to resolve (tier literal or explicit `timedelta`).
176+
attempt: Current attempt number (1-indexed).
177+
178+
Returns:
179+
Timeout in seconds, or `None` for `'no_timeout'`.
180+
"""
181+
if timeout == 'no_timeout':
182+
return None
183+
184+
if timeout == 'short':
185+
resolved = self._timeout_short
186+
elif timeout == 'medium':
187+
resolved = self._timeout_medium
188+
elif timeout == 'long':
189+
resolved = self._timeout_long
190+
else:
191+
resolved = timeout
192+
193+
new_timeout = min(resolved * (2 ** (attempt - 1)), self._timeout_max)
194+
return to_seconds(new_timeout)
195+
152196
def _prepare_request_call(
153197
self,
154198
headers: dict[str, str] | None = None,
@@ -192,12 +236,6 @@ def _build_url_with_params(self, url: str, params: dict[str, Any] | None = None)
192236

193237
return f'{url}?{query_string}'
194238

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-
201239

202240
@docs_group('HTTP clients')
203241
class HttpClient(HttpClientBase, ABC):
@@ -219,7 +257,7 @@ def call(
219257
data: str | bytes | bytearray | None = None,
220258
json: Any = None,
221259
stream: bool | None = None,
222-
timeout: timedelta | None = None,
260+
timeout: Timeout = 'medium',
223261
) -> HttpResponse:
224262
"""Make an HTTP request.
225263
@@ -231,7 +269,9 @@ def call(
231269
data: Raw request body data. Cannot be used together with json.
232270
json: JSON-serializable data for the request body. Cannot be used together with data.
233271
stream: Whether to stream the response body.
234-
timeout: Timeout for this specific request.
272+
timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for
273+
preconfigured timeouts. A `timedelta` overrides it for this call, and `no_timeout` disables
274+
the timeout entirely.
235275
236276
Returns:
237277
The HTTP response object.
@@ -240,7 +280,6 @@ def call(
240280
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
241281
ValueError: If both json and data are provided.
242282
"""
243-
...
244283

245284

246285
@docs_group('HTTP clients')
@@ -262,7 +301,7 @@ async def call(
262301
data: str | bytes | bytearray | None = None,
263302
json: Any = None,
264303
stream: bool | None = None,
265-
timeout: timedelta | None = None,
304+
timeout: Timeout = 'medium',
266305
) -> HttpResponse:
267306
"""Make an HTTP request.
268307
@@ -274,7 +313,9 @@ async def call(
274313
data: Raw request body data. Cannot be used together with json.
275314
json: JSON-serializable data for the request body. Cannot be used together with data.
276315
stream: Whether to stream the response body.
277-
timeout: Timeout for this specific request.
316+
timeout: Timeout for the API HTTP request. Use `short`, `medium`, or `long` tier literals for
317+
preconfigured timeouts. A `timedelta` overrides it for this call, and `no_timeout` disables
318+
the timeout entirely.
278319
279320
Returns:
280321
The HTTP response object.
@@ -283,4 +324,3 @@ async def call(
283324
ApifyApiError: If the request fails after all retries or returns a non-retryable error status.
284325
ValueError: If both json and data are provided.
285326
"""
286-
...

0 commit comments

Comments
 (0)