Skip to content

Commit 7bc0d51

Browse files
committed
Polish
1 parent cb57e87 commit 7bc0d51

3 files changed

Lines changed: 116 additions & 51 deletions

File tree

docs/04_upgrading/upgrading_to_v3.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,44 @@ The default timeout tier assigned to each method on non-storage resource clients
186186

187187
If your code relied on the previous global timeout behavior, review the timeout tier on the methods you use and adjust via the `timeout` parameter or by overriding tier defaults on the <ApiLink to="class/ApifyClient">`ApifyClient`</ApiLink> constructor (see [Tiered timeout system](#tiered-timeout-system) above).
188188

189+
## Exception subclasses for API errors
190+
191+
<ApiLink to="class/ApifyApiError">`ApifyApiError`</ApiLink> now dispatches to a dedicated subclass based on the HTTP status code of the failed response. Instantiating `ApifyApiError` directly still works — it returns the most specific subclass for the status — so existing `except ApifyApiError` handlers are unaffected.
192+
193+
The following subclasses are available:
194+
195+
| Status | Subclass |
196+
|---|---|
197+
| 400 | <ApiLink to="class/InvalidRequestError">`InvalidRequestError`</ApiLink> |
198+
| 401 | <ApiLink to="class/UnauthorizedError">`UnauthorizedError`</ApiLink> |
199+
| 403 | <ApiLink to="class/ForbiddenError">`ForbiddenError`</ApiLink> |
200+
| 404 | <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> |
201+
| 409 | <ApiLink to="class/ConflictError">`ConflictError`</ApiLink> |
202+
| 429 | <ApiLink to="class/RateLimitError">`RateLimitError`</ApiLink> |
203+
| 5xx | <ApiLink to="class/ServerError">`ServerError`</ApiLink> |
204+
205+
You can now branch on error kind without inspecting `status_code` or `type`:
206+
207+
```python
208+
from apify_client import ApifyClient
209+
from apify_client.errors import NotFoundError, RateLimitError
210+
211+
client = ApifyClient(token='MY-APIFY-TOKEN')
212+
213+
try:
214+
run = client.run('some-run-id').get()
215+
except NotFoundError:
216+
run = None
217+
except RateLimitError:
218+
...
219+
```
220+
221+
### Behavior change: `.get()` now returns `None` on any 404
222+
223+
As a consequence of the dispatch above, `.get()`-style convenience methods — which use `catch_not_found_or_throw` internally to swallow 404 responses and return `None` — now swallow **every** 404, regardless of the `error.type` string in the response body. Previously only 404 responses carrying the types `record-not-found` or `record-or-token-not-found` were swallowed; any other 404 was re-raised as `ApifyApiError`.
224+
225+
In practice this matters only if you relied on a `.get()` call raising for a 404 with an unusual error type — such cases now return `None` instead. If your code needs to distinguish between "resource missing" and "404 with an unexpected type", inspect `.type` on the returned response or catch <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> from non-`.get()` calls that do not use `catch_not_found_or_throw`.
226+
189227
## Snake_case `sort_by` values on `actors().list()`
190228

191229
The `sort_by` parameter of <ApiLink to="class/ActorCollectionClient#list">`ActorCollectionClient.list()`</ApiLink> and <ApiLink to="class/ActorCollectionClientAsync#list">`ActorCollectionClientAsync.list()`</ApiLink> now accepts pythonic snake_case values instead of the raw camelCase values used by the API.

src/apify_client/errors.py

Lines changed: 39 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -13,30 +13,19 @@
1313

1414
@docs_group('Errors')
1515
class ApifyClientError(Exception):
16-
"""Base class for all Apify API client errors.
17-
18-
All custom exceptions defined by this package inherit from this class, making it convenient
19-
to catch any client-related error with a single except clause.
20-
"""
16+
"""Base class for all Apify API client errors."""
2117

2218

2319
@docs_group('Errors')
2420
class ApifyApiError(ApifyClientError):
2521
"""Error raised when the Apify API returns an error response.
2622
27-
This error is raised when an HTTP request to the Apify API succeeds at the transport level
28-
but the server returns an error status code. Rate limit (HTTP 429) and server errors (HTTP 5xx)
29-
are retried automatically before this error is raised, while client errors (HTTP 4xx) are raised
30-
immediately.
23+
Instantiating `ApifyApiError` dispatches to the subclass matching the HTTP status code (e.g. 404 → `NotFoundError`,
24+
any 5xx → `ServerError`). Unmapped statuses stay on `ApifyApiError`. Existing `except ApifyApiError` handlers keep
25+
working because every subclass inherits from this class.
3126
32-
Instantiating `ApifyApiError` directly dispatches to a more specific subclass based on the
33-
HTTP status code of the response (e.g. a 404 response produces a `NotFoundError`). Existing
34-
`except ApifyApiError` handlers continue to match because every subclass inherits from this
35-
class. Statuses without a dedicated subclass fall back to `ApifyApiError` itself.
36-
37-
The `type`, `message` and `data` fields from the API response body are exposed as attributes
38-
for inspection, but they are treated as non-authoritative metadata — dispatch is driven by the
39-
status code only, which is more stable than the per-endpoint `error.type` strings.
27+
The `type`, `message` and `data` fields from the response body are exposed for inspection but are treated as
28+
non-authoritative metadata — dispatch is driven by the status code only.
4029
4130
Attributes:
4231
message: The error message from the API response.
@@ -47,6 +36,9 @@ class ApifyApiError(ApifyClientError):
4736
data: Additional error data from the API response.
4837
"""
4938

39+
# Subclasses in `_STATUS_TO_CLASS` must keep the `(response, attempt, method='GET')` constructor signature —
40+
# `__new__` forwards those arguments verbatim.
41+
5042
def __new__(cls, response: HttpResponse, attempt: int, method: str = 'GET') -> Self: # noqa: ARG004
5143
"""Dispatch to the subclass matching the response's HTTP status code, if any."""
5244
target_cls: type[ApifyApiError] = cls
@@ -67,36 +59,35 @@ def __init__(self, response: HttpResponse, attempt: int, method: str = 'GET') ->
6759
attempt: The attempt number when the request failed (1-indexed).
6860
method: The HTTP method of the failed request.
6961
"""
70-
payload = _extract_error_payload(response)
62+
payload = self._extract_error_payload(response)
7163

7264
self.message: str | None = f'Unexpected error: {response.text}'
7365
self.type: str | None = None
7466
self.data = dict[str, str]()
7567

7668
if payload is not None:
77-
self.message = payload['message']
78-
self.type = payload['type']
69+
self.message = payload.get('message', self.message)
70+
self.type = payload.get('type')
7971
if 'data' in payload:
8072
self.data = payload['data']
8173

8274
super().__init__(self.message)
8375

84-
self.name = 'ApifyApiError'
8576
self.status_code = response.status_code
8677
self.attempt = attempt
8778
self.http_method = method
8879

89-
90-
def _extract_error_payload(response: HttpResponse) -> dict[str, Any] | None:
91-
"""Return the `error` dict from the response body, or None if absent or unparsable."""
92-
try:
93-
data = response.json()
94-
except ValueError:
95-
return None
96-
if not isinstance(data, dict):
97-
return None
98-
error = data.get('error')
99-
return error if isinstance(error, dict) else None
80+
@staticmethod
81+
def _extract_error_payload(response: HttpResponse) -> dict[str, Any] | None:
82+
"""Return the `error` dict from the response body, or None if absent or unparsable."""
83+
try:
84+
data = response.json()
85+
except ValueError:
86+
return None
87+
if not isinstance(data, dict):
88+
return None
89+
error = data.get('error')
90+
return error if isinstance(error, dict) else None
10091

10192

10293
@docs_group('Errors')
@@ -128,37 +119,26 @@ class ConflictError(ApifyApiError):
128119
class RateLimitError(ApifyApiError):
129120
"""Raised when the Apify API returns an HTTP 429 Too Many Requests response.
130121
131-
Rate-limited requests are retried automatically; this error is only raised after all
132-
retry attempts have been exhausted.
122+
Rate-limited requests are retried automatically; this error is only raised after all retry attempts have been
123+
exhausted.
133124
"""
134125

135126

136127
@docs_group('Errors')
137128
class ServerError(ApifyApiError):
138129
"""Raised when the Apify API returns an HTTP 5xx response.
139130
140-
Server errors are retried automatically; this error is only raised after all
141-
retry attempts have been exhausted.
131+
Server errors are retried automatically; this error is only raised after all retry attempts have been exhausted.
142132
"""
143133

144134

145-
_STATUS_TO_CLASS: dict[int, type[ApifyApiError]] = {
146-
400: InvalidRequestError,
147-
401: UnauthorizedError,
148-
403: ForbiddenError,
149-
404: NotFoundError,
150-
409: ConflictError,
151-
429: RateLimitError,
152-
}
153-
154-
155135
@docs_group('Errors')
156136
class InvalidResponseBodyError(ApifyClientError):
157137
"""Error raised when a response body cannot be parsed.
158138
159-
This typically occurs when the API returns a partial or malformed JSON response, for example
160-
due to a network interruption. The client retries such requests automatically, so this error
161-
is only raised after all retry attempts have been exhausted.
139+
This typically occurs when the API returns a partial or malformed JSON response, for example due to a network
140+
interruption. The client retries such requests automatically, so this error is only raised after all retry
141+
attempts have been exhausted.
162142
"""
163143

164144
def __init__(self, response: HttpResponse) -> None:
@@ -169,6 +149,15 @@ def __init__(self, response: HttpResponse) -> None:
169149
"""
170150
super().__init__('Response body could not be parsed')
171151

172-
self.name = 'InvalidResponseBodyError'
173152
self.code = 'invalid-response-body'
174153
self.response = response
154+
155+
156+
_STATUS_TO_CLASS: dict[int, type[ApifyApiError]] = {
157+
400: InvalidRequestError,
158+
401: UnauthorizedError,
159+
403: ForbiddenError,
160+
404: NotFoundError,
161+
409: ConflictError,
162+
429: RateLimitError,
163+
}

tests/unit/test_client_errors.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,16 @@
77
from werkzeug import Response
88

99
from apify_client._http_clients import ImpitHttpClient, ImpitHttpClientAsync
10-
from apify_client.errors import ApifyApiError, ForbiddenError, NotFoundError, ServerError
10+
from apify_client.errors import (
11+
ApifyApiError,
12+
ConflictError,
13+
ForbiddenError,
14+
InvalidRequestError,
15+
NotFoundError,
16+
RateLimitError,
17+
ServerError,
18+
UnauthorizedError,
19+
)
1120

1221
if TYPE_CHECKING:
1322
from pytest_httpserver import HTTPServer
@@ -163,6 +172,35 @@ def test_apify_api_error_falls_back_for_unmapped_status(httpserver: HTTPServer)
163172
assert exc.value.type == 'whatever'
164173

165174

175+
@pytest.mark.parametrize(
176+
('status_code', 'expected_cls'),
177+
[
178+
pytest.param(400, InvalidRequestError, id='400 → InvalidRequestError'),
179+
pytest.param(401, UnauthorizedError, id='401 → UnauthorizedError'),
180+
pytest.param(403, ForbiddenError, id='403 → ForbiddenError'),
181+
pytest.param(404, NotFoundError, id='404 → NotFoundError'),
182+
pytest.param(409, ConflictError, id='409 → ConflictError'),
183+
pytest.param(429, RateLimitError, id='429 → RateLimitError'),
184+
],
185+
)
186+
def test_apify_api_error_dispatches_all_mapped_statuses(
187+
httpserver: HTTPServer, status_code: int, expected_cls: type[ApifyApiError]
188+
) -> None:
189+
"""Every status in `_STATUS_TO_CLASS` dispatches to its matching subclass."""
190+
httpserver.expect_request('/dispatch_all').respond_with_json(
191+
{'error': {'type': 'some-type', 'message': 'msg'}}, status=status_code
192+
)
193+
# Use max_retries=1 so retryable statuses (429) don't loop during the test.
194+
client = ImpitHttpClient(max_retries=1)
195+
196+
with pytest.raises(expected_cls) as exc:
197+
client.call(method='GET', url=str(httpserver.url_for('/dispatch_all')))
198+
199+
assert type(exc.value) is expected_cls
200+
assert isinstance(exc.value, ApifyApiError)
201+
assert exc.value.status_code == status_code
202+
203+
166204
def test_apify_api_error_falls_back_for_unparsable_body(httpserver: HTTPServer) -> None:
167205
"""When the body can't be parsed, status-based dispatch still applies and `.type` is None."""
168206
httpserver.expect_request('/unparsable').respond_with_data('<not json>', status=418, content_type='text/html')

0 commit comments

Comments
 (0)