Skip to content

Commit eaeb849

Browse files
committed
refactor: Deduplicate the response-status retry policy into the HTTP client base
1 parent f8c34a8 commit eaeb849

7 files changed

Lines changed: 105 additions & 66 deletions

File tree

‎src/apify_client/_apify_client.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,13 @@ def with_custom_http_client(
233233
234234
```python
235235
from apify_client import ApifyClient
236-
from my_http_client import MyHttpClient
236+
from apify_client.http_clients import HttpClient, HttpResponse
237+
238+
239+
class MyHttpClient(HttpClient):
240+
def send_request(self, *, method, url, headers, content, timeout, stream) -> HttpResponse:
241+
...
242+
237243
238244
client = ApifyClient.with_custom_http_client(
239245
token='MY-APIFY-TOKEN',
@@ -591,7 +597,13 @@ def with_custom_http_client(
591597
592598
```python
593599
from apify_client import ApifyClientAsync
594-
from my_http_client import MyHttpClientAsync
600+
from apify_client.http_clients import HttpClientAsync, HttpResponse
601+
602+
603+
class MyHttpClientAsync(HttpClientAsync):
604+
async def send_request(self, *, method, url, headers, content, timeout, stream) -> HttpResponse:
605+
...
606+
595607
596608
client = ApifyClientAsync.with_custom_http_client(
597609
token='MY-APIFY-TOKEN',

‎src/apify_client/http_clients/_base.py‎

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,11 @@ def aiter_bytes(self) -> AsyncIterator[bytes]:
101101
class HttpClientBase:
102102
"""Shared configuration and utilities for HTTP clients.
103103
104-
Provides common functionality for both sync and async HTTP clients including:
105-
header construction, parameter parsing, request body preparation, URL building,
106-
and timeout calculation.
104+
Provides common functionality for both sync and async HTTP clients including: header construction, parameter
105+
parsing, request body preparation, URL building, timeout calculation, and error classification.
107106
108-
Subclasses should call `super().__init__()` to initialize shared configuration.
109-
The helper methods are then available for use in the `call()` implementation.
107+
Subclasses should call `super().__init__()` to initialize shared configuration. The helpers are then used by the
108+
inherited `call`, and stay available to a client that replaces it.
110109
"""
111110

112111
def __init__(
@@ -365,15 +364,44 @@ def _handle_request_exception(self, exc: Exception, *, stop_retrying: Callable[[
365364
logger.debug('Exception is not retryable', exc_info=exc)
366365
stop_retrying()
367366

367+
def _handle_response_status(
368+
self,
369+
response: HttpResponse,
370+
*,
371+
attempt: int,
372+
stop_retrying: Callable[[], None],
373+
) -> bool:
374+
"""Record the response status and stop retrying unless it is a server error or a rate limit.
375+
376+
Returns whether the response is a success, so the caller can hand it back instead of raising.
377+
"""
378+
if response.status_code < HTTPStatus.MULTIPLE_CHOICES:
379+
logger.debug('Request successful', extra={'status_code': response.status_code})
380+
return True
381+
382+
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
383+
self._statistics.add_rate_limit_error(attempt)
384+
385+
logger.debug('Request unsuccessful', extra={'status_code': response.status_code})
386+
if (
387+
response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR
388+
and response.status_code != HTTPStatus.TOO_MANY_REQUESTS
389+
):
390+
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
391+
stop_retrying()
392+
393+
return False
394+
368395

369396
@docs_group('HTTP clients')
370397
class HttpClient(HttpClientBase):
371398
"""Base class for synchronous HTTP clients used by `ApifyClient`.
372399
373400
Concrete clients inherit its request preparation, retry, and error handling, and implement the transport,
374401
error-classification, and lifecycle hooks. Only `send_request` has to be implemented, the other hooks have
375-
defaults. Replacing `call` itself also remains supported, and then none of the hooks are used. Helper methods
376-
from `HttpClientBase` remain available for request preparation, URL building, and parameter parsing.
402+
defaults. Replacing `call` itself also remains supported, and then the transport and retry-classification hooks
403+
are bypassed. Helper methods from `HttpClientBase` remain available for request preparation, URL building, and
404+
parameter parsing.
377405
378406
The client's default headers from `self._headers` have to go out with every request, otherwise the
379407
`Authorization` header never reaches the API. The inherited `call` merges them into the per-request headers
@@ -556,21 +584,9 @@ def _make_request(
556584
self._handle_request_exception(exc, stop_retrying=stop_retrying)
557585
raise
558586

559-
if response.status_code < HTTPStatus.MULTIPLE_CHOICES:
560-
logger.debug('Request successful', extra={'status_code': response.status_code})
587+
if self._handle_response_status(response, attempt=attempt, stop_retrying=stop_retrying):
561588
return response
562589

563-
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
564-
self._statistics.add_rate_limit_error(attempt)
565-
566-
logger.debug('Request unsuccessful', extra={'status_code': response.status_code})
567-
if (
568-
response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR
569-
and response.status_code != HTTPStatus.TOO_MANY_REQUESTS
570-
):
571-
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
572-
stop_retrying()
573-
574590
# Read the response in case it is a stream, so the error can be raised properly.
575591
response.read()
576592
raise ApifyApiError(response, attempt, method=method)
@@ -773,21 +789,9 @@ async def _make_request(
773789
self._handle_request_exception(exc, stop_retrying=stop_retrying)
774790
raise
775791

776-
if response.status_code < HTTPStatus.MULTIPLE_CHOICES:
777-
logger.debug('Request successful', extra={'status_code': response.status_code})
792+
if self._handle_response_status(response, attempt=attempt, stop_retrying=stop_retrying):
778793
return response
779794

780-
if response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
781-
self._statistics.add_rate_limit_error(attempt)
782-
783-
logger.debug('Request unsuccessful', extra={'status_code': response.status_code})
784-
if (
785-
response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR
786-
and response.status_code != HTTPStatus.TOO_MANY_REQUESTS
787-
):
788-
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
789-
stop_retrying()
790-
791795
# Read the response in case it is a stream, so the error can be raised properly.
792796
await response.aread()
793797
raise ApifyApiError(response, attempt, method=method)

‎src/apify_client/http_clients/_impit.py‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ def __init__(
7979
def is_timeout_error(self, exc: Exception) -> bool:
8080
return super().is_timeout_error(exc) or isinstance(exc, impit.TimeoutException)
8181

82+
@override
83+
def is_retryable_transport_error(self, exc: Exception) -> bool:
84+
# All errors from Impit's own hierarchy count as transient - they represent transport-level failures
85+
# (network issues, timeouts, protocol errors, body decoding errors) that are typically transient.
86+
return isinstance(exc, impit.HTTPError)
87+
8288
@override
8389
def close(self) -> None:
8490
"""Release resources owned by this client.
@@ -113,12 +119,6 @@ def send_request(
113119
stream=stream,
114120
)
115121

116-
@override
117-
def is_retryable_transport_error(self, exc: Exception) -> bool:
118-
# All errors from Impit's own hierarchy count as transient - they represent transport-level failures
119-
# (network issues, timeouts, protocol errors, body decoding errors) that are typically transient.
120-
return isinstance(exc, impit.HTTPError)
121-
122122

123123
@docs_group('HTTP clients')
124124
class ImpitHttpClientAsync(HttpClientAsync):
@@ -176,6 +176,12 @@ def __init__(
176176
def is_timeout_error(self, exc: Exception) -> bool:
177177
return super().is_timeout_error(exc) or isinstance(exc, impit.TimeoutException)
178178

179+
@override
180+
def is_retryable_transport_error(self, exc: Exception) -> bool:
181+
# All errors from Impit's own hierarchy count as transient - they represent transport-level failures
182+
# (network issues, timeouts, protocol errors, body decoding errors) that are typically transient.
183+
return isinstance(exc, impit.HTTPError)
184+
179185
@override
180186
async def aclose(self) -> None:
181187
"""Release resources owned by this client.
@@ -205,9 +211,3 @@ async def send_request(
205211
timeout=impit_timeout,
206212
stream=stream,
207213
)
208-
209-
@override
210-
def is_retryable_transport_error(self, exc: Exception) -> bool:
211-
# All errors from Impit's own hierarchy count as transient - they represent transport-level failures
212-
# (network issues, timeouts, protocol errors, body decoding errors) that are typically transient.
213-
return isinstance(exc, impit.HTTPError)

‎tests/unit/test_client_timeouts.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async def test_timeout_resolves_for_async_clients(
9393

9494

9595
def test_compute_timeout_with_timedelta(http_client_class: type[HttpClient]) -> None:
96-
"""Concrete timedeltas double per attempt and are capped at the configured maximum."""
96+
"""Concrete timedeltas double per attempt, are capped at the maximum, and `no_timeout` stays unbounded."""
9797
client = http_client_class(timeout_max=timedelta(seconds=20))
9898

9999
assert client._compute_timeout(timedelta(seconds=5), attempt=1) == 5.0

‎tests/unit/test_http_clients.py‎

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ async def test_call_only_http_client_async_keeps_working() -> None:
7272
assert not client.is_retryable_transport_error(TimeoutError('test'))
7373

7474

75-
def test_retry_with_exp_backoff(http_client_class: type[HttpClient]) -> None:
75+
def test_retry_with_exp_backoff() -> None:
7676
attempt_counter = 0
7777

7878
class RetryableError(Exception):
@@ -101,7 +101,7 @@ def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any:
101101

102102
# Returns the correct result after the correct time (should take 100 + 200 + 400 + 800 = 1500 ms)
103103
start = time.time()
104-
result = http_client_class._retry_with_exp_backoff(
104+
result = HttpClient._retry_with_exp_backoff(
105105
returns_on_fifth_attempt, backoff_base=timedelta(milliseconds=100), backoff_factor=2, random_factor=0
106106
)
107107
elapsed_time_seconds = time.time() - start
@@ -113,21 +113,19 @@ def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any:
113113
# Stops retrying when failed for max_retries times
114114
attempt_counter = 0
115115
with pytest.raises(RetryableError):
116-
http_client_class._retry_with_exp_backoff(
116+
HttpClient._retry_with_exp_backoff(
117117
returns_on_fifth_attempt, max_retries=3, backoff_base=timedelta(milliseconds=1)
118118
)
119119
assert attempt_counter == 4
120120

121121
# Bails when the bail function is called
122122
attempt_counter = 0
123123
with pytest.raises(NonRetryableError):
124-
http_client_class._retry_with_exp_backoff(bails_on_third_attempt, backoff_base=timedelta(milliseconds=1))
124+
HttpClient._retry_with_exp_backoff(bails_on_third_attempt, backoff_base=timedelta(milliseconds=1))
125125
assert attempt_counter == 3
126126

127127

128-
async def test_retry_with_exp_backoff_async(
129-
http_client_async_class: type[HttpClientAsync],
130-
) -> None:
128+
async def test_retry_with_exp_backoff_async() -> None:
131129
attempt_counter = 0
132130

133131
class RetryableError(Exception):
@@ -156,7 +154,7 @@ async def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any:
156154

157155
# Returns the correct result after the correct time (should take 100 + 200 + 400 + 800 = 1500 ms)
158156
start = time.time()
159-
result = await http_client_async_class._retry_with_exp_backoff(
157+
result = await HttpClientAsync._retry_with_exp_backoff(
160158
returns_on_fifth_attempt, backoff_base=timedelta(milliseconds=100), backoff_factor=2, random_factor=0
161159
)
162160
elapsed_time_seconds = time.time() - start
@@ -168,17 +166,15 @@ async def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any:
168166
# Stops retrying when failed for max_retries times
169167
attempt_counter = 0
170168
with pytest.raises(RetryableError):
171-
await http_client_async_class._retry_with_exp_backoff(
169+
await HttpClientAsync._retry_with_exp_backoff(
172170
returns_on_fifth_attempt, max_retries=3, backoff_base=timedelta(milliseconds=1)
173171
)
174172
assert attempt_counter == 4
175173

176174
# Bails when the bail function is called
177175
attempt_counter = 0
178176
with pytest.raises(NonRetryableError):
179-
await http_client_async_class._retry_with_exp_backoff(
180-
bails_on_third_attempt, backoff_base=timedelta(milliseconds=1)
181-
)
177+
await HttpClientAsync._retry_with_exp_backoff(bails_on_third_attempt, backoff_base=timedelta(milliseconds=1))
182178
assert attempt_counter == 3
183179

184180

@@ -253,15 +249,15 @@ def test_merge_headers(
253249

254250

255251
def test_http_client_creates_sync_impit_client() -> None:
256-
"""The synchronous adapter creates the underlying Impit client and releases it on close."""
252+
"""The synchronous adapter creates the underlying Impit client and closes it through the close hook."""
257253
client = ImpitHttpClient(token='test_token_123')
258254

259255
assert isinstance(client._impit_client, impit.Client)
260256
client.close()
261257

262258

263259
async def test_http_client_async_creates_async_impit_client() -> None:
264-
"""The asynchronous adapter creates the underlying Impit client and releases it on close."""
260+
"""The asynchronous adapter creates the underlying Impit client and closes it through the close hook."""
265261
client = ImpitHttpClientAsync(token='test_token_123')
266262

267263
assert isinstance(client._impit_async_client, impit.AsyncClient)

‎tests/unit/test_logging.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -877,7 +877,7 @@ def test_streamed_log_sync_does_not_leak_exception_on_stream_timeout(
877877
http_client_class: type[HttpClient],
878878
monkeypatch: pytest.MonkeyPatch,
879879
) -> None:
880-
"""The streaming thread ends quietly when either transport times out while reading the log stream."""
880+
"""The streaming thread ends quietly when the transport times out while reading the log stream."""
881881
monkeypatch.setattr(StreamedLog, '_stream_timeout', timedelta(seconds=1))
882882

883883
release_server = threading.Event()
@@ -970,7 +970,7 @@ async def test_streamed_log_async_does_not_error_on_stream_timeout(
970970
http_client_async_class: type[HttpClientAsync],
971971
monkeypatch: pytest.MonkeyPatch,
972972
) -> None:
973-
"""The async streaming task treats either transport's stream timeout as an expected terminal condition."""
973+
"""The async streaming task treats a transport stream timeout as an expected terminal condition."""
974974
monkeypatch.setattr(StreamedLogAsync, '_stream_timeout', timedelta(seconds=1))
975975

976976
release_server = threading.Event()

‎tests/unit/test_pluggable_http_client.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from datetime import timedelta
77
from http.client import HTTPConnection
88
from typing import TYPE_CHECKING, Any
9+
from unittest.mock import AsyncMock, Mock
910
from urllib.parse import urlsplit
1011

1112
import impit
@@ -257,13 +258,13 @@ async def test_fake_response_async_methods() -> None:
257258

258259
def test_http_client_without_transport_fails_loudly() -> None:
259260
"""The sync base carries hook defaults, but its inherited `call` still needs a transport implementation."""
260-
with pytest.raises(NotImplementedError):
261+
with pytest.raises(NotImplementedError, match='Implement `send_request`'):
261262
HttpClient().call(method='GET', url='https://example.com')
262263

263264

264265
async def test_http_client_async_without_transport_fails_loudly() -> None:
265266
"""The async base carries hook defaults, but its inherited `call` still needs a transport implementation."""
266-
with pytest.raises(NotImplementedError):
267+
with pytest.raises(NotImplementedError, match='Implement `send_request`'):
267268
await HttpClientAsync().call(method='GET', url='https://example.com')
268269

269270

@@ -725,3 +726,29 @@ def handler(_request: Request) -> Response:
725726

726727
assert exc_info.value.status_code == 404
727728
assert request_count == 1
729+
730+
731+
def test_hooks_only_client_does_not_retry_an_unclassified_transport_error(monkeypatch: pytest.MonkeyPatch) -> None:
732+
"""The default `is_retryable_transport_error` classifies nothing, so a transport failure ends the call at once."""
733+
client = StdlibHttpClient(token='hook_token', min_delay_between_retries=timedelta(milliseconds=1))
734+
send_request = Mock(side_effect=OSError('connection reset'))
735+
monkeypatch.setattr(client, 'send_request', send_request)
736+
737+
with pytest.raises(OSError, match='connection reset'):
738+
client.call(method='GET', url='https://example.com')
739+
740+
send_request.assert_called_once()
741+
742+
743+
async def test_hooks_only_client_async_does_not_retry_an_unclassified_transport_error(
744+
monkeypatch: pytest.MonkeyPatch,
745+
) -> None:
746+
"""The asynchronous pipeline gives up on the first transport failure the client does not classify either."""
747+
client = StdlibHttpClientAsync(token='hook_token', min_delay_between_retries=timedelta(milliseconds=1))
748+
send_request = AsyncMock(side_effect=OSError('connection reset'))
749+
monkeypatch.setattr(client, 'send_request', send_request)
750+
751+
with pytest.raises(OSError, match='connection reset'):
752+
await client.call(method='GET', url='https://example.com')
753+
754+
send_request.assert_awaited_once()

0 commit comments

Comments
 (0)