Skip to content

Commit ab63eff

Browse files
authored
fix: Fail fast on transport errors a retry cannot fix (#1019)
Every `impit.HTTPError` counted as transient, so a transport failure a retry cannot fix burned the whole backoff before surfacing. The permanent classes now fail on the first attempt instead: - `LocalProtocolError` - a request Impit rejects before sending it, e.g. one carrying an invalid header value. - `TooManyRedirects` - a routing loop, which repeating the request cannot break. - `UnsupportedProtocol` - the class Impit declares for a scheme it refuses to speak, but does not raise today; an unsupported scheme arrives as `impit.InvalidURL`, outside the `impit.HTTPError` tree and non-retryable already. Listed so the classifier stays right if Impit switches over. - `HTTPStatusError` - only `Response.raise_for_status()` raises it and the client never calls it; `_make_request` decides on status codes from the response itself. Everything else in the `impit.HTTPError` tree stays retryable, including a bare `HTTPError` - Impit wraps a failure its internal HTTP library did not classify in one, e.g. a non-HTTP response or a connection reset - and a `ProxyError`, which covers a rejected CONNECT tunnel and a 407 alike, so a transient case cannot be told from a permanent one. Also drops `InvalidResponseBodyError` from the retry check. Only `key_value_store.py` raises it, once `call` has already returned, so it can never reach the classifier. Its docstring no longer claims the client retries such requests. The duplicate `is_retryable_error` in `_utils/errors.py`, which carried the old policy, is gone. Split out of #1006 and re-targeted at `master`, so the retry fixes can ship in a patch release ahead of the pipeline refactor in #1011. *✍️ Drafted by Claude Code*
1 parent 3e1d8b4 commit ab63eff

3 files changed

Lines changed: 102 additions & 27 deletions

File tree

src/apify_client/errors.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ class ServerError(ApifyApiError):
137137
class InvalidResponseBodyError(ApifyClientError):
138138
"""Error raised when a response body cannot be parsed.
139139
140-
This typically occurs when the API returns a partial or malformed JSON response, for example due to a network
141-
interruption. The client retries such requests automatically, so this error is only raised after all retry
142-
attempts have been exhausted.
140+
This occurs when the API returns a body that does not match its content type, for example a malformed JSON
141+
document. It is raised on a response the client already accepted, so it is not retried - a transfer that breaks
142+
mid-body surfaces as a transport error inside the retry loop instead.
143143
"""
144144

145145
def __init__(self, response: HttpResponse) -> None:

src/apify_client/http_clients/_impit.py

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from apify_client._docs import docs_group
2222
from apify_client._logging import log_context, logger_name
2323
from apify_client._utils.time import to_seconds
24-
from apify_client.errors import ApifyApiError, InvalidResponseBodyError
24+
from apify_client.errors import ApifyApiError
2525
from apify_client.http_clients._base import HttpClient, HttpClientAsync
2626

2727
if TYPE_CHECKING:
@@ -37,20 +37,31 @@
3737
logger = logging.getLogger(logger_name)
3838

3939

40+
_PERMANENT_ERRORS = (
41+
# A request Impit rejects before sending it, e.g. one carrying an invalid header value.
42+
impit.LocalProtocolError,
43+
# The class Impit declares for a scheme it refuses to speak, but does not raise today - it reports an unsupported
44+
# scheme as `impit.InvalidURL`, which sits outside the `impit.HTTPError` tree and is non-retryable anyway. Listed
45+
# so the classifier stays right if Impit switches over.
46+
impit.UnsupportedProtocol,
47+
# An over-long redirect chain is a routing loop, which repeating the request cannot break.
48+
impit.TooManyRedirects,
49+
# Only `Response.raise_for_status()` raises this, and the client never calls it - `_make_request` decides on
50+
# status codes from the response itself.
51+
impit.HTTPStatusError,
52+
)
53+
54+
4055
def _is_retryable_error(exc: Exception) -> bool:
41-
"""Check if an exception represents a transient error that should be retried.
56+
"""Check if an exception represents a transient transport failure that should be retried.
4257
43-
All `impit.HTTPError` subclasses are considered retryable because they represent transport-level failures
44-
(network issues, timeouts, protocol errors, body decoding errors) that are typically transient. HTTP status
45-
code errors are handled separately in `_make_request` based on the response status code, not here.
58+
Every error from Impit's own hierarchy counts as transient except the permanently-failing types listed in
59+
`_PERMANENT_ERRORS`. Retrying is the default because Impit also reports genuinely transient failures through
60+
its generic base class, e.g. a bare `impit.HTTPError` wrapping a failure its internal HTTP library did not
61+
classify. HTTP status code errors are handled separately in `_make_request` based on the response status code,
62+
not here.
4663
"""
47-
return isinstance(
48-
exc,
49-
(
50-
InvalidResponseBodyError,
51-
impit.HTTPError,
52-
),
53-
)
64+
return isinstance(exc, impit.HTTPError) and not isinstance(exc, _PERMANENT_ERRORS)
5465

5566

5667
@docs_group('HTTP clients')

tests/unit/test_http_clients.py

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -309,18 +309,82 @@ def test_parse_params_mixed() -> None:
309309
}
310310

311311

312-
def test_is_retryable_error() -> None:
313-
"""Test _is_retryable_error correctly identifies retryable errors."""
314-
mock_response = Mock()
315-
assert _is_retryable_error(InvalidResponseBodyError(mock_response))
316-
assert _is_retryable_error(impit.NetworkError('test'))
317-
assert _is_retryable_error(impit.TimeoutException('test'))
318-
assert _is_retryable_error(impit.RemoteProtocolError('test'))
319-
320-
# Non-retryable errors
321-
assert not _is_retryable_error(ValueError('test'))
322-
assert not _is_retryable_error(RuntimeError('test'))
323-
assert not _is_retryable_error(Exception('test'))
312+
@pytest.mark.parametrize(
313+
'exc',
314+
[
315+
# Impit wraps a failure its internal HTTP library did not classify in a bare `HTTPError`, so even the
316+
# generic base class is transient.
317+
pytest.param(impit.HTTPError('unclassified failure'), id='bare HTTPError'),
318+
pytest.param(impit.TimeoutException('timeout'), id='TimeoutException'),
319+
pytest.param(impit.NetworkError('network error'), id='NetworkError'),
320+
pytest.param(impit.RemoteProtocolError('remote protocol error'), id='RemoteProtocolError'),
321+
pytest.param(impit.DecodingError('decoding error'), id='DecodingError'),
322+
# One `ProxyError` covers both a proxy rejecting the CONNECT tunnel and a 407, so a transient case cannot
323+
# be told from a permanent one - retrying is the safer default.
324+
pytest.param(impit.ProxyError('proxy error'), id='ProxyError'),
325+
],
326+
)
327+
def test_is_retryable_error(exc: Exception) -> None:
328+
"""A transient transport failure is retried."""
329+
assert _is_retryable_error(exc)
330+
331+
332+
@pytest.mark.parametrize(
333+
'exc',
334+
[
335+
pytest.param(impit.LocalProtocolError('invalid header value'), id='LocalProtocolError'),
336+
pytest.param(impit.UnsupportedProtocol('unsupported scheme'), id='UnsupportedProtocol'),
337+
pytest.param(impit.TooManyRedirects('too many redirects'), id='TooManyRedirects'),
338+
pytest.param(impit.HTTPStatusError('status error'), id='HTTPStatusError'),
339+
# Impit reports a bad URL outside the `impit.HTTPError` tree entirely.
340+
pytest.param(impit.InvalidURL('unsupported scheme'), id='InvalidURL'),
341+
# `InvalidResponseBodyError` is raised by a resource client once `call` has returned, never in the retry loop.
342+
pytest.param(InvalidResponseBodyError(Mock()), id='InvalidResponseBodyError'),
343+
pytest.param(ValueError('value error'), id='ValueError'),
344+
pytest.param(RuntimeError('runtime error'), id='RuntimeError'),
345+
pytest.param(Exception('generic exception'), id='Exception'),
346+
],
347+
)
348+
def test_is_not_retryable_error(exc: Exception) -> None:
349+
"""A transport failure a retry cannot fix, and anything outside Impit's hierarchy, is not retried."""
350+
assert not _is_retryable_error(exc)
351+
352+
353+
def test_permanent_transport_error_is_not_retried() -> None:
354+
"""A transport error a retry cannot fix fails on the first attempt instead of burning the whole backoff."""
355+
client = ImpitHttpClient(token='test_token', min_delay_between_retries=timedelta(0))
356+
request = Mock(side_effect=impit.LocalProtocolError('invalid header value'))
357+
client._impit_client = Mock(request=request)
358+
359+
with pytest.raises(impit.LocalProtocolError):
360+
client.call(method='GET', url='https://api.test.com/endpoint')
361+
362+
request.assert_called_once()
363+
364+
365+
async def test_permanent_transport_error_is_not_retried_async() -> None:
366+
"""The async client applies the same policy, failing on the first attempt."""
367+
client = ImpitHttpClientAsync(token='test_token', min_delay_between_retries=timedelta(0))
368+
request = AsyncMock(side_effect=impit.LocalProtocolError('invalid header value'))
369+
client._impit_async_client = Mock(request=request)
370+
371+
with pytest.raises(impit.LocalProtocolError):
372+
await client.call(method='GET', url='https://api.test.com/endpoint')
373+
374+
request.assert_awaited_once()
375+
376+
377+
def test_transient_transport_error_is_retried() -> None:
378+
"""A transient transport failure keeps being retried until the attempts run out."""
379+
client = ImpitHttpClient(token='test_token', max_retries=2, min_delay_between_retries=timedelta(0))
380+
request = Mock(side_effect=impit.TimeoutException('timeout'))
381+
client._impit_client = Mock(request=request)
382+
383+
with pytest.raises(impit.TimeoutException):
384+
client.call(method='GET', url='https://api.test.com/endpoint')
385+
386+
# `max_retries` attempts inside the backoff loop, plus the final one it makes after the last delay.
387+
assert request.call_count == 3
324388

325389

326390
@pytest.fixture(

0 commit comments

Comments
 (0)