Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/04_upgrading/upgrading_to_v3.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ApiLink to="class/ApifyClient">`ApifyClient`</ApiLink> constructor (see [Tiered timeout system](#tiered-timeout-system) above).

## Exception subclasses for API errors

<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.

The following subclasses are available:

| Status | Subclass |
|---|---|
| 400 | <ApiLink to="class/InvalidRequestError">`InvalidRequestError`</ApiLink> |
| 401 | <ApiLink to="class/UnauthorizedError">`UnauthorizedError`</ApiLink> |
| 403 | <ApiLink to="class/ForbiddenError">`ForbiddenError`</ApiLink> |
| 404 | <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> |
| 409 | <ApiLink to="class/ConflictError">`ConflictError`</ApiLink> |
| 429 | <ApiLink to="class/RateLimitError">`RateLimitError`</ApiLink> |
| 5xx | <ApiLink to="class/ServerError">`ServerError`</ApiLink> |

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 <ApiLink to="class/NotFoundError">`NotFoundError`</ApiLink> 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 <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.
Expand Down
7 changes: 2 additions & 5 deletions src/apify_client/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Copy link
Copy Markdown
Contributor

@Pijukatel Pijukatel Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure about this (but the previous variant had the same issues.)

The problem of swallowing 404s is that you lose the information about what was not found. For example

dataset_1 = client.run(run_id="RUN DOES NOT EXIST").dataset().get() # Run does not exist
dataset_2 = client.run(run_id="RUN EXISTS").dataset().get() # Dataset does not exist

In both cases it returns None and user has no idea which of the cases happened.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably just swallow 404 related to the last resource in the chain and throw all the others?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for bringing this up.

We should probably just swallow 404 related to the last resource in the chain and throw all the others?

I implemented it this way. It's certainly better, but I'm not sure in terms of consistency - now for the get we for some cases return None and for some other raise exception, hmm?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's remove the last commit, merge it as it is, and do this in a dedicated one if there is a consensus.

raise exc


Expand Down
134 changes: 101 additions & 33 deletions src/apify_client/errors.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
Comment thread
vdusek marked this conversation as resolved.

def __init__(self, response: HttpResponse, attempt: int, method: str = 'GET') -> None:
"""Initialize the API error from a failed response.

Expand All @@ -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:
Expand All @@ -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,
}
110 changes: 109 additions & 1 deletion tests/unit/test_client_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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('<not json>', 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
Loading
Loading