Skip to content

Commit 6356d0c

Browse files
authored
refactor: Rework client timeout (#384)
Add optional timeout argument to the individual client calls. Timeout of each call is either this timeout or the default client timeout. In case of failed, but retry-able request, the timeout is multiplied by 2^attempt in each consecutive attempt, up to the value of client default timeout. Several storage related endpoints now have reduced timeout value. Mainly the endpoints that are not expected to take long or are considered safe to run twice or idempotent. Timeouts for the storage related endpoints are all summarized in the new tests where it is all in one place. Addresses issues found in: apify/crawlee-python#1132
1 parent fb8fe8c commit 6356d0c

File tree

7 files changed

+250
-26
lines changed

7 files changed

+250
-26
lines changed

src/apify_client/_http_client.py

+22
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def call(
143143
json: JSONSerializable | None = None,
144144
stream: bool | None = None,
145145
parse_response: bool | None = True,
146+
timeout_secs: int | None = None,
146147
) -> httpx.Response:
147148
log_context.method.set(method)
148149
log_context.url.set(url)
@@ -170,6 +171,16 @@ def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response:
170171
params=params,
171172
content=content,
172173
)
174+
175+
# Increase timeout with each attempt. Max timeout is bounded by the client timeout.
176+
timeout = min(self.timeout_secs, (timeout_secs or self.timeout_secs) * 2 ** (attempt - 1))
177+
request.extensions['timeout'] = {
178+
'connect': timeout,
179+
'pool': timeout,
180+
'read': timeout,
181+
'write': timeout,
182+
}
183+
173184
response = httpx_client.send(
174185
request=request,
175186
stream=stream or False,
@@ -225,6 +236,7 @@ async def call(
225236
json: JSONSerializable | None = None,
226237
stream: bool | None = None,
227238
parse_response: bool | None = True,
239+
timeout_secs: int | None = None,
228240
) -> httpx.Response:
229241
log_context.method.set(method)
230242
log_context.url.set(url)
@@ -249,6 +261,16 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response
249261
params=params,
250262
content=content,
251263
)
264+
265+
# Increase timeout with each attempt. Max timeout is bounded by the client timeout.
266+
timeout = min(self.timeout_secs, (timeout_secs or self.timeout_secs) * 2 ** (attempt - 1))
267+
request.extensions['timeout'] = {
268+
'connect': timeout,
269+
'pool': timeout,
270+
'read': timeout,
271+
'write': timeout,
272+
}
273+
252274
response = await httpx_async_client.send(
253275
request=request,
254276
stream=stream or False,

src/apify_client/client.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
)
5555

5656
DEFAULT_API_URL = 'https://api.apify.com'
57+
DEFAULT_TIMEOUT = 360
5758
API_VERSION = 'v2'
5859

5960

@@ -68,7 +69,7 @@ def __init__(
6869
api_url: str | None = None,
6970
max_retries: int | None = 8,
7071
min_delay_between_retries_millis: int | None = 500,
71-
timeout_secs: int | None = 360,
72+
timeout_secs: int | None = DEFAULT_TIMEOUT,
7273
) -> None:
7374
"""Initialize a new instance.
7475
@@ -85,7 +86,7 @@ def __init__(
8586
self.base_url = f'{api_url}/{API_VERSION}'
8687
self.max_retries = max_retries or 8
8788
self.min_delay_between_retries_millis = min_delay_between_retries_millis or 500
88-
self.timeout_secs = timeout_secs or 360
89+
self.timeout_secs = timeout_secs or DEFAULT_TIMEOUT
8990

9091
def _options(self) -> dict:
9192
return {
@@ -107,7 +108,7 @@ def __init__(
107108
api_url: str | None = None,
108109
max_retries: int | None = 8,
109110
min_delay_between_retries_millis: int | None = 500,
110-
timeout_secs: int | None = 360,
111+
timeout_secs: int | None = DEFAULT_TIMEOUT,
111112
) -> None:
112113
"""Initialize a new instance.
113114
@@ -290,7 +291,7 @@ def __init__(
290291
api_url: str | None = None,
291292
max_retries: int | None = 8,
292293
min_delay_between_retries_millis: int | None = 500,
293-
timeout_secs: int | None = 360,
294+
timeout_secs: int | None = DEFAULT_TIMEOUT,
294295
) -> None:
295296
"""Initialize a new instance.
296297

src/apify_client/clients/base/resource_client.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@
1111
class ResourceClient(BaseClient):
1212
"""Base class for sub-clients manipulating a single resource."""
1313

14-
def _get(self) -> dict | None:
14+
def _get(self, timeout_secs: int | None = None) -> dict | None:
1515
try:
1616
response = self.http_client.call(
1717
url=self.url,
1818
method='GET',
1919
params=self._params(),
20+
timeout_secs=timeout_secs,
2021
)
2122

2223
return parse_date_fields(pluck_data(response.json()))
@@ -26,22 +27,24 @@ def _get(self) -> dict | None:
2627

2728
return None
2829

29-
def _update(self, updated_fields: dict) -> dict:
30+
def _update(self, updated_fields: dict, timeout_secs: int | None = None) -> dict:
3031
response = self.http_client.call(
3132
url=self._url(),
3233
method='PUT',
3334
params=self._params(),
3435
json=updated_fields,
36+
timeout_secs=timeout_secs,
3537
)
3638

3739
return parse_date_fields(pluck_data(response.json()))
3840

39-
def _delete(self) -> None:
41+
def _delete(self, timeout_secs: int | None = None) -> None:
4042
try:
4143
self.http_client.call(
4244
url=self._url(),
4345
method='DELETE',
4446
params=self._params(),
47+
timeout_secs=timeout_secs,
4548
)
4649

4750
except ApifyApiError as exc:
@@ -52,12 +55,13 @@ def _delete(self) -> None:
5255
class ResourceClientAsync(BaseClientAsync):
5356
"""Base class for async sub-clients manipulating a single resource."""
5457

55-
async def _get(self) -> dict | None:
58+
async def _get(self, timeout_secs: int | None = None) -> dict | None:
5659
try:
5760
response = await self.http_client.call(
5861
url=self.url,
5962
method='GET',
6063
params=self._params(),
64+
timeout_secs=timeout_secs,
6165
)
6266

6367
return parse_date_fields(pluck_data(response.json()))
@@ -67,22 +71,24 @@ async def _get(self) -> dict | None:
6771

6872
return None
6973

70-
async def _update(self, updated_fields: dict) -> dict:
74+
async def _update(self, updated_fields: dict, timeout_secs: int | None = None) -> dict:
7175
response = await self.http_client.call(
7276
url=self._url(),
7377
method='PUT',
7478
params=self._params(),
7579
json=updated_fields,
80+
timeout_secs=timeout_secs,
7681
)
7782

7883
return parse_date_fields(pluck_data(response.json()))
7984

80-
async def _delete(self) -> None:
85+
async def _delete(self, timeout_secs: int | None = None) -> None:
8186
try:
8287
await self.http_client.call(
8388
url=self._url(),
8489
method='DELETE',
8590
params=self._params(),
91+
timeout_secs=timeout_secs,
8692
)
8793

8894
except ApifyApiError as exc:

src/apify_client/clients/resource_clients/dataset.py

+13-6
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
import httpx
1818
from apify_shared.types import JSONSerializable
1919

20+
_SMALL_TIMEOUT = 5 # For fast and common actions. Suitable for idempotent actions.
21+
_MEDIUM_TIMEOUT = 30 # For actions that may take longer.
22+
2023

2124
class DatasetClient(ResourceClient):
2225
"""Sub-client for manipulating a single dataset."""
@@ -34,7 +37,7 @@ def get(self) -> dict | None:
3437
Returns:
3538
The retrieved dataset, or None, if it does not exist.
3639
"""
37-
return self._get()
40+
return self._get(timeout_secs=_SMALL_TIMEOUT)
3841

3942
def update(self, *, name: str | None = None) -> dict:
4043
"""Update the dataset with specified fields.
@@ -49,14 +52,14 @@ def update(self, *, name: str | None = None) -> dict:
4952
"""
5053
updated_fields = {'name': name}
5154

52-
return self._update(filter_out_none_values_recursively(updated_fields))
55+
return self._update(filter_out_none_values_recursively(updated_fields), timeout_secs=_SMALL_TIMEOUT)
5356

5457
def delete(self) -> None:
5558
"""Delete the dataset.
5659
5760
https://docs.apify.com/api/v2#/reference/datasets/dataset/delete-dataset
5861
"""
59-
return self._delete()
62+
return self._delete(timeout_secs=_SMALL_TIMEOUT)
6063

6164
def list_items(
6265
self,
@@ -539,6 +542,7 @@ def push_items(self, items: JSONSerializable) -> None:
539542
params=self._params(),
540543
data=data,
541544
json=json,
545+
timeout_secs=_MEDIUM_TIMEOUT,
542546
)
543547

544548
def get_statistics(self) -> dict | None:
@@ -554,6 +558,7 @@ def get_statistics(self) -> dict | None:
554558
url=self._url('statistics'),
555559
method='GET',
556560
params=self._params(),
561+
timeout_secs=_SMALL_TIMEOUT,
557562
)
558563
return pluck_data(response.json())
559564
except ApifyApiError as exc:
@@ -578,7 +583,7 @@ async def get(self) -> dict | None:
578583
Returns:
579584
The retrieved dataset, or None, if it does not exist.
580585
"""
581-
return await self._get()
586+
return await self._get(timeout_secs=_SMALL_TIMEOUT)
582587

583588
async def update(self, *, name: str | None = None) -> dict:
584589
"""Update the dataset with specified fields.
@@ -593,14 +598,14 @@ async def update(self, *, name: str | None = None) -> dict:
593598
"""
594599
updated_fields = {'name': name}
595600

596-
return await self._update(filter_out_none_values_recursively(updated_fields))
601+
return await self._update(filter_out_none_values_recursively(updated_fields), timeout_secs=_SMALL_TIMEOUT)
597602

598603
async def delete(self) -> None:
599604
"""Delete the dataset.
600605
601606
https://docs.apify.com/api/v2#/reference/datasets/dataset/delete-dataset
602607
"""
603-
return await self._delete()
608+
return await self._delete(timeout_secs=_SMALL_TIMEOUT)
604609

605610
async def list_items(
606611
self,
@@ -990,6 +995,7 @@ async def push_items(self, items: JSONSerializable) -> None:
990995
params=self._params(),
991996
data=data,
992997
json=json,
998+
timeout_secs=_MEDIUM_TIMEOUT,
993999
)
9941000

9951001
async def get_statistics(self) -> dict | None:
@@ -1005,6 +1011,7 @@ async def get_statistics(self) -> dict | None:
10051011
url=self._url('statistics'),
10061012
method='GET',
10071013
params=self._params(),
1014+
timeout_secs=_SMALL_TIMEOUT,
10081015
)
10091016
return pluck_data(response.json())
10101017
except ApifyApiError as exc:

src/apify_client/clients/resource_clients/key_value_store.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
if TYPE_CHECKING:
1414
from collections.abc import AsyncIterator, Iterator
1515

16+
_SMALL_TIMEOUT = 5 # For fast and common actions. Suitable for idempotent actions.
17+
_MEDIUM_TIMEOUT = 30 # For actions that may take longer.
18+
1619

1720
class KeyValueStoreClient(ResourceClient):
1821
"""Sub-client for manipulating a single key-value store."""
@@ -30,7 +33,7 @@ def get(self) -> dict | None:
3033
Returns:
3134
The retrieved key-value store, or None if it does not exist.
3235
"""
33-
return self._get()
36+
return self._get(timeout_secs=_SMALL_TIMEOUT)
3437

3538
def update(self, *, name: str | None = None) -> dict:
3639
"""Update the key-value store with specified fields.
@@ -54,7 +57,7 @@ def delete(self) -> None:
5457
5558
https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/delete-store
5659
"""
57-
return self._delete()
60+
return self._delete(timeout_secs=_SMALL_TIMEOUT)
5861

5962
def list_keys(self, *, limit: int | None = None, exclusive_start_key: str | None = None) -> dict:
6063
"""List the keys in the key-value store.
@@ -74,6 +77,7 @@ def list_keys(self, *, limit: int | None = None, exclusive_start_key: str | None
7477
url=self._url('keys'),
7578
method='GET',
7679
params=request_params,
80+
timeout_secs=_MEDIUM_TIMEOUT,
7781
)
7882

7983
return parse_date_fields(pluck_data(response.json()))
@@ -236,6 +240,7 @@ def delete_record(self, key: str) -> None:
236240
url=self._url(f'records/{key}'),
237241
method='DELETE',
238242
params=self._params(),
243+
timeout_secs=_SMALL_TIMEOUT,
239244
)
240245

241246

@@ -255,7 +260,7 @@ async def get(self) -> dict | None:
255260
Returns:
256261
The retrieved key-value store, or None if it does not exist.
257262
"""
258-
return await self._get()
263+
return await self._get(timeout_secs=_SMALL_TIMEOUT)
259264

260265
async def update(self, *, name: str | None = None) -> dict:
261266
"""Update the key-value store with specified fields.
@@ -279,7 +284,7 @@ async def delete(self) -> None:
279284
280285
https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/delete-store
281286
"""
282-
return await self._delete()
287+
return await self._delete(timeout_secs=_SMALL_TIMEOUT)
283288

284289
async def list_keys(self, *, limit: int | None = None, exclusive_start_key: str | None = None) -> dict:
285290
"""List the keys in the key-value store.
@@ -299,6 +304,7 @@ async def list_keys(self, *, limit: int | None = None, exclusive_start_key: str
299304
url=self._url('keys'),
300305
method='GET',
301306
params=request_params,
307+
timeout_secs=_MEDIUM_TIMEOUT,
302308
)
303309

304310
return parse_date_fields(pluck_data(response.json()))
@@ -440,4 +446,5 @@ async def delete_record(self, key: str) -> None:
440446
url=self._url(f'records/{key}'),
441447
method='DELETE',
442448
params=self._params(),
449+
timeout_secs=_SMALL_TIMEOUT,
443450
)

0 commit comments

Comments
 (0)