Skip to content

Commit 0c91ca5

Browse files
authored
fix: Fix API error with stream (#459)
- Handle properly API error with stream - Add tests
1 parent 2dcd19b commit 0c91ca5

File tree

2 files changed

+70
-2
lines changed

2 files changed

+70
-2
lines changed

src/apify_client/_http_client.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,9 @@ def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response:
214214
if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004
215215
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
216216
stop_retrying()
217+
218+
# Read the response in case it is a stream, so we can raise the error properly
219+
response.read()
217220
raise ApifyApiError(response, attempt)
218221

219222
return retry_with_exp_backoff(
@@ -304,6 +307,9 @@ async def _make_request(stop_retrying: Callable, attempt: int) -> httpx.Response
304307
if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004
305308
logger.debug('Status code is not retryable', extra={'status_code': response.status_code})
306309
stop_retrying()
310+
311+
# Read the response in case it is a stream, so we can raise the error properly
312+
await response.aread()
307313
raise ApifyApiError(response, attempt)
308314

309315
return await retry_with_exp_backoff_async(

tests/unit/test_client_errors.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import json
2-
from collections.abc import Generator
2+
from collections.abc import AsyncIterator, Generator, Iterator
33

44
import httpx
55
import pytest
@@ -15,8 +15,18 @@
1515
'invalidItems': {'0': ["should have required property 'name'"], '1': ["should have required property 'name'"]}
1616
}
1717

18+
RAW_ERROR = (
19+
b'{\n'
20+
b' "error": {\n'
21+
b' "type": "insufficient-permissions",\n'
22+
b' "message": "Insufficient permissions for the Actor run. Make sure you\''
23+
b're passing a correct API token and that it has the required permissions."\n'
24+
b' }\n'
25+
b'}'
26+
)
1827

19-
@pytest.fixture(autouse=True)
28+
29+
@pytest.fixture
2030
def mocked_response() -> Generator[respx.MockRouter]:
2131
response_content = json.dumps(
2232
{'error': {'message': _EXPECTED_MESSAGE, 'type': _EXPECTED_TYPE, 'data': _EXPECTED_DATA}}
@@ -26,6 +36,7 @@ def mocked_response() -> Generator[respx.MockRouter]:
2636
yield respx_mock
2737

2838

39+
@pytest.mark.usefixtures('mocked_response')
2940
def test_client_apify_api_error_with_data() -> None:
3041
"""Test that client correctly throws ApifyApiError with error data from response."""
3142
client = HTTPClient()
@@ -38,6 +49,7 @@ def test_client_apify_api_error_with_data() -> None:
3849
assert e.value.data == _EXPECTED_DATA
3950

4051

52+
@pytest.mark.usefixtures('mocked_response')
4153
async def test_async_client_apify_api_error_with_data() -> None:
4254
"""Test that async client correctly throws ApifyApiError with error data from response."""
4355
client = HTTPClientAsync()
@@ -48,3 +60,53 @@ async def test_async_client_apify_api_error_with_data() -> None:
4860
assert e.value.message == _EXPECTED_MESSAGE
4961
assert e.value.type == _EXPECTED_TYPE
5062
assert e.value.data == _EXPECTED_DATA
63+
64+
65+
def test_client_apify_api_error_streamed() -> None:
66+
"""Test that client correctly throws ApifyApiError when the response has stream."""
67+
68+
error = json.loads(RAW_ERROR.decode())
69+
70+
class ByteStream(httpx._types.SyncByteStream):
71+
def __iter__(self) -> Iterator[bytes]:
72+
yield RAW_ERROR
73+
74+
def close(self) -> None:
75+
pass
76+
77+
stream_url = 'http://some-stream-url.com'
78+
79+
client = HTTPClient()
80+
81+
with respx.mock() as respx_mock:
82+
respx_mock.get(url=stream_url).mock(return_value=httpx.Response(stream=ByteStream(), status_code=403))
83+
with pytest.raises(ApifyApiError) as e:
84+
client.call(method='GET', url=stream_url, stream=True, parse_response=False)
85+
86+
assert e.value.message == error['error']['message']
87+
assert e.value.type == error['error']['type']
88+
89+
90+
async def test_async_client_apify_api_error_streamed() -> None:
91+
"""Test that async client correctly throws ApifyApiError when the response has stream."""
92+
93+
error = json.loads(RAW_ERROR.decode())
94+
95+
class AsyncByteStream(httpx._types.AsyncByteStream):
96+
async def __aiter__(self) -> AsyncIterator[bytes]:
97+
yield RAW_ERROR
98+
99+
async def aclose(self) -> None:
100+
pass
101+
102+
stream_url = 'http://some-stream-url.com'
103+
104+
client = HTTPClientAsync()
105+
106+
with respx.mock() as respx_mock:
107+
respx_mock.get(url=stream_url).mock(return_value=httpx.Response(stream=AsyncByteStream(), status_code=403))
108+
with pytest.raises(ApifyApiError) as e:
109+
await client.call(method='GET', url=stream_url, stream=True, parse_response=False)
110+
111+
assert e.value.message == error['error']['message']
112+
assert e.value.type == error['error']['type']

0 commit comments

Comments
 (0)