diff --git a/docs/04_upgrading/upgrading_to_v3.mdx b/docs/04_upgrading/upgrading_to_v3.mdx
index 4bea7801..58d6a0bb 100644
--- a/docs/04_upgrading/upgrading_to_v3.mdx
+++ b/docs/04_upgrading/upgrading_to_v3.mdx
@@ -186,6 +186,44 @@ The default timeout tier assigned to each method on non-storage resource clients
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 `ApifyClient` constructor (see [Tiered timeout system](#tiered-timeout-system) above).
+## Exception subclasses for API errors
+
+`ApifyApiError` 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.
+
+The following subclasses are available:
+
+| Status | Subclass |
+|---|---|
+| 400 | `InvalidRequestError` |
+| 401 | `UnauthorizedError` |
+| 403 | `ForbiddenError` |
+| 404 | `NotFoundError` |
+| 409 | `ConflictError` |
+| 429 | `RateLimitError` |
+| 5xx | `ServerError` |
+
+You can now branch on error kind without inspecting `status_code` or `type`:
+
+```python
+from apify_client import ApifyClient
+from apify_client.errors import NotFoundError, RateLimitError
+
+client = ApifyClient(token='MY-APIFY-TOKEN')
+
+try:
+ run = client.run('some-run-id').get()
+except NotFoundError:
+ run = None
+except RateLimitError:
+ ...
+```
+
+### Behavior change: `.get()` now returns `None` on any 404
+
+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`.
+
+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 `NotFoundError` from non-`.get()` calls that do not use `catch_not_found_or_throw`.
+
## Snake_case `sort_by` values on `actors().list()`
The `sort_by` parameter of `ActorCollectionClient.list()` and `ActorCollectionClientAsync.list()` now accepts pythonic snake_case values instead of the raw camelCase values used by the API.
diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py
index 5f03836c..8ce9b171 100644
--- a/src/apify_client/_utils.py
+++ b/src/apify_client/_utils.py
@@ -8,13 +8,12 @@
import time
import warnings
from base64 import urlsafe_b64encode
-from http import HTTPStatus
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
import impit
from apify_client._consts import OVERRIDABLE_DEFAULT_HEADERS
-from apify_client.errors import InvalidResponseBodyError
+from apify_client.errors import InvalidResponseBodyError, NotFoundError
if TYPE_CHECKING:
from datetime import timedelta
@@ -63,9 +62,7 @@ def catch_not_found_or_throw(exc: ApifyApiError) -> None:
Raises:
ApifyApiError: If the error is not a 404 Not Found error.
"""
- is_not_found_status = exc.status_code == HTTPStatus.NOT_FOUND
- is_not_found_type = exc.type in ['record-not-found', 'record-or-token-not-found']
- if not (is_not_found_status and is_not_found_type):
+ if not isinstance(exc, NotFoundError):
raise exc
diff --git a/src/apify_client/errors.py b/src/apify_client/errors.py
index 90c2b147..b67ad4e3 100644
--- a/src/apify_client/errors.py
+++ b/src/apify_client/errors.py
@@ -1,30 +1,31 @@
from __future__ import annotations
-from typing import TYPE_CHECKING
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Any
from apify_client._docs import docs_group
if TYPE_CHECKING:
+ from typing import Self
+
from apify_client._http_clients import HttpResponse
@docs_group('Errors')
class ApifyClientError(Exception):
- """Base class for all Apify API client errors.
-
- All custom exceptions defined by this package inherit from this class, making it convenient
- to catch any client-related error with a single except clause.
- """
+ """Base class for all Apify API client errors."""
@docs_group('Errors')
class ApifyApiError(ApifyClientError):
"""Error raised when the Apify API returns an error response.
- This error is raised when an HTTP request to the Apify API succeeds at the transport level
- but the server returns an error status code. Rate limit (HTTP 429) and server errors (HTTP 5xx)
- are retried automatically before this error is raised, while client errors (HTTP 4xx) are raised
- immediately.
+ Instantiating `ApifyApiError` dispatches to the subclass matching the HTTP status code (e.g. 404 → `NotFoundError`,
+ any 5xx → `ServerError`). Unmapped statuses stay on `ApifyApiError`. Existing `except ApifyApiError` handlers keep
+ working because every subclass inherits from this class.
+
+ The `type`, `message` and `data` fields from the response body are exposed for inspection but are treated as
+ non-authoritative metadata — dispatch is driven by the status code only.
Attributes:
message: The error message from the API response.
@@ -35,6 +36,21 @@ class ApifyApiError(ApifyClientError):
data: Additional error data from the API response.
"""
+ # Subclasses in `_STATUS_TO_CLASS` must keep the `(response, attempt, method='GET')` constructor signature —
+ # `__new__` forwards those arguments verbatim.
+
+ def __new__(cls, response: HttpResponse, attempt: int, method: str = 'GET') -> Self: # noqa: ARG004
+ """Dispatch to the subclass matching the response's HTTP status code, if any."""
+ target_cls: type[ApifyApiError] = cls
+ if cls is ApifyApiError:
+ status = response.status_code
+ mapped = _STATUS_TO_CLASS.get(status)
+ if mapped is None and status >= HTTPStatus.INTERNAL_SERVER_ERROR:
+ mapped = ServerError
+ if mapped is not None:
+ target_cls = mapped
+ return super().__new__(target_cls)
+
def __init__(self, response: HttpResponse, attempt: int, method: str = 'GET') -> None:
"""Initialize the API error from a failed response.
@@ -43,43 +59,86 @@ def __init__(self, response: HttpResponse, attempt: int, method: str = 'GET') ->
attempt: The attempt number when the request failed (1-indexed).
method: The HTTP method of the failed request.
"""
- self.message: str | None = None
+ payload = self._extract_error_payload(response)
+
+ self.message: str | None = f'Unexpected error: {response.text}'
self.type: str | None = None
self.data = dict[str, str]()
- self.message = f'Unexpected error: {response.text}'
- try:
- response_data = response.json()
-
- if (
- isinstance(response_data, dict)
- and 'error' in response_data
- and isinstance(response_data['error'], dict)
- ):
- self.message = response_data['error']['message']
- self.type = response_data['error']['type']
-
- if 'data' in response_data['error']:
- self.data = response_data['error']['data']
-
- except ValueError:
- pass
+ if payload is not None:
+ self.message = payload.get('message', self.message)
+ self.type = payload.get('type')
+ if 'data' in payload:
+ self.data = payload['data']
super().__init__(self.message)
- self.name = 'ApifyApiError'
self.status_code = response.status_code
self.attempt = attempt
self.http_method = method
+ @staticmethod
+ def _extract_error_payload(response: HttpResponse) -> dict[str, Any] | None:
+ """Return the `error` dict from the response body, or None if absent or unparsable."""
+ try:
+ data = response.json()
+ except ValueError:
+ return None
+ if not isinstance(data, dict):
+ return None
+ error = data.get('error')
+ return error if isinstance(error, dict) else None
+
+
+@docs_group('Errors')
+class InvalidRequestError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 400 Bad Request response."""
+
+
+@docs_group('Errors')
+class UnauthorizedError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 401 Unauthorized response."""
+
+
+@docs_group('Errors')
+class ForbiddenError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 403 Forbidden response."""
+
+
+@docs_group('Errors')
+class NotFoundError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 404 Not Found response."""
+
+
+@docs_group('Errors')
+class ConflictError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 409 Conflict response."""
+
+
+@docs_group('Errors')
+class RateLimitError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 429 Too Many Requests response.
+
+ Rate-limited requests are retried automatically; this error is only raised after all retry attempts have been
+ exhausted.
+ """
+
+
+@docs_group('Errors')
+class ServerError(ApifyApiError):
+ """Raised when the Apify API returns an HTTP 5xx response.
+
+ Server errors are retried automatically; this error is only raised after all retry attempts have been exhausted.
+ """
+
@docs_group('Errors')
class InvalidResponseBodyError(ApifyClientError):
"""Error raised when a response body cannot be parsed.
- This typically occurs when the API returns a partial or malformed JSON response, for example
- due to a network interruption. The client retries such requests automatically, so this error
- is only raised after all retry attempts have been exhausted.
+ This typically occurs when the API returns a partial or malformed JSON response, for example due to a network
+ interruption. The client retries such requests automatically, so this error is only raised after all retry
+ attempts have been exhausted.
"""
def __init__(self, response: HttpResponse) -> None:
@@ -90,6 +149,15 @@ def __init__(self, response: HttpResponse) -> None:
"""
super().__init__('Response body could not be parsed')
- self.name = 'InvalidResponseBodyError'
self.code = 'invalid-response-body'
self.response = response
+
+
+_STATUS_TO_CLASS: dict[int, type[ApifyApiError]] = {
+ 400: InvalidRequestError,
+ 401: UnauthorizedError,
+ 403: ForbiddenError,
+ 404: NotFoundError,
+ 409: ConflictError,
+ 429: RateLimitError,
+}
diff --git a/tests/unit/test_client_errors.py b/tests/unit/test_client_errors.py
index 03af5d57..e7c87257 100644
--- a/tests/unit/test_client_errors.py
+++ b/tests/unit/test_client_errors.py
@@ -7,7 +7,16 @@
from werkzeug import Response
from apify_client._http_clients import ImpitHttpClient, ImpitHttpClientAsync
-from apify_client.errors import ApifyApiError
+from apify_client.errors import (
+ ApifyApiError,
+ ConflictError,
+ ForbiddenError,
+ InvalidRequestError,
+ NotFoundError,
+ RateLimitError,
+ ServerError,
+ UnauthorizedError,
+)
if TYPE_CHECKING:
from pytest_httpserver import HTTPServer
@@ -103,3 +112,102 @@ async def test_async_client_apify_api_error_streamed(httpserver: HTTPServer) ->
assert exc.value.message == error['error']['message']
assert exc.value.type == error['error']['type']
+
+
+def test_apify_api_error_dispatches_to_subclass_for_known_status(httpserver: HTTPServer) -> None:
+ """Mapped HTTP status codes dispatch to their matching subclass."""
+ httpserver.expect_request('/dispatch').respond_with_json(
+ {'error': {'type': 'record-not-found', 'message': 'nope'}}, status=404
+ )
+ client = ImpitHttpClient()
+
+ with pytest.raises(NotFoundError) as exc:
+ client.call(method='GET', url=str(httpserver.url_for('/dispatch')))
+
+ # Still an ApifyApiError, so legacy `except` handlers keep working.
+ assert isinstance(exc.value, ApifyApiError)
+ assert exc.value.status_code == 404
+ assert exc.value.type == 'record-not-found'
+
+
+def test_apify_api_error_dispatches_streamed_response(httpserver: HTTPServer) -> None:
+ """Dispatch works even when the response body comes in as a stream (403 → ForbiddenError)."""
+ httpserver.expect_request('/stream_dispatch').respond_with_handler(streaming_handler)
+ client = ImpitHttpClient()
+
+ with pytest.raises(ForbiddenError) as exc:
+ client.call(method='GET', url=httpserver.url_for('/stream_dispatch'), stream=True)
+
+ assert isinstance(exc.value, ApifyApiError)
+ assert exc.value.status_code == 403
+ assert exc.value.type == 'insufficient-permissions'
+
+
+def test_apify_api_error_dispatches_5xx_to_server_error(httpserver: HTTPServer) -> None:
+ """Any 5xx status falls under the ServerError subclass."""
+ httpserver.expect_request('/server_error').respond_with_json(
+ {'error': {'type': 'internal-error', 'message': 'boom'}}, status=503
+ )
+ client = ImpitHttpClient(max_retries=1)
+
+ with pytest.raises(ServerError) as exc:
+ client.call(method='GET', url=str(httpserver.url_for('/server_error')))
+
+ assert isinstance(exc.value, ApifyApiError)
+ assert exc.value.status_code == 503
+
+
+def test_apify_api_error_falls_back_for_unmapped_status(httpserver: HTTPServer) -> None:
+ """Statuses without a dedicated subclass fall back to the base ApifyApiError."""
+ httpserver.expect_request('/unmapped').respond_with_json(
+ {'error': {'type': 'whatever', 'message': 'nope'}}, status=418
+ )
+ client = ImpitHttpClient()
+
+ with pytest.raises(ApifyApiError) as exc:
+ client.call(method='GET', url=str(httpserver.url_for('/unmapped')))
+
+ assert type(exc.value) is ApifyApiError
+ assert exc.value.status_code == 418
+ assert exc.value.type == 'whatever'
+
+
+@pytest.mark.parametrize(
+ ('status_code', 'expected_cls'),
+ [
+ pytest.param(400, InvalidRequestError, id='400 → InvalidRequestError'),
+ pytest.param(401, UnauthorizedError, id='401 → UnauthorizedError'),
+ pytest.param(403, ForbiddenError, id='403 → ForbiddenError'),
+ pytest.param(404, NotFoundError, id='404 → NotFoundError'),
+ pytest.param(409, ConflictError, id='409 → ConflictError'),
+ pytest.param(429, RateLimitError, id='429 → RateLimitError'),
+ ],
+)
+def test_apify_api_error_dispatches_all_mapped_statuses(
+ httpserver: HTTPServer, status_code: int, expected_cls: type[ApifyApiError]
+) -> None:
+ """Every status in `_STATUS_TO_CLASS` dispatches to its matching subclass."""
+ httpserver.expect_request('/dispatch_all').respond_with_json(
+ {'error': {'type': 'some-type', 'message': 'msg'}}, status=status_code
+ )
+ # Use max_retries=1 so retryable statuses (429) don't loop during the test.
+ client = ImpitHttpClient(max_retries=1)
+
+ with pytest.raises(expected_cls) as exc:
+ client.call(method='GET', url=str(httpserver.url_for('/dispatch_all')))
+
+ assert type(exc.value) is expected_cls
+ assert isinstance(exc.value, ApifyApiError)
+ assert exc.value.status_code == status_code
+
+
+def test_apify_api_error_falls_back_for_unparsable_body(httpserver: HTTPServer) -> None:
+ """When the body can't be parsed, status-based dispatch still applies and `.type` is None."""
+ httpserver.expect_request('/unparsable').respond_with_data('', status=418, content_type='text/html')
+ client = ImpitHttpClient(max_retries=1)
+
+ with pytest.raises(ApifyApiError) as exc:
+ client.call(method='GET', url=str(httpserver.url_for('/unparsable')))
+
+ assert type(exc.value) is ApifyApiError
+ assert exc.value.type is None
diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py
index 2f4d4cef..d4c6f281 100644
--- a/tests/unit/test_utils.py
+++ b/tests/unit/test_utils.py
@@ -125,7 +125,8 @@ def test__is_not_retryable_error(exc: Exception) -> None:
[
pytest.param(HTTPStatus.NOT_FOUND, 'record-not-found', True, id='404 record-not-found'),
pytest.param(HTTPStatus.NOT_FOUND, 'record-or-token-not-found', True, id='404 token-not-found'),
- pytest.param(HTTPStatus.NOT_FOUND, 'some-other-error', False, id='404 other error type'),
+ pytest.param(HTTPStatus.NOT_FOUND, 'some-other-error', True, id='404 other error type'),
+ pytest.param(HTTPStatus.BAD_REQUEST, 'record-not-found', False, id='400 record-not-found'),
pytest.param(HTTPStatus.INTERNAL_SERVER_ERROR, 'record-not-found', False, id='500 record-not-found'),
],
)
@@ -133,10 +134,10 @@ def test_catch_not_found_or_throw(status_code: HTTPStatus, error_type: str, *, s
"""Test that catch_not_found_or_throw suppresses 404 errors correctly."""
mock_response = Mock()
mock_response.status_code = status_code
+ mock_response.json.return_value = {'error': {'type': error_type, 'message': 'msg'}}
mock_response.text = f'{{"error":{{"type":"{error_type}"}}}}'
error = ApifyApiError(mock_response, 1)
- error.type = error_type
if should_suppress:
catch_not_found_or_throw(error)