Skip to content

Commit a6daff7

Browse files
authored
feat: add ApifyApiError subclasses grouped by HTTP status (#737)
## Summary Closes: #423 Addresses #423. Adds a small set of `ApifyApiError` subclasses so callers can narrow `except` clauses to specific failure modes. Dispatch is driven by HTTP status — a stable contract — rather than the per-endpoint `error.type` string. `type`, `message`, and `data` remain exposed as metadata. All subclasses inherit from `ApifyApiError`, so existing `except ApifyApiError` handlers keep matching. | Class | Status | |---|---| | `InvalidRequestError` | 400 | | `UnauthorizedError` | 401 | | `ForbiddenError` | 403 | | `NotFoundError` | 404 | | `ConflictError` | 409 | | `RateLimitError` | 429 | | `ServerError` | 5xx | Unmapped statuses and unparsable bodies fall back to the base `ApifyApiError`. ## Migration note Constructing `ApifyApiError(response, ...)` directly now returns an instance of the matching subclass. `except ApifyApiError` is unaffected; tests asserting `type(exc) is ApifyApiError` on a mapped status would need updates. `catch_not_found_or_throw` now uses `isinstance(exc, NotFoundError)` instead of a hardcoded `error.type` allowlist.
1 parent 3e9fc3d commit a6daff7

5 files changed

Lines changed: 253 additions & 41 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/_utils.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@
88
import time
99
import warnings
1010
from base64 import urlsafe_b64encode
11-
from http import HTTPStatus
1211
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
1312

1413
import impit
1514

1615
from apify_client._consts import OVERRIDABLE_DEFAULT_HEADERS
17-
from apify_client.errors import InvalidResponseBodyError
16+
from apify_client.errors import InvalidResponseBodyError, NotFoundError
1817

1918
if TYPE_CHECKING:
2019
from datetime import timedelta
@@ -63,9 +62,7 @@ def catch_not_found_or_throw(exc: ApifyApiError) -> None:
6362
Raises:
6463
ApifyApiError: If the error is not a 404 Not Found error.
6564
"""
66-
is_not_found_status = exc.status_code == HTTPStatus.NOT_FOUND
67-
is_not_found_type = exc.type in ['record-not-found', 'record-or-token-not-found']
68-
if not (is_not_found_status and is_not_found_type):
65+
if not isinstance(exc, NotFoundError):
6966
raise exc
7067

7168

src/apify_client/errors.py

Lines changed: 101 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
from http import HTTPStatus
4+
from typing import TYPE_CHECKING, Any
45

56
from apify_client._docs import docs_group
67

78
if TYPE_CHECKING:
9+
from typing import Self
10+
811
from apify_client._http_clients import HttpResponse
912

1013

1114
@docs_group('Errors')
1215
class ApifyClientError(Exception):
13-
"""Base class for all Apify API client errors.
14-
15-
All custom exceptions defined by this package inherit from this class, making it convenient
16-
to catch any client-related error with a single except clause.
17-
"""
16+
"""Base class for all Apify API client errors."""
1817

1918

2019
@docs_group('Errors')
2120
class ApifyApiError(ApifyClientError):
2221
"""Error raised when the Apify API returns an error response.
2322
24-
This error is raised when an HTTP request to the Apify API succeeds at the transport level
25-
but the server returns an error status code. Rate limit (HTTP 429) and server errors (HTTP 5xx)
26-
are retried automatically before this error is raised, while client errors (HTTP 4xx) are raised
27-
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.
26+
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.
2829
2930
Attributes:
3031
message: The error message from the API response.
@@ -35,6 +36,21 @@ class ApifyApiError(ApifyClientError):
3536
data: Additional error data from the API response.
3637
"""
3738

39+
# Subclasses in `_STATUS_TO_CLASS` must keep the `(response, attempt, method='GET')` constructor signature —
40+
# `__new__` forwards those arguments verbatim.
41+
42+
def __new__(cls, response: HttpResponse, attempt: int, method: str = 'GET') -> Self: # noqa: ARG004
43+
"""Dispatch to the subclass matching the response's HTTP status code, if any."""
44+
target_cls: type[ApifyApiError] = cls
45+
if cls is ApifyApiError:
46+
status = response.status_code
47+
mapped = _STATUS_TO_CLASS.get(status)
48+
if mapped is None and status >= HTTPStatus.INTERNAL_SERVER_ERROR:
49+
mapped = ServerError
50+
if mapped is not None:
51+
target_cls = mapped
52+
return super().__new__(target_cls)
53+
3854
def __init__(self, response: HttpResponse, attempt: int, method: str = 'GET') -> None:
3955
"""Initialize the API error from a failed response.
4056
@@ -43,43 +59,86 @@ def __init__(self, response: HttpResponse, attempt: int, method: str = 'GET') ->
4359
attempt: The attempt number when the request failed (1-indexed).
4460
method: The HTTP method of the failed request.
4561
"""
46-
self.message: str | None = None
62+
payload = self._extract_error_payload(response)
63+
64+
self.message: str | None = f'Unexpected error: {response.text}'
4765
self.type: str | None = None
4866
self.data = dict[str, str]()
49-
self.message = f'Unexpected error: {response.text}'
5067

51-
try:
52-
response_data = response.json()
53-
54-
if (
55-
isinstance(response_data, dict)
56-
and 'error' in response_data
57-
and isinstance(response_data['error'], dict)
58-
):
59-
self.message = response_data['error']['message']
60-
self.type = response_data['error']['type']
61-
62-
if 'data' in response_data['error']:
63-
self.data = response_data['error']['data']
64-
65-
except ValueError:
66-
pass
68+
if payload is not None:
69+
self.message = payload.get('message', self.message)
70+
self.type = payload.get('type')
71+
if 'data' in payload:
72+
self.data = payload['data']
6773

6874
super().__init__(self.message)
6975

70-
self.name = 'ApifyApiError'
7176
self.status_code = response.status_code
7277
self.attempt = attempt
7378
self.http_method = method
7479

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
91+
92+
93+
@docs_group('Errors')
94+
class InvalidRequestError(ApifyApiError):
95+
"""Raised when the Apify API returns an HTTP 400 Bad Request response."""
96+
97+
98+
@docs_group('Errors')
99+
class UnauthorizedError(ApifyApiError):
100+
"""Raised when the Apify API returns an HTTP 401 Unauthorized response."""
101+
102+
103+
@docs_group('Errors')
104+
class ForbiddenError(ApifyApiError):
105+
"""Raised when the Apify API returns an HTTP 403 Forbidden response."""
106+
107+
108+
@docs_group('Errors')
109+
class NotFoundError(ApifyApiError):
110+
"""Raised when the Apify API returns an HTTP 404 Not Found response."""
111+
112+
113+
@docs_group('Errors')
114+
class ConflictError(ApifyApiError):
115+
"""Raised when the Apify API returns an HTTP 409 Conflict response."""
116+
117+
118+
@docs_group('Errors')
119+
class RateLimitError(ApifyApiError):
120+
"""Raised when the Apify API returns an HTTP 429 Too Many Requests response.
121+
122+
Rate-limited requests are retried automatically; this error is only raised after all retry attempts have been
123+
exhausted.
124+
"""
125+
126+
127+
@docs_group('Errors')
128+
class ServerError(ApifyApiError):
129+
"""Raised when the Apify API returns an HTTP 5xx response.
130+
131+
Server errors are retried automatically; this error is only raised after all retry attempts have been exhausted.
132+
"""
133+
75134

76135
@docs_group('Errors')
77136
class InvalidResponseBodyError(ApifyClientError):
78137
"""Error raised when a response body cannot be parsed.
79138
80-
This typically occurs when the API returns a partial or malformed JSON response, for example
81-
due to a network interruption. The client retries such requests automatically, so this error
82-
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.
83142
"""
84143

85144
def __init__(self, response: HttpResponse) -> None:
@@ -90,6 +149,15 @@ def __init__(self, response: HttpResponse) -> None:
90149
"""
91150
super().__init__('Response body could not be parsed')
92151

93-
self.name = 'InvalidResponseBodyError'
94152
self.code = 'invalid-response-body'
95153
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: 109 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
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
@@ -103,3 +112,102 @@ async def test_async_client_apify_api_error_streamed(httpserver: HTTPServer) ->
103112

104113
assert exc.value.message == error['error']['message']
105114
assert exc.value.type == error['error']['type']
115+
116+
117+
def test_apify_api_error_dispatches_to_subclass_for_known_status(httpserver: HTTPServer) -> None:
118+
"""Mapped HTTP status codes dispatch to their matching subclass."""
119+
httpserver.expect_request('/dispatch').respond_with_json(
120+
{'error': {'type': 'record-not-found', 'message': 'nope'}}, status=404
121+
)
122+
client = ImpitHttpClient()
123+
124+
with pytest.raises(NotFoundError) as exc:
125+
client.call(method='GET', url=str(httpserver.url_for('/dispatch')))
126+
127+
# Still an ApifyApiError, so legacy `except` handlers keep working.
128+
assert isinstance(exc.value, ApifyApiError)
129+
assert exc.value.status_code == 404
130+
assert exc.value.type == 'record-not-found'
131+
132+
133+
def test_apify_api_error_dispatches_streamed_response(httpserver: HTTPServer) -> None:
134+
"""Dispatch works even when the response body comes in as a stream (403 → ForbiddenError)."""
135+
httpserver.expect_request('/stream_dispatch').respond_with_handler(streaming_handler)
136+
client = ImpitHttpClient()
137+
138+
with pytest.raises(ForbiddenError) as exc:
139+
client.call(method='GET', url=httpserver.url_for('/stream_dispatch'), stream=True)
140+
141+
assert isinstance(exc.value, ApifyApiError)
142+
assert exc.value.status_code == 403
143+
assert exc.value.type == 'insufficient-permissions'
144+
145+
146+
def test_apify_api_error_dispatches_5xx_to_server_error(httpserver: HTTPServer) -> None:
147+
"""Any 5xx status falls under the ServerError subclass."""
148+
httpserver.expect_request('/server_error').respond_with_json(
149+
{'error': {'type': 'internal-error', 'message': 'boom'}}, status=503
150+
)
151+
client = ImpitHttpClient(max_retries=1)
152+
153+
with pytest.raises(ServerError) as exc:
154+
client.call(method='GET', url=str(httpserver.url_for('/server_error')))
155+
156+
assert isinstance(exc.value, ApifyApiError)
157+
assert exc.value.status_code == 503
158+
159+
160+
def test_apify_api_error_falls_back_for_unmapped_status(httpserver: HTTPServer) -> None:
161+
"""Statuses without a dedicated subclass fall back to the base ApifyApiError."""
162+
httpserver.expect_request('/unmapped').respond_with_json(
163+
{'error': {'type': 'whatever', 'message': 'nope'}}, status=418
164+
)
165+
client = ImpitHttpClient()
166+
167+
with pytest.raises(ApifyApiError) as exc:
168+
client.call(method='GET', url=str(httpserver.url_for('/unmapped')))
169+
170+
assert type(exc.value) is ApifyApiError
171+
assert exc.value.status_code == 418
172+
assert exc.value.type == 'whatever'
173+
174+
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+
204+
def test_apify_api_error_falls_back_for_unparsable_body(httpserver: HTTPServer) -> None:
205+
"""When the body can't be parsed, status-based dispatch still applies and `.type` is None."""
206+
httpserver.expect_request('/unparsable').respond_with_data('<not json>', status=418, content_type='text/html')
207+
client = ImpitHttpClient(max_retries=1)
208+
209+
with pytest.raises(ApifyApiError) as exc:
210+
client.call(method='GET', url=str(httpserver.url_for('/unparsable')))
211+
212+
assert type(exc.value) is ApifyApiError
213+
assert exc.value.type is None

0 commit comments

Comments
 (0)