Skip to content

Commit a4ae1e6

Browse files
committed
feat: Unify the request pipeline across HTTP clients
1 parent 334e131 commit a4ae1e6

14 files changed

Lines changed: 1084 additions & 897 deletions

.rules.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ Docstrings are written on sync clients and **automatically copied** to async cli
6060

6161
### HTTP Client Abstraction
6262

63-
- `HttpClient`/`HttpClientAsync` — abstract base classes in `_http_clients/_base.py`
63+
- `HttpClient`/`HttpClientAsync` — base classes in `http_clients/_base.py` holding the shared request pipeline (retries,
64+
timeouts, API errors); transports implement the `send_request`, error-classification, and lifecycle hooks
6465
- `ImpitHttpClient`/`ImpitHttpClientAsync` — default implementation (Rust-based Impit)
6566
- `HttpResponse` — Protocol (not a concrete class) for response objects
6667
- Users can plug in custom HTTP clients via `ApifyClient.with_custom_http_client()`

src/apify_client/_apify_client.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -226,19 +226,21 @@ def with_custom_http_client(
226226
"""Create an `ApifyClient` instance with a custom HTTP client.
227227
228228
Use this alternative constructor when you want to provide your own HTTP client implementation
229-
instead of the default one. The custom client is responsible for its own configuration
230-
(retries, timeouts, etc.); only the token is applied to it, as described below.
229+
instead of the default one. The custom client controls its transport configuration; the shared
230+
`HttpClient` pipeline handles request preparation, retries, timeouts, and API errors unless overridden.
231231
232232
### Usage
233233
234234
```python
235235
from apify_client import ApifyClient
236236
from apify_client.http_clients import HttpClient, HttpResponse
237237
238+
238239
class MyHttpClient(HttpClient):
239-
def call(self, *, method, url, **kwargs) -> HttpResponse:
240+
def send_request(self, *, method, url, headers, content, timeout, stream) -> HttpResponse:
240241
...
241242
243+
242244
client = ApifyClient.with_custom_http_client(
243245
token='MY-APIFY-TOKEN',
244246
http_client=MyHttpClient(),
@@ -588,22 +590,24 @@ def with_custom_http_client(
588590
"""Create an `ApifyClientAsync` instance with a custom HTTP client.
589591
590592
Use this alternative constructor when you want to provide your own HTTP client implementation
591-
instead of the default one. The custom client is responsible for its own configuration
592-
(retries, timeouts, etc.); only the token is applied to it, as described below.
593+
instead of the default one. The custom client controls its transport configuration; the shared
594+
`HttpClientAsync` pipeline handles request preparation, retries, timeouts, and API errors unless overridden.
593595
594596
### Usage
595597
596598
```python
597599
from apify_client import ApifyClientAsync
598600
from apify_client.http_clients import HttpClientAsync, HttpResponse
599601
600-
class MyHttpClient(HttpClientAsync):
601-
async def call(self, *, method, url, **kwargs) -> HttpResponse:
602+
603+
class MyHttpClientAsync(HttpClientAsync):
604+
async def send_request(self, *, method, url, headers, content, timeout, stream) -> HttpResponse:
602605
...
603606
607+
604608
client = ApifyClientAsync.with_custom_http_client(
605609
token='MY-APIFY-TOKEN',
606-
http_client=MyHttpClient(),
610+
http_client=MyHttpClientAsync(),
607611
)
608612
```
609613

src/apify_client/_streamed_log.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
from threading import Thread
1010
from typing import TYPE_CHECKING, ClassVar, Self, cast
1111

12-
import impit
13-
1412
from apify_client._docs import docs_group
1513

1614
if TYPE_CHECKING:
@@ -29,9 +27,8 @@ class StreamedLogBase:
2927
_stream_timeout: ClassVar[Timeout] = 'no_timeout'
3028
"""Timeout for the log-stream long-poll request, which stays open for the whole Actor run.
3129
32-
impit applies its `timeout` to the whole request including the streamed body, so any bounded value truncates a
33-
longer run mid-stream and raises `impit.TimeoutException` (#1040). `no_timeout` maps to impit's ~24h cap, which
34-
is effectively unbounded for real runs and mirrors the JS client.
30+
A bounded transport timeout can truncate a longer run mid-stream. `no_timeout` keeps the connection open for the
31+
duration of the run (Impit currently maps it to an effective 24-hour cap) and mirrors the JS client.
3532
"""
3633

3734
def __init__(self, to_logger: logging.Logger, *, from_start: bool = True) -> None:
@@ -162,13 +159,13 @@ def _stream_log(self) -> None:
162159
finally:
163160
# Flush the last buffered part even if the read timed out or was stopped.
164161
self._log_buffer_content(include_last_part=True)
165-
except impit.TimeoutException:
166-
# With `no_timeout` this fires only if the run outlives impit's ~24h cap or the connection stalls.
167-
# The stream cannot continue, so warn and let the thread end instead of leaking a traceback (#1040).
168-
self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
169-
except Exception:
170-
# Any other failure in log redirection must not escape the background thread; log it instead.
171-
self._to_logger.exception('Log redirection stopped due to unexpected error:')
162+
except Exception as exc:
163+
if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
164+
# The stream cannot continue, so warn and let the thread end instead of leaking a traceback.
165+
self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
166+
else:
167+
# Any other failure in log redirection must not escape the background thread; log it instead.
168+
self._to_logger.exception('Log redirection stopped due to unexpected error:')
172169

173170

174171
@docs_group('Other')
@@ -241,10 +238,10 @@ async def _stream_log(self) -> None:
241238
finally:
242239
# Flush the last buffered part even if the task is cancelled by `stop()`.
243240
self._log_buffer_content(include_last_part=True)
244-
except impit.TimeoutException:
245-
# As in `StreamedLog._stream_log`, impit's whole-request timeout on the long-lived stream is an
246-
# expected terminal condition, not an error, so log a warning and end the task instead of a traceback.
247-
self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
248-
except Exception:
249-
# Exception in log redirection should not propagate further.
250-
self._to_logger.exception('Log redirection stopped due to unexpected error:')
241+
except Exception as exc:
242+
if self._log_client._http_client.is_timeout_error(exc): # noqa: SLF001
243+
# A timeout on the long-lived stream is an expected terminal condition, not an error.
244+
self._to_logger.warning('Log streaming stopped: the log stream request timed out.')
245+
else:
246+
# Exception in log redirection should not propagate further.
247+
self._to_logger.exception('Log redirection stopped due to unexpected error:')

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-
)

src/apify_client/http_clients/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
from ._base import HttpClient, HttpClientAsync, HttpResponse
2-
from ._impit import ImpitHttpClient, ImpitHttpClientAsync
1+
from apify_client.http_clients._base import HttpClient, HttpClientAsync, HttpResponse
2+
from apify_client.http_clients._impit import ImpitHttpClient, ImpitHttpClientAsync
33

44
__all__ = [
55
'HttpClient',

0 commit comments

Comments
 (0)