From f64fdc283cd54c41adb3ab34102e8579cf3a60ca Mon Sep 17 00:00:00 2001 From: Josef Prochazka Date: Fri, 8 Aug 2025 14:47:09 +0200 Subject: [PATCH] Handle properly API error with stream Add tests --- src/apify_client/_http_client.py | 6 +++ tests/unit/test_client_errors.py | 66 +++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/apify_client/_http_client.py b/src/apify_client/_http_client.py index 4ba304af..74d98553 100644 --- a/src/apify_client/_http_client.py +++ b/src/apify_client/_http_client.py @@ -214,6 +214,9 @@ def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response: if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004 logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) stop_retrying() + + # Read the response in case it is a stream, so we can raise the error properly + response.read() raise ApifyApiError(response, attempt) return retry_with_exp_backoff( @@ -304,6 +307,9 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004 logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) stop_retrying() + + # Read the response in case it is a stream, so we can raise the error properly + await response.aread() raise ApifyApiError(response, attempt) return await retry_with_exp_backoff_async( diff --git a/tests/unit/test_client_errors.py b/tests/unit/test_client_errors.py index 71d0e4ea..f685cea1 100644 --- a/tests/unit/test_client_errors.py +++ b/tests/unit/test_client_errors.py @@ -1,5 +1,5 @@ import json -from collections.abc import Generator +from collections.abc import AsyncIterator, Generator, Iterator import httpx import pytest @@ -15,8 +15,18 @@ 'invalidItems': {'0': ["should have required property 'name'"], '1': ["should have required property 'name'"]} } +RAW_ERROR = ( + b'{\n' + b' "error": {\n' + b' "type": "insufficient-permissions",\n' + b' "message": "Insufficient permissions for the Actor run. Make sure you\'' + b're passing a correct API token and that it has the required permissions."\n' + b' }\n' + b'}' +) -@pytest.fixture(autouse=True) + +@pytest.fixture def mocked_response() -> Generator[respx.MockRouter]: response_content = json.dumps( {'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}} @@ -26,6 +36,7 @@ def mocked_response() -> Generator[respx.MockRouter]: yield respx_mock +@pytest.mark.usefixtures('mocked_response') def test_client_apify_api_error_with_data() -> None: """Test that client correctly throws ApifyApiError with error data from response.""" client = HTTPClient() @@ -38,6 +49,7 @@ def test_client_apify_api_error_with_data() -> None: assert e.value.data == _EXPECTED_DATA +@pytest.mark.usefixtures('mocked_response') async def test_async_client_apify_api_error_with_data() -> None: """Test that async client correctly throws ApifyApiError with error data from response.""" client = HTTPClientAsync() @@ -48,3 +60,53 @@ async def test_async_client_apify_api_error_with_data() -> None: assert e.value.message == _EXPECTED_MESSAGE assert e.value.type == _EXPECTED_TYPE assert e.value.data == _EXPECTED_DATA + + +def test_client_apify_api_error_streamed() -> None: + """Test that client correctly throws ApifyApiError when the response has stream.""" + + error = json.loads(RAW_ERROR.decode()) + + class ByteStream(httpx._types.SyncByteStream): + def __iter__(self) -> Iterator[bytes]: + yield RAW_ERROR + + def close(self) -> None: + pass + + stream_url = 'http://some-stream-url.com' + + client = HTTPClient() + + with respx.mock() as respx_mock: + respx_mock.get(url=stream_url).mock(return_value=httpx.Response(stream=ByteStream(), status_code=403)) + with pytest.raises(ApifyApiError) as e: + client.call(method='GET', url=stream_url, stream=True, parse_response=False) + + assert e.value.message == error['error']['message'] + assert e.value.type == error['error']['type'] + + +async def test_async_client_apify_api_error_streamed() -> None: + """Test that async client correctly throws ApifyApiError when the response has stream.""" + + error = json.loads(RAW_ERROR.decode()) + + class AsyncByteStream(httpx._types.AsyncByteStream): + async def __aiter__(self) -> AsyncIterator[bytes]: + yield RAW_ERROR + + async def aclose(self) -> None: + pass + + stream_url = 'http://some-stream-url.com' + + client = HTTPClientAsync() + + with respx.mock() as respx_mock: + respx_mock.get(url=stream_url).mock(return_value=httpx.Response(stream=AsyncByteStream(), status_code=403)) + with pytest.raises(ApifyApiError) as e: + await client.call(method='GET', url=stream_url, stream=True, parse_response=False) + + assert e.value.message == error['error']['message'] + assert e.value.type == error['error']['type']