Skip to content

Commit 6c8f522

Browse files
committed
Add DynamicTimeoutFunction to allow timeout customization for individual requests.
1 parent 3059a17 commit 6c8f522

File tree

4 files changed

+165
-0
lines changed

4 files changed

+165
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import AsyncIterable, Iterable
4+
from typing import Protocol, Union
5+
6+
RequestContent = Union[str, bytes, Iterable[bytes], AsyncIterable[bytes]]
7+
8+
9+
class DynamicTimeoutFunction(Protocol):
10+
"""A function for dynamically creating suitable timeout for an http request."""
11+
12+
def __call__(self, method: str, url: str, content: RequestContent) -> None | int:
13+
"""Generate suitable timeout [s] for the request."""

src/apify_client/_http_client.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
if TYPE_CHECKING:
2121
from apify_shared.types import JSONSerializable
2222

23+
from apify_client._dynamic_timeout import DynamicTimeoutFunction
24+
2325

2426
DEFAULT_BACKOFF_EXPONENTIAL_FACTOR = 2
2527
DEFAULT_BACKOFF_RANDOM_FACTOR = 1
@@ -36,11 +38,13 @@ def __init__(
3638
max_retries: int = 8,
3739
min_delay_between_retries_millis: int = 500,
3840
timeout_secs: int = 360,
41+
get_dynamic_timeout: DynamicTimeoutFunction | None = None,
3942
stats: Statistics | None = None,
4043
) -> None:
4144
self.max_retries = max_retries
4245
self.min_delay_between_retries_millis = min_delay_between_retries_millis
4346
self.timeout_secs = timeout_secs
47+
self._get_dynamic_timeout = get_dynamic_timeout
4448

4549
headers = {'Accept': 'application/json, */*'}
4650

@@ -170,6 +174,16 @@ def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response:
170174
params=params,
171175
content=content,
172176
)
177+
178+
if self._get_dynamic_timeout:
179+
timeout = self._get_dynamic_timeout(method, url, content) or self.timeout_secs
180+
request.extensions['timeout'] = {
181+
'connect': timeout,
182+
'pool': timeout,
183+
'read': timeout,
184+
'write': timeout,
185+
}
186+
173187
response = httpx_client.send(
174188
request=request,
175189
stream=stream or False,
@@ -249,6 +263,16 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response
249263
params=params,
250264
content=content,
251265
)
266+
267+
if self._get_dynamic_timeout:
268+
timeout = self._get_dynamic_timeout(method, url, content) or self.timeout_secs
269+
request.extensions['timeout'] = {
270+
'connect': timeout,
271+
'pool': timeout,
272+
'read': timeout,
273+
'write': timeout,
274+
}
275+
252276
response = await httpx_async_client.send(
253277
request=request,
254278
stream=stream or False,

src/apify_client/client.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from typing import TYPE_CHECKING
4+
35
from apify_shared.utils import ignore_docs
46

57
from apify_client._http_client import HTTPClient, HTTPClientAsync
@@ -53,6 +55,9 @@
5355
WebhookDispatchCollectionClientAsync,
5456
)
5557

58+
if TYPE_CHECKING:
59+
from apify_client._dynamic_timeout import DynamicTimeoutFunction
60+
5661
DEFAULT_API_URL = 'https://api.apify.com'
5762
API_VERSION = 'v2'
5863

@@ -108,6 +113,7 @@ def __init__(
108113
max_retries: int | None = 8,
109114
min_delay_between_retries_millis: int | None = 500,
110115
timeout_secs: int | None = 360,
116+
get_dynamic_timeout: DynamicTimeoutFunction | None = None,
111117
) -> None:
112118
"""Initialize a new instance.
113119
@@ -118,6 +124,7 @@ def __init__(
118124
min_delay_between_retries_millis: How long will the client wait between retrying requests
119125
(increases exponentially from this value).
120126
timeout_secs: The socket timeout of the HTTP requests sent to the Apify API.
127+
get_dynamic_timeout: A function that can be called for each request to get suitable custom timeout for it.
121128
"""
122129
super().__init__(
123130
token,
@@ -133,6 +140,7 @@ def __init__(
133140
max_retries=self.max_retries,
134141
min_delay_between_retries_millis=self.min_delay_between_retries_millis,
135142
timeout_secs=self.timeout_secs,
143+
get_dynamic_timeout=get_dynamic_timeout,
136144
stats=self.stats,
137145
)
138146

@@ -291,6 +299,7 @@ def __init__(
291299
max_retries: int | None = 8,
292300
min_delay_between_retries_millis: int | None = 500,
293301
timeout_secs: int | None = 360,
302+
get_dynamic_timeout: DynamicTimeoutFunction | None = None,
294303
) -> None:
295304
"""Initialize a new instance.
296305
@@ -301,6 +310,7 @@ def __init__(
301310
min_delay_between_retries_millis: How long will the client wait between retrying requests
302311
(increases exponentially from this value).
303312
timeout_secs: The socket timeout of the HTTP requests sent to the Apify API.
313+
get_dynamic_timeout: A function that can be called for each request to get suitable custom timeout for it.
304314
"""
305315
super().__init__(
306316
token,
@@ -316,6 +326,7 @@ def __init__(
316326
max_retries=self.max_retries,
317327
min_delay_between_retries_millis=self.min_delay_between_retries_millis,
318328
timeout_secs=self.timeout_secs,
329+
get_dynamic_timeout=get_dynamic_timeout,
319330
stats=self.stats,
320331
)
321332

tests/unit/test_dynamic_timeout.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import pytest
6+
import respx
7+
from httpx import Request, Response
8+
9+
from apify_client import ApifyClient, ApifyClientAsync
10+
11+
if TYPE_CHECKING:
12+
from apify_client._dynamic_timeout import DynamicTimeoutFunction, RequestContent
13+
14+
15+
@pytest.fixture
16+
def get_dynamic_timeout_function() -> DynamicTimeoutFunction:
17+
"""Example of a dynamic timeout function."""
18+
19+
def get_dynamic_timeout(method: str, url: str, content: RequestContent) -> int | None:
20+
"""Return suitable timeout.
21+
22+
For POST on endpoint v2/datasets/whatever/items timeout is proportional to the size of the content.
23+
For everything else return fixed 30."""
24+
if isinstance(content, bytes) and method == 'POST' and url.endswith('v2/datasets/whatever/items'):
25+
dynamic_timeout_based_on_size = int(len(content) / 10)
26+
return min(360, max(5, dynamic_timeout_based_on_size)) # Saturate in range 5-360 seconds
27+
return 30
28+
29+
return get_dynamic_timeout
30+
31+
32+
@respx.mock
33+
@pytest.mark.parametrize(
34+
('content', 'expected_timeout'),
35+
[
36+
pytest.param('abcd', 5, id='Small payload'),
37+
pytest.param('abcd' * 10000, 9, id='Payload in the dynamic timeout interval interval'),
38+
pytest.param('abcd' * 1000000, 360, id='Large payload'),
39+
],
40+
)
41+
async def test_dynamic_timeout_async_client(
42+
get_dynamic_timeout_function: DynamicTimeoutFunction, content: str, expected_timeout: int
43+
) -> None:
44+
def check_timeout(request: Request) -> Response:
45+
assert request.extensions['timeout'] == {
46+
'connect': expected_timeout,
47+
'pool': expected_timeout,
48+
'read': expected_timeout,
49+
'write': expected_timeout,
50+
}
51+
return Response(201)
52+
53+
respx.post('https://api.apify.com/v2/datasets/whatever/items').mock(side_effect=check_timeout)
54+
client = ApifyClientAsync(get_dynamic_timeout=get_dynamic_timeout_function)
55+
await client.dataset(dataset_id='whatever').push_items({'some_key': content})
56+
57+
58+
@respx.mock
59+
async def test_dynamic_timeout_async_client_default() -> None:
60+
expected_timeout = 360
61+
62+
def check_timeout(request: Request) -> Response:
63+
assert request.extensions['timeout'] == {
64+
'connect': expected_timeout,
65+
'pool': expected_timeout,
66+
'read': expected_timeout,
67+
'write': expected_timeout,
68+
}
69+
return Response(201)
70+
71+
respx.post('https://api.apify.com/v2/datasets/whatever/items').mock(side_effect=check_timeout)
72+
client = ApifyClientAsync()
73+
await client.dataset(dataset_id='whatever').push_items({'some_key': 'abcd'})
74+
75+
76+
@respx.mock
77+
@pytest.mark.parametrize(
78+
('content', 'expected_timeout'),
79+
[
80+
pytest.param('abcd', 5, id='Small payload'),
81+
pytest.param('abcd' * 10000, 9, id='Payload in the dynamic timeout interval interval'),
82+
pytest.param('abcd' * 1000000, 360, id='Large payload'),
83+
],
84+
)
85+
def test_dynamic_timeout_sync_client(
86+
get_dynamic_timeout_function: DynamicTimeoutFunction, content: str, expected_timeout: int
87+
) -> None:
88+
def check_timeout(request: Request) -> Response:
89+
assert request.extensions['timeout'] == {
90+
'connect': expected_timeout,
91+
'pool': expected_timeout,
92+
'read': expected_timeout,
93+
'write': expected_timeout,
94+
}
95+
return Response(201)
96+
97+
respx.post('https://api.apify.com/v2/datasets/whatever/items').mock(side_effect=check_timeout)
98+
client = ApifyClient(get_dynamic_timeout=get_dynamic_timeout_function)
99+
client.dataset(dataset_id='whatever').push_items({'some_key': content})
100+
101+
102+
@respx.mock
103+
def test_dynamic_timeout_sync_client_default() -> None:
104+
expected_timeout = 360
105+
106+
def check_timeout(request: Request) -> Response:
107+
assert request.extensions['timeout'] == {
108+
'connect': expected_timeout,
109+
'pool': expected_timeout,
110+
'read': expected_timeout,
111+
'write': expected_timeout,
112+
}
113+
return Response(201)
114+
115+
respx.post('https://api.apify.com/v2/datasets/whatever/items').mock(side_effect=check_timeout)
116+
client = ApifyClient()
117+
client.dataset(dataset_id='whatever').push_items({'some_key': 'abcd'})

0 commit comments

Comments
 (0)