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)