Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/apify_client/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,16 @@ def maybe_parse_response(response: Response) -> Any:


def is_retryable_error(exc: Exception) -> bool:
"""Check if the given error is retryable."""
"""Check if the given error is retryable.

All ``impit.HTTPError`` subclasses are considered retryable because they represent transport-level failures
(network issues, timeouts, protocol errors, body decoding errors) that are typically transient. HTTP status
code errors are handled separately in ``_make_request`` based on the response status code, not here.
"""
return isinstance(
exc,
(
InvalidResponseBodyError,
impit.NetworkError,
impit.TimeoutException,
impit.RemoteProtocolError,
impit.HTTPError,
),
)
28 changes: 27 additions & 1 deletion tests/unit/test_client_timeouts.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from unittest.mock import Mock

import pytest
from impit import Response, TimeoutException
from impit import HTTPError, Response, TimeoutException

from apify_client import ApifyClient
from apify_client._http_client import HTTPClient, HTTPClientAsync
Expand Down Expand Up @@ -76,6 +76,32 @@ async def mock_request(*_args: Any, **kwargs: Any) -> Response:
assert response.status_code == 200


async def test_retry_on_http_error_async_client(monkeypatch: pytest.MonkeyPatch) -> None:
"""Tests that bare impit.HTTPError (e.g. body decode errors) are retried.

This reproduces the scenario where the HTTP response body is truncated mid-stream
(e.g. "unexpected EOF during chunk size line"), which impit raises as a generic HTTPError.
"""
should_raise_error = iter((True, True, False))
retry_counter_mock = Mock()

async def mock_request(*_args: Any, **_kwargs: Any) -> Response:
retry_counter_mock()
should_raise = next(should_raise_error)
if should_raise:
raise HTTPError('The internal HTTP library has thrown an error: unexpected EOF during chunk size line')

return Response(status_code=200)

monkeypatch.setattr('impit.AsyncClient.request', mock_request)

response = await HTTPClientAsync(timeout_secs=5).call(method='GET', url='http://placeholder.url/http_error')

# 3 attempts: 2 failures + 1 success
assert retry_counter_mock.call_count == 3
assert response.status_code == 200


def test_dynamic_timeout_sync_client(monkeypatch: pytest.MonkeyPatch) -> None:
"""Tests timeout values for request with retriable errors.

Expand Down
33 changes: 33 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@
from collections.abc import Callable
from typing import Any

import impit
import pytest
from apify_shared.consts import WebhookEventType

from apify_client._utils import (
encode_webhook_list_to_base64,
is_retryable_error,
pluck_data,
retry_with_exp_backoff,
retry_with_exp_backoff_async,
to_safe_id,
)
from apify_client.errors import InvalidResponseBodyError


def test__to_safe_id() -> None:
Expand Down Expand Up @@ -154,3 +157,33 @@ def test__encode_webhook_list_to_base64() -> None:
)
== 'W3siZXZlbnRUeXBlcyI6IFsiQUNUT1IuUlVOLkNSRUFURUQiXSwgInJlcXVlc3RVcmwiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9ydW4tY3JlYXRlZCJ9LCB7ImV2ZW50VHlwZXMiOiBbIkFDVE9SLlJVTi5TVUNDRUVERUQiXSwgInJlcXVlc3RVcmwiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9ydW4tc3VjY2VlZGVkIiwgInBheWxvYWRUZW1wbGF0ZSI6ICJ7XCJoZWxsb1wiOiBcIndvcmxkXCIsIFwicmVzb3VyY2VcIjp7e3Jlc291cmNlfX19In1d' # noqa: E501
)


@pytest.mark.parametrize(
'exc',
[
InvalidResponseBodyError(impit.Response(status_code=200)),
impit.HTTPError('generic http error'),
impit.NetworkError('network error'),
impit.TimeoutException('timeout'),
impit.RemoteProtocolError('remote protocol error'),
impit.ReadError('read error'),
impit.ConnectError('connect error'),
impit.WriteError('write error'),
impit.DecodingError('decoding error'),
],
)
def test__is_retryable_error(exc: Exception) -> None:
assert is_retryable_error(exc) is True


@pytest.mark.parametrize(
'exc',
[
Exception('generic exception'),
ValueError('value error'),
RuntimeError('runtime error'),
],
)
def test__is_not_retryable_error(exc: Exception) -> None:
assert is_retryable_error(exc) is False