Skip to content

Commit e99cfe9

Browse files
committed
fix: Unify retry policy and keep custom HTTP clients backward compatible
1 parent d91842b commit e99cfe9

22 files changed

Lines changed: 491 additions & 267 deletions

docs/02_concepts/10_custom_http_clients.mdx

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ Responses use one separate abstraction:
111111
To plug in your custom implementation, use the <ApiLink to="class/ApifyClient#with_custom_http_client">`ApifyClient.with_custom_http_client`</ApiLink> class method.
112112

113113
The built-in Impit and HTTPX classes are thin transport adapters over the request implementation in `HttpClient` and
114-
`HttpClientAsync`. Custom transport adapters implement the abstract request, error-classification, and lifecycle hooks;
115-
they inherit request construction, retries, timeout growth, API error conversion, logging, and statistics from the base.
114+
`HttpClientAsync`. Custom transport adapters implement the request, error-classification, and lifecycle hooks. They
115+
inherit request construction, retries, timeout growth, API error conversion, logging, and statistics from the base.
116116

117117
All of these are available from the `apify_client.http_clients` module:
118118

@@ -122,12 +122,16 @@ All of these are available from the `apify_client.http_clients` module:
122122

123123
### The transport contract
124124

125-
The public `call` method provides the shared request pipeline. A concrete transport implements these abstract methods:
125+
The public `call` method provides the shared request pipeline. A concrete transport implements these hooks:
126126

127-
- `_send_request(...)` sends one prepared request and returns an `HttpResponse`.
128-
- `_is_retryable_transport_error(exc)` classifies transport failures for the shared retry loop.
129-
- `is_timeout_error(exc)` identifies transport-specific timeout exceptions for higher-level client features.
130-
- `close()` or `aclose()` closes resources owned by the transport.
127+
- `_send_request(...)` sends one prepared request and returns an `HttpResponse`. The inherited `call` needs it, so
128+
every transport adapter has to implement it.
129+
- `_is_retryable_transport_error(exc)` classifies transport failures for the shared retry loop. The default classifies
130+
nothing as retryable, so a transport that skips it gives up on the first connection failure.
131+
- `is_timeout_error(exc)` identifies transport-specific timeout exceptions for higher-level client features. The
132+
default recognizes Python's `TimeoutError`.
133+
- `close()` or `aclose()` closes resources owned by the transport. The default does nothing, which is correct for a
134+
transport that owns no pool or session.
131135

132136
The `@override` decorators in the built-in Impit and HTTPX adapters make these implementations explicit and allow type
133137
checkers to catch misspelled or incompatible overrides.
@@ -195,6 +199,6 @@ example when you need to:
195199
- **Modify requests** - Add custom fields, modify the body, or change headers.
196200
- **Collect custom metrics** - Measure request latency, track error rates, or count API calls.
197201

198-
For a complete implementation using a transport with a different response API, see
199-
[Build a custom HTTP client with AIOHTTP](/api/client/python/docs/guides/custom-http-client-httpx). You can also refer
200-
to the <ApiLink to="class/HttpClient">`HttpClient` API reference</ApiLink> for the synchronous contract.
202+
For complete synchronous and asynchronous implementations over a transport with a different response API, see
203+
[Build a custom HTTP client](../03_guides/05_custom_http_client.mdx). You can also refer to the
204+
<ApiLink to="class/HttpClient">`HttpClient` API reference</ApiLink> for the synchronous contract.

docs/02_concepts/code/10_plugging_in_async.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ async def _send_request(
3333

3434
@override
3535
def _is_retryable_transport_error(self, exc: Exception) -> bool:
36-
return False
36+
# List the transport's transient failures here, e.g. its timeout
37+
# and connection errors. Returning False for everything opts out
38+
# of transport retries entirely.
39+
return isinstance(exc, TimeoutError)
3740

3841

3942
async def main() -> None:

docs/02_concepts/code/10_plugging_in_sync.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ def _send_request(
3333

3434
@override
3535
def _is_retryable_transport_error(self, exc: Exception) -> bool:
36-
return False
36+
# List the transport's transient failures here, e.g. its timeout
37+
# and connection errors. Returning False for everything opts out
38+
# of transport retries entirely.
39+
return isinstance(exc, TimeoutError)
3740

3841

3942
def main() -> None:
Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,63 @@
11
---
2-
id: custom-http-client-httpx
3-
title: Build a custom HTTP client with AIOHTTP
4-
description: Implement the asynchronous HTTP client contract with AIOHTTP.
2+
id: custom-http-client
3+
title: Build a custom HTTP client
4+
description: Implement the HTTP client contract with AIOHTTP and requests.
55
---
66

77
import ApiLink from '@theme/ApiLink';
88
import CodeBlock from '@theme/CodeBlock';
9+
import Tabs from '@theme/Tabs';
10+
import TabItem from '@theme/TabItem';
911

10-
import CustomHttpClientExample from '!!raw-loader!./code/05_custom_http_client_async.py';
12+
import CustomHttpClientAsyncExample from '!!raw-loader!./code/05_custom_http_client_async.py';
13+
import CustomHttpClientSyncExample from '!!raw-loader!./code/05_custom_http_client_sync.py';
1114

12-
This guide implements a custom <ApiLink to="class/HttpClientAsync">`HttpClientAsync`</ApiLink> using
13-
[AIOHTTP](https://docs.aiohttp.org/). It demonstrates how to integrate a transport that does not already implement the
14-
<ApiLink to="class/HttpResponse">`HttpResponse`</ApiLink> protocol. Because AIOHTTP is asynchronous, this guide focuses
15-
on the async client contract; a synchronous custom transport would implement <ApiLink to="class/HttpClient">`HttpClient`</ApiLink>
16-
in the same role.
15+
This guide implements a custom <ApiLink to="class/HttpClientAsync">`HttpClientAsync`</ApiLink> with
16+
[AIOHTTP](https://docs.aiohttp.org/) and a custom <ApiLink to="class/HttpClient">`HttpClient`</ApiLink> with
17+
[requests](https://requests.readthedocs.io/). Neither library satisfies the
18+
<ApiLink to="class/HttpResponse">`HttpResponse`</ApiLink> protocol, so both examples also show how to adapt a
19+
foreign response API.
1720

1821
For an overview of the architecture and the built-in Impit and HTTPX implementations, see
19-
[HTTP clients](/api/client/python/docs/concepts/custom-http-clients).
22+
[HTTP clients](../02_concepts/10_custom_http_clients.mdx).
2023

2124
## Installation
2225

23-
Install AIOHTTP alongside the Apify client. AIOHTTP is only used by this custom implementation and is not an
24-
`apify-client` extra:
26+
Install the transport alongside the Apify client. Neither AIOHTTP nor requests is an `apify-client` extra:
2527

2628
```bash
27-
pip install apify-client aiohttp
28-
# or
29-
uv add apify-client aiohttp
29+
pip install apify-client aiohttp # for the asynchronous client
30+
pip install apify-client requests # for the synchronous client
3031
```
3132

3233
## Implementation
3334

34-
The example has three parts:
35-
36-
1. `AiohttpResponse` adapts AIOHTTP's response API to the <ApiLink to="class/HttpResponse">`HttpResponse`</ApiLink>
37-
protocol expected by resource clients.
38-
2. `AiohttpHttpClient` implements the abstract transport, error-classification, timeout-classification, and lifecycle
39-
hooks. It inherits request preparation, retry handling, timeout growth, and API error conversion from
40-
<ApiLink to="class/HttpClientAsync">`HttpClientAsync`</ApiLink>.
41-
3. <ApiLink to="class/ApifyClientAsync#with_custom_http_client">`ApifyClientAsync.with_custom_http_client()`</ApiLink>
42-
connects the implementation to the resource clients and applies the API token. The async context manager closes
43-
the AIOHTTP session at shutdown.
44-
45-
<CodeBlock className="language-python">
46-
{CustomHttpClientExample}
47-
</CodeBlock>
35+
Each example has three parts:
36+
37+
1. The response adapter, `AiohttpResponse` or `RequestsResponse`, maps the library's own response onto the
38+
<ApiLink to="class/HttpResponse">`HttpResponse`</ApiLink> protocol that resource clients expect.
39+
2. The client, `AiohttpHttpClient` or `RequestsHttpClient`, implements the transport, error-classification,
40+
timeout-classification, and lifecycle hooks. It inherits request preparation, retry handling, timeout growth,
41+
and API error conversion from its base class.
42+
3. `with_custom_http_client()` connects the implementation to the resource clients and applies the API token. The
43+
context manager closes the session at shutdown.
44+
45+
<Tabs>
46+
<TabItem value="AsyncExample" label="Async client" default>
47+
<CodeBlock className="language-python">
48+
{CustomHttpClientAsyncExample}
49+
</CodeBlock>
50+
</TabItem>
51+
<TabItem value="SyncExample" label="Sync client">
52+
<CodeBlock className="language-python">
53+
{CustomHttpClientSyncExample}
54+
</CodeBlock>
55+
</TabItem>
56+
</Tabs>
4857

4958
:::warning
50-
This is a compact integration example, not a replacement for all built-in client behavior. A production custom client
51-
should account for transport-specific details such as proxy configuration, TLS settings, redirects, and response
52-
resource cleanup. The shared base provides retries, logging, statistics, timeout growth, and API error conversion.
59+
These are compact integration examples, not a replacement for all built-in client behavior. A production custom
60+
client should account for transport-specific details such as proxy configuration, TLS settings, redirects, and
61+
response resource cleanup. The shared base provides retries, logging, statistics, timeout growth, and API error
62+
conversion.
5363
:::
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
import json as jsonlib
4+
from typing import TYPE_CHECKING, Any
5+
6+
import requests
7+
from typing_extensions import override
8+
9+
from apify_client import ApifyClient
10+
from apify_client.http_clients import HttpClient, HttpResponse
11+
12+
if TYPE_CHECKING:
13+
from collections.abc import AsyncIterator, Iterator, Mapping
14+
15+
TOKEN = 'MY-APIFY-TOKEN'
16+
17+
18+
class RequestsResponse:
19+
"""Adapt a requests response to the Apify client's HttpResponse protocol."""
20+
21+
def __init__(self, response: requests.Response) -> None:
22+
self._response = response
23+
self._body: bytes | None = None
24+
25+
@property
26+
def status_code(self) -> int:
27+
return self._response.status_code
28+
29+
@property
30+
def headers(self) -> Mapping[str, str]:
31+
return self._response.headers
32+
33+
@property
34+
def content(self) -> bytes:
35+
if self._body is None:
36+
raise RuntimeError(
37+
'The streamed response has not been read yet; use read() or iter_bytes()'
38+
)
39+
return self._body
40+
41+
@property
42+
def text(self) -> str:
43+
encoding = self._response.encoding or 'utf-8'
44+
return self.content.decode(encoding, errors='replace')
45+
46+
def json(self) -> Any:
47+
return jsonlib.loads(self.text)
48+
49+
def read(self) -> bytes:
50+
if self._body is None:
51+
self._body = self._response.content
52+
return self._body
53+
54+
async def aread(self) -> bytes:
55+
return self.read()
56+
57+
def close(self) -> None:
58+
self._response.close()
59+
60+
async def aclose(self) -> None:
61+
self.close()
62+
63+
def iter_bytes(self) -> Iterator[bytes]:
64+
if self._body is not None:
65+
if self._body:
66+
yield self._body
67+
return
68+
yield from self._response.iter_content(64 * 1024)
69+
70+
async def aiter_bytes(self) -> AsyncIterator[bytes]:
71+
for chunk in self.iter_bytes():
72+
yield chunk
73+
74+
75+
class RequestsHttpClient(HttpClient):
76+
"""Minimal custom synchronous HTTP client backed by requests."""
77+
78+
@override
79+
def __init__(self) -> None:
80+
super().__init__()
81+
self._session = requests.Session()
82+
83+
@override
84+
def is_timeout_error(self, exc: Exception) -> bool:
85+
return super().is_timeout_error(exc) or isinstance(exc, requests.Timeout)
86+
87+
@override
88+
def close(self) -> None:
89+
self._session.close()
90+
91+
@override
92+
def _send_request(
93+
self,
94+
*,
95+
method: str,
96+
url: str,
97+
headers: dict[str, str],
98+
content: bytes | None,
99+
timeout: float | None,
100+
stream: bool,
101+
) -> HttpResponse:
102+
response = self._session.request(
103+
method=method,
104+
url=url,
105+
headers=headers,
106+
data=content,
107+
timeout=timeout,
108+
stream=stream,
109+
)
110+
adapted_response = RequestsResponse(response)
111+
112+
if not stream:
113+
adapted_response.read()
114+
115+
return adapted_response
116+
117+
@override
118+
def _is_retryable_transport_error(self, exc: Exception) -> bool:
119+
return isinstance(
120+
exc,
121+
(
122+
requests.ConnectionError,
123+
requests.Timeout,
124+
requests.exceptions.ChunkedEncodingError,
125+
),
126+
)
127+
128+
129+
def main() -> None:
130+
with RequestsHttpClient() as http_client:
131+
client = ApifyClient.with_custom_http_client(
132+
token=TOKEN,
133+
http_client=http_client,
134+
)
135+
actor = client.actor('apify/hello-world').get()
136+
print(actor)
137+
138+
139+
if __name__ == '__main__':
140+
main()

src/apify_client/_resource_clients/log.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ def __init__(
3434
**kwargs,
3535
)
3636

37-
def is_timeout_error(self, exc: Exception) -> bool:
37+
def _is_timeout_error(self, exc: Exception) -> bool:
3838
"""Return whether an exception came from the configured HTTP client's timeout handling."""
3939
return self._http_client.is_timeout_error(exc)
4040

@@ -146,7 +146,7 @@ def __init__(
146146
**kwargs,
147147
)
148148

149-
def is_timeout_error(self, exc: Exception) -> bool:
149+
def _is_timeout_error(self, exc: Exception) -> bool:
150150
"""Return whether an exception came from the configured HTTP client's timeout handling."""
151151
return self._http_client.is_timeout_error(exc)
152152

src/apify_client/_streamed_log.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def _stream_log(self) -> None:
160160
# Flush the last buffered part even if the read timed out or was stopped.
161161
self._log_buffer_content(include_last_part=True)
162162
except Exception as exc:
163-
if self._log_client.is_timeout_error(exc):
163+
if self._log_client._is_timeout_error(exc): # noqa: SLF001
164164
# The stream cannot continue, so warn and let the thread end instead of leaking a traceback (#1040).
165165
self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
166166
else:
@@ -239,7 +239,7 @@ async def _stream_log(self) -> None:
239239
# Flush the last buffered part even if the task is cancelled by `stop()`.
240240
self._log_buffer_content(include_last_part=True)
241241
except Exception as exc:
242-
if self._log_client.is_timeout_error(exc):
242+
if self._log_client._is_timeout_error(exc): # noqa: SLF001
243243
# A timeout on the long-lived stream is an expected terminal condition, not an error.
244244
self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
245245
else:

src/apify_client/_utils/errors.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22

33
from typing import TYPE_CHECKING
44

5-
import impit
6-
7-
from apify_client.errors import InvalidResponseBodyError, NotFoundError
5+
from apify_client.errors import NotFoundError
86

97
if TYPE_CHECKING:
108
from apify_client.errors import ApifyApiError
@@ -33,19 +31,3 @@ def catch_not_found_for_resource_or_throw(exc: ApifyApiError, resource_id: str |
3331
if resource_id is None:
3432
raise exc
3533
catch_not_found_or_throw(exc)
36-
37-
38-
def is_retryable_error(exc: Exception) -> bool:
39-
"""Check if the given error is retryable.
40-
41-
All `impit.HTTPError` subclasses are considered retryable because they represent transport-level failures
42-
(network issues, timeouts, protocol errors, body decoding errors) that are typically transient. HTTP status
43-
code errors are handled separately in `_make_request` based on the response status code, not here.
44-
"""
45-
return isinstance(
46-
exc,
47-
(
48-
InvalidResponseBodyError,
49-
impit.HTTPError,
50-
),
51-
)

0 commit comments

Comments
 (0)