Skip to content

Commit 911d477

Browse files
authored
feat: Add HTTPX-based HTTP client
1 parent 5196b25 commit 911d477

17 files changed

Lines changed: 668 additions & 58 deletions

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@
5757
uv add "apify-client[brotli]"
5858
```
5959

60+
[Impit](https://github.com/apify/impit) is the default HTTP client and is installed automatically. To use the
61+
built-in [HTTPX](https://www.python-httpx.org/) client instead, install its optional extra:
62+
63+
```bash
64+
pip install "apify-client[httpx]"
65+
# or
66+
uv add "apify-client[httpx]"
67+
```
68+
6069
- From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/):
6170

6271
```bash
@@ -124,7 +133,7 @@ For a guided walkthrough — authenticating, running an Actor, and reading its r
124133
- **Tiered timeouts** — short / medium / long tiers picked per endpoint, overridable per call ([Timeouts](https://docs.apify.com/api/client/python/docs/concepts/timeouts)).
125134
- **Pagination and streaming** — iterate datasets, key-value store keys, or live logs without manual paging or buffering ([Pagination](https://docs.apify.com/api/client/python/docs/concepts/pagination), [Streaming](https://docs.apify.com/api/client/python/docs/concepts/streaming-resources)).
126135
- **Convenience methods**`call()`, `wait_for_finish()`, nested resource access, and other shortcuts that hide platform quirks ([Convenience methods](https://docs.apify.com/api/client/python/docs/concepts/convenience-methods)).
127-
- **Pluggable HTTP layer**swap the default [Impit](https://github.com/apify/impit)-based HTTP client for `httpx`, `requests`, `aiohttp`, or any custom implementation ([Custom HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)).
136+
- **Pluggable HTTP layer**use the default [Impit](https://github.com/apify/impit)-based client, opt in to the built-in [HTTPX](https://www.python-httpx.org/) client, or plug in any custom implementation ([Custom HTTP clients](https://docs.apify.com/api/client/python/docs/concepts/custom-http-clients)).
128137
- **Structured errors** — every API error surfaces as an [`ApifyApiError`](https://docs.apify.com/api/client/python/reference/class/ApifyApiError) with HTTP-specific subclasses for precise handling ([Error handling](https://docs.apify.com/api/client/python/docs/concepts/error-handling)).
129138
- **Debug logging** — opt-in structured logging on the `apify_client` logger captures request URLs, status codes, retry attempts, and more ([Logging](https://docs.apify.com/api/client/python/docs/concepts/logging)).
130139

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dependencies = [
3434

3535
[project.optional-dependencies]
3636
brotli = ["brotli>=1.0.9"]
37+
httpx = ["httpx>=0.27.0,<1.0.0"]
3738

3839
[project.urls]
3940
"Apify Homepage" = "https://apify.com"
Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,35 @@
1+
from apify_client._utils.try_import import install_import_hook as _install_import_hook
2+
from apify_client._utils.try_import import try_import as _try_import
13
from apify_client.http_clients._base import HttpClient, HttpClientAsync, HttpResponse
24
from apify_client.http_clients._impit import ImpitHttpClient, ImpitHttpClientAsync
35

4-
__all__ = [
5-
'HttpClient',
6-
'HttpClientAsync',
7-
'HttpResponse',
8-
'ImpitHttpClient',
9-
'ImpitHttpClientAsync',
10-
]
6+
_install_import_hook(__name__)
7+
8+
# `httpx` is an optional extra, so it's wrapped in try_import. Accessing the HTTPX clients
9+
# without the extra installed raises a clear ImportError instead of failing at package import time.
10+
with _try_import(
11+
__name__,
12+
'HttpxHttpClient',
13+
'HttpxHttpClientAsync',
14+
dependency_name='httpx',
15+
) as _httpx_import:
16+
from apify_client.http_clients._httpx import HttpxHttpClient, HttpxHttpClientAsync
17+
18+
if _httpx_import.available:
19+
__all__ = [
20+
'HttpClient',
21+
'HttpClientAsync',
22+
'HttpResponse',
23+
'HttpxHttpClient',
24+
'HttpxHttpClientAsync',
25+
'ImpitHttpClient',
26+
'ImpitHttpClientAsync',
27+
]
28+
else:
29+
__all__ = [
30+
'HttpClient',
31+
'HttpClientAsync',
32+
'HttpResponse',
33+
'ImpitHttpClient',
34+
'ImpitHttpClientAsync',
35+
]
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import httpx
6+
from typing_extensions import override
7+
8+
from apify_client._consts import (
9+
DEFAULT_MAX_RETRIES,
10+
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
11+
DEFAULT_TIMEOUT_LONG,
12+
DEFAULT_TIMEOUT_MAX,
13+
DEFAULT_TIMEOUT_MEDIUM,
14+
DEFAULT_TIMEOUT_SHORT,
15+
)
16+
from apify_client._docs import docs_group
17+
from apify_client.http_clients._base import HttpClient, HttpClientAsync
18+
19+
if TYPE_CHECKING:
20+
from datetime import timedelta
21+
22+
from apify_client._statistics import ClientStatistics
23+
from apify_client.http_compressors._base import HttpCompressor
24+
25+
26+
_PERMANENT_ERRORS = (
27+
# A request HTTPX rejects before sending it, e.g. one carrying an invalid header value.
28+
httpx.LocalProtocolError,
29+
# A URL scheme HTTPX refuses to speak, which repeating the request cannot change.
30+
httpx.UnsupportedProtocol,
31+
# An over-long redirect chain is a routing loop, which repeating the request cannot break.
32+
httpx.TooManyRedirects,
33+
# Only `Response.raise_for_status()` raises this, and the client never calls it - the shared pipeline decides on
34+
# status codes from the response itself.
35+
httpx.HTTPStatusError,
36+
)
37+
"""HTTPX errors that a retry cannot fix. Everything else in the `httpx.HTTPError` tree counts as transient."""
38+
39+
40+
@docs_group('HTTP clients')
41+
class HttpxHttpClient(HttpClient):
42+
"""Synchronous HTTP client for the Apify API built on top of [HTTPX](https://www.python-httpx.org/).
43+
44+
This client wraps `httpx.Client` and adds automatic retries with exponential backoff for rate-limited
45+
(HTTP 429) and server error (HTTP 5xx) responses.
46+
47+
Requires the `httpx` extra: `pip install "apify-client[httpx]"`.
48+
"""
49+
50+
def __init__(
51+
self,
52+
*,
53+
token: str | None = None,
54+
timeout_short: timedelta = DEFAULT_TIMEOUT_SHORT,
55+
timeout_medium: timedelta = DEFAULT_TIMEOUT_MEDIUM,
56+
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
57+
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
58+
max_retries: int = DEFAULT_MAX_RETRIES,
59+
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
60+
statistics: ClientStatistics | None = None,
61+
headers: dict[str, str] | None = None,
62+
http_compressor: HttpCompressor | None = None,
63+
) -> None:
64+
"""Initialize the HTTPX-based synchronous HTTP client.
65+
66+
Args:
67+
token: Apify API token for authentication.
68+
timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...).
69+
timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...).
70+
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
71+
timeout_max: Maximum timeout cap for any single request attempt, including tier and per-call timeouts.
72+
max_retries: Maximum number of retry attempts for failed requests.
73+
min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt).
74+
statistics: Statistics tracker for API calls. Created automatically if not provided.
75+
headers: Additional HTTP headers to include in all requests.
76+
http_compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`.
77+
"""
78+
super().__init__(
79+
token=token,
80+
timeout_short=timeout_short,
81+
timeout_medium=timeout_medium,
82+
timeout_long=timeout_long,
83+
timeout_max=timeout_max,
84+
max_retries=max_retries,
85+
min_delay_between_retries=min_delay_between_retries,
86+
statistics=statistics,
87+
headers=headers,
88+
http_compressor=http_compressor,
89+
)
90+
91+
self._httpx_client = httpx.Client(
92+
follow_redirects=True,
93+
event_hooks={'response': [self._clear_response_cookies]},
94+
)
95+
96+
@override
97+
def is_timeout_error(self, exc: Exception) -> bool:
98+
return super().is_timeout_error(exc) or isinstance(exc, httpx.TimeoutException)
99+
100+
@override
101+
def is_retryable_transport_error(self, exc: Exception) -> bool:
102+
# Every error from HTTPX's own hierarchy counts as transient except the permanently-failing types listed in
103+
# `_PERMANENT_ERRORS`. Retrying is the default because HTTPX also reports genuinely transient failures
104+
# through its generic base class. HTTP status code errors are handled by the shared pipeline based on the
105+
# response status code, not here.
106+
return isinstance(exc, httpx.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
107+
108+
@override
109+
def close(self) -> None:
110+
"""Close the underlying HTTPX connection pool."""
111+
self._httpx_client.close()
112+
113+
def _clear_response_cookies(self, _response: httpx.Response) -> None:
114+
"""Prevent HTTPX's shared cookie jar from leaking server cookies into later API requests."""
115+
self._httpx_client.cookies.clear()
116+
117+
@override
118+
def send_request(
119+
self,
120+
*,
121+
method: str,
122+
url: str,
123+
headers: dict[str, str],
124+
content: bytes | None,
125+
timeout: float | None,
126+
stream: bool,
127+
) -> httpx.Response:
128+
request = self._httpx_client.build_request(
129+
method=method,
130+
url=url,
131+
headers=headers,
132+
content=content,
133+
timeout=timeout,
134+
)
135+
_restore_explicit_cookie_header(request, headers)
136+
return self._httpx_client.send(request, stream=stream)
137+
138+
139+
@docs_group('HTTP clients')
140+
class HttpxHttpClientAsync(HttpClientAsync):
141+
"""Asynchronous HTTP client for the Apify API built on top of [HTTPX](https://www.python-httpx.org/).
142+
143+
This client wraps `httpx.AsyncClient` and adds automatic retries with exponential backoff for rate-limited
144+
(HTTP 429) and server error (HTTP 5xx) responses.
145+
146+
Requires the `httpx` extra: `pip install "apify-client[httpx]"`.
147+
"""
148+
149+
def __init__(
150+
self,
151+
*,
152+
token: str | None = None,
153+
timeout_short: timedelta = DEFAULT_TIMEOUT_SHORT,
154+
timeout_medium: timedelta = DEFAULT_TIMEOUT_MEDIUM,
155+
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
156+
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
157+
max_retries: int = DEFAULT_MAX_RETRIES,
158+
min_delay_between_retries: timedelta = DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
159+
statistics: ClientStatistics | None = None,
160+
headers: dict[str, str] | None = None,
161+
http_compressor: HttpCompressor | None = None,
162+
) -> None:
163+
"""Initialize the HTTPX-based asynchronous HTTP client.
164+
165+
Args:
166+
token: Apify API token for authentication.
167+
timeout_short: Default timeout for short-duration API operations (simple CRUD operations, ...).
168+
timeout_medium: Default timeout for medium-duration API operations (batch operations, listing, ...).
169+
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
170+
timeout_max: Maximum timeout cap for any single request attempt, including tier and per-call timeouts.
171+
max_retries: Maximum number of retry attempts for failed requests.
172+
min_delay_between_retries: Minimum delay between retries (increases exponentially with each attempt).
173+
statistics: Statistics tracker for API calls. Created automatically if not provided.
174+
headers: Additional HTTP headers to include in all requests.
175+
http_compressor: Compressor used to compress request bodies. Defaults to `GzipHttpCompressor`.
176+
"""
177+
super().__init__(
178+
token=token,
179+
timeout_short=timeout_short,
180+
timeout_medium=timeout_medium,
181+
timeout_long=timeout_long,
182+
timeout_max=timeout_max,
183+
max_retries=max_retries,
184+
min_delay_between_retries=min_delay_between_retries,
185+
statistics=statistics,
186+
headers=headers,
187+
http_compressor=http_compressor,
188+
)
189+
190+
self._httpx_async_client = httpx.AsyncClient(
191+
follow_redirects=True,
192+
event_hooks={'response': [self._clear_response_cookies]},
193+
)
194+
195+
@override
196+
def is_timeout_error(self, exc: Exception) -> bool:
197+
return super().is_timeout_error(exc) or isinstance(exc, httpx.TimeoutException)
198+
199+
@override
200+
def is_retryable_transport_error(self, exc: Exception) -> bool:
201+
# Every error from HTTPX's own hierarchy counts as transient except the permanently-failing types listed in
202+
# `_PERMANENT_ERRORS`. Retrying is the default because HTTPX also reports genuinely transient failures
203+
# through its generic base class. HTTP status code errors are handled by the shared pipeline based on the
204+
# response status code, not here.
205+
return isinstance(exc, httpx.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
206+
207+
@override
208+
async def aclose(self) -> None:
209+
"""Close the underlying asynchronous HTTPX connection pool."""
210+
await self._httpx_async_client.aclose()
211+
212+
async def _clear_response_cookies(self, _response: httpx.Response) -> None:
213+
"""Prevent HTTPX's shared cookie jar from leaking server cookies into later API requests."""
214+
self._httpx_async_client.cookies.clear()
215+
216+
@override
217+
async def send_request(
218+
self,
219+
*,
220+
method: str,
221+
url: str,
222+
headers: dict[str, str],
223+
content: bytes | None,
224+
timeout: float | None,
225+
stream: bool,
226+
) -> httpx.Response:
227+
request = self._httpx_async_client.build_request(
228+
method=method,
229+
url=url,
230+
headers=headers,
231+
content=content,
232+
timeout=timeout,
233+
)
234+
_restore_explicit_cookie_header(request, headers)
235+
return await self._httpx_async_client.send(request, stream=stream)
236+
237+
238+
def _restore_explicit_cookie_header(request: httpx.Request, headers: dict[str, str]) -> None:
239+
"""Keep only cookies explicitly supplied for this request, never cookies from HTTPX's shared jar."""
240+
explicit_cookie = next((value for key, value in headers.items() if key.lower() == 'cookie'), None)
241+
if explicit_cookie is None:
242+
request.headers.pop('cookie', None)
243+
else:
244+
request.headers['cookie'] = explicit_cookie

0 commit comments

Comments
 (0)