Skip to content

Commit 0ad2c46

Browse files
committed
Introduce tiered timeout system with per-endpoint configuration
1 parent ac20367 commit 0ad2c46

39 files changed

+751
-843
lines changed

docs/02_concepts/code/05_retries_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ async def main() -> None:
1010
token=TOKEN,
1111
max_retries=8,
1212
min_delay_between_retries=timedelta(milliseconds=500),
13-
timeout=timedelta(seconds=360),
13+
timeout_medium=timedelta(seconds=360),
1414
)

docs/02_concepts/code/05_retries_sync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ def main() -> None:
1010
token=TOKEN,
1111
max_retries=8,
1212
min_delay_between_retries=timedelta(milliseconds=500),
13-
timeout=timedelta(seconds=360),
13+
timeout_medium=timedelta(seconds=360),
1414
)

src/apify_client/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
from importlib import metadata
22

33
from ._apify_client import ApifyClient, ApifyClientAsync
4-
from ._consts import Timeout
54
from ._http_clients import (
65
HttpClient,
76
HttpClientAsync,
87
HttpResponse,
98
ImpitHttpClient,
109
ImpitHttpClientAsync,
1110
)
11+
from ._types import Timeout
1212

1313
__version__ = metadata.version('apify-client')
1414

src/apify_client/_apify_client.py

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
DEFAULT_API_URL,
1010
DEFAULT_MAX_RETRIES,
1111
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
12-
DEFAULT_REQUEST_TIMEOUT,
13-
DEFAULT_REQUEST_TIMEOUT_MAX,
12+
DEFAULT_TIMEOUT_LONG,
13+
DEFAULT_TIMEOUT_MAX,
14+
DEFAULT_TIMEOUT_MEDIUM,
15+
DEFAULT_TIMEOUT_SHORT,
1416
)
1517
from apify_client._docs import docs_group
1618
from apify_client._http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync
@@ -115,8 +117,10 @@ def __init__(
115117
api_public_url: str | None = DEFAULT_API_URL,
116118
max_retries: int = DEFAULT_MAX_RETRIES,
117119
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
118-
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
119-
timeout_max: timedelta = DEFAULT_REQUEST_TIMEOUT_MAX,
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,
120124
headers: dict[str, str] | None = None,
121125
) -> None:
122126
"""Initialize the Apify API client.
@@ -134,7 +138,9 @@ def __init__(
134138
max_retries: How many times to retry a failed request at most.
135139
min_delay_between_retries: How long will the client wait between retrying requests
136140
(increases exponentially from this value).
137-
timeout: The initial socket timeout of the HTTP requests sent to the Apify API.
141+
timeout_short: Timeout for fast CRUD operations (e.g., get, update, delete).
142+
timeout_medium: Timeout for batch, list, and data transfer operations.
143+
timeout_long: Timeout for long-polling, streaming, and other heavy operations.
138144
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
139145
headers: Additional HTTP headers to include in all API requests.
140146
"""
@@ -194,7 +200,9 @@ def __init__(
194200
# Configuration for the default HTTP client (used if a custom client is not provided).
195201
self._max_retries = max_retries
196202
self._min_delay_between_retries = min_delay_between_retries
197-
self._timeout = timeout
203+
self._timeout_short = timeout_short
204+
self._timeout_medium = timeout_medium
205+
self._timeout_long = timeout_long
198206
self._timeout_max = timeout_max
199207
self._headers = headers
200208

@@ -253,7 +261,9 @@ def http_client(self) -> HttpClient:
253261
if self._http_client is None:
254262
self._http_client = ImpitHttpClient(
255263
token=self._token,
256-
timeout=self._timeout,
264+
timeout_short=self._timeout_short,
265+
timeout_medium=self._timeout_medium,
266+
timeout_long=self._timeout_long,
257267
timeout_max=self._timeout_max,
258268
max_retries=self._max_retries,
259269
min_delay_between_retries=self._min_delay_between_retries,
@@ -460,8 +470,10 @@ def __init__(
460470
api_public_url: str | None = DEFAULT_API_URL,
461471
max_retries: int = DEFAULT_MAX_RETRIES,
462472
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
463-
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
464-
timeout_max: timedelta = DEFAULT_REQUEST_TIMEOUT_MAX,
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,
465477
headers: dict[str, str] | None = None,
466478
) -> None:
467479
"""Initialize the Apify API client.
@@ -479,7 +491,9 @@ def __init__(
479491
max_retries: How many times to retry a failed request at most.
480492
min_delay_between_retries: How long will the client wait between retrying requests
481493
(increases exponentially from this value).
482-
timeout: The initial socket timeout of the HTTP requests sent to the Apify API.
494+
timeout_short: Timeout for fast CRUD operations (e.g., get, update, delete).
495+
timeout_medium: Timeout for batch, list, and data transfer operations.
496+
timeout_long: Timeout for long-polling, streaming, and other heavy operations.
483497
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
484498
headers: Additional HTTP headers to include in all API requests.
485499
"""
@@ -539,7 +553,9 @@ def __init__(
539553
# Configuration for the default HTTP client (used if a custom client is not provided).
540554
self._max_retries = max_retries
541555
self._min_delay_between_retries = min_delay_between_retries
542-
self._timeout = timeout
556+
self._timeout_short = timeout_short
557+
self._timeout_medium = timeout_medium
558+
self._timeout_long = timeout_long
543559
self._timeout_max = timeout_max
544560
self._headers = headers
545561

@@ -598,7 +614,9 @@ def http_client(self) -> HttpClientAsync:
598614
if self._http_client is None:
599615
self._http_client = ImpitHttpClientAsync(
600616
token=self._token,
601-
timeout=self._timeout,
617+
timeout_short=self._timeout_short,
618+
timeout_medium=self._timeout_medium,
619+
timeout_long=self._timeout_long,
602620
timeout_max=self._timeout_max,
603621
max_retries=self._max_retries,
604622
min_delay_between_retries=self._min_delay_between_retries,

src/apify_client/_consts.py

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

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

65
from apify_client._models import ActorJobStatus
76

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-
15-
JsonSerializable = str | int | float | bool | None | dict[str, Any] | list[Any]
16-
"""Type for representing json-serializable values. It's close enough to the real thing supported by json.parse.
17-
It was suggested in a discussion with (and approved by) Guido van Rossum, so I'd consider it correct enough.
18-
"""
19-
207
DEFAULT_API_URL = 'https://api.apify.com'
218
"""Default base URL for the Apify API."""
229

2310
API_VERSION = 'v2'
2411
"""Current Apify API version."""
2512

26-
DEFAULT_REQUEST_TIMEOUT = timedelta(seconds=10)
27-
"""Default initial timeout for individual API requests."""
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=300)
20+
"""Default timeout for long-polling, streaming, and other heavy operations."""
2821

29-
DEFAULT_REQUEST_TIMEOUT_MAX = timedelta(seconds=600)
22+
DEFAULT_TIMEOUT_MAX = timedelta(seconds=600)
3023
"""Default maximum timeout cap for individual API requests (limits exponential growth)."""
3124

3225
DEFAULT_MAX_RETRIES = 4

src/apify_client/_http_clients/_base.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,19 @@
1313
from apify_client._consts import (
1414
DEFAULT_MAX_RETRIES,
1515
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
16-
DEFAULT_REQUEST_TIMEOUT,
17-
DEFAULT_REQUEST_TIMEOUT_MAX,
18-
Timeout,
16+
DEFAULT_TIMEOUT_LONG,
17+
DEFAULT_TIMEOUT_MAX,
18+
DEFAULT_TIMEOUT_MEDIUM,
19+
DEFAULT_TIMEOUT_SHORT,
1920
)
2021
from apify_client._docs import docs_group
2122
from apify_client._statistics import ClientStatistics
23+
from apify_client._utils import to_seconds
2224

2325
if TYPE_CHECKING:
2426
from collections.abc import AsyncIterator, Iterator, Mapping
2527

26-
from apify_client._consts import JsonSerializable, Timeout
28+
from apify_client._types import JsonSerializable, Timeout
2729

2830

2931
@docs_group('HTTP clients')
@@ -90,8 +92,10 @@ def __init__(
9092
self,
9193
*,
9294
token: str | None = None,
93-
timeout: timedelta = DEFAULT_REQUEST_TIMEOUT,
94-
timeout_max: timedelta = DEFAULT_REQUEST_TIMEOUT_MAX,
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,
9599
max_retries: int = DEFAULT_MAX_RETRIES,
96100
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
97101
statistics: ClientStatistics | None = None,
@@ -101,14 +105,18 @@ def __init__(
101105
102106
Args:
103107
token: Apify API token for authentication.
104-
timeout: Initial request timeout.
108+
timeout_short: Timeout for fast CRUD operations (e.g., get, update, delete).
109+
timeout_medium: Timeout for batch, list, and data transfer operations.
110+
timeout_long: Timeout for long-polling, streaming, and other heavy operations.
105111
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
106112
max_retries: Maximum number of retries for failed requests.
107113
min_delay_between_retries: Minimum delay between retries.
108114
statistics: Statistics tracker for API calls. Created automatically if not provided.
109115
headers: Additional HTTP headers to include in all requests.
110116
"""
111-
self._timeout = timeout
117+
self._timeout_short = timeout_short
118+
self._timeout_medium = timeout_medium
119+
self._timeout_long = timeout_long
112120
self._timeout_max = timeout_max
113121
self._max_retries = max_retries
114122
self._min_delay_between_retries = min_delay_between_retries
@@ -157,6 +165,34 @@ def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None:
157165

158166
return parsed_params
159167

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+
160196
def _prepare_request_call(
161197
self,
162198
headers: dict[str, str] | None = None,
@@ -221,7 +257,7 @@ def call(
221257
data: str | bytes | bytearray | None = None,
222258
json: Any = None,
223259
stream: bool | None = None,
224-
timeout: Timeout = None,
260+
timeout: Timeout = 'medium',
225261
) -> HttpResponse:
226262
"""Make an HTTP request.
227263
@@ -233,8 +269,9 @@ def call(
233269
data: Raw request body data. Cannot be used together with json.
234270
json: JSON-serializable data for the request body. Cannot be used together with data.
235271
stream: Whether to stream the response body.
236-
timeout: Timeout for the API HTTP request. `None` uses the timeout configured on the client,
237-
a `timedelta` overrides it for this call, and `'no_timeout'` disables the timeout entirely.
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.
238275
239276
Returns:
240277
The HTTP response object.
@@ -264,7 +301,7 @@ async def call(
264301
data: str | bytes | bytearray | None = None,
265302
json: Any = None,
266303
stream: bool | None = None,
267-
timeout: Timeout = None,
304+
timeout: Timeout = 'medium',
268305
) -> HttpResponse:
269306
"""Make an HTTP request.
270307
@@ -276,8 +313,9 @@ async def call(
276313
data: Raw request body data. Cannot be used together with json.
277314
json: JSON-serializable data for the request body. Cannot be used together with data.
278315
stream: Whether to stream the response body.
279-
timeout: Timeout for the API HTTP request. `None` uses the timeout configured on the client,
280-
a `timedelta` overrides it for this call, and `'no_timeout'` disables the timeout entirely.
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.
281319
282320
Returns:
283321
The HTTP response object.

0 commit comments

Comments
 (0)