Skip to content

Commit 9351189

Browse files
committed
feat: Add apify.errors domain-level error taxonomy
1 parent b609955 commit 9351189

5 files changed

Lines changed: 344 additions & 18 deletions

File tree

src/apify/_utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def is_running_in_ipython() -> bool:
7474
'Actor',
7575
'Charging',
7676
'Configuration',
77+
'Errors',
7778
'Event data',
7879
'Event managers',
7980
'Events',

src/apify/errors.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
from apify_client.errors import ForbiddenError as _ForbiddenError
6+
from apify_client.errors import InvalidRequestError as _InvalidRequestError
7+
from apify_client.errors import RateLimitError as _RateLimitError
8+
from apify_client.errors import ServerError as _ServerError
9+
from apify_client.errors import UnauthorizedError as _UnauthorizedError
10+
11+
from apify._utils import docs_group
12+
13+
if TYPE_CHECKING:
14+
from apify_client._models import Run
15+
16+
17+
@docs_group('Errors')
18+
class ActorError(Exception):
19+
"""Base class for all domain-level Apify SDK errors.
20+
21+
Carries a machine-readable `code` and a `retryable` flag so callers can branch on a failure without reading
22+
the human-readable error message.
23+
"""
24+
25+
code: str = 'actor-error'
26+
"""Stable, machine-readable identifier of the error category."""
27+
28+
retryable: bool = False
29+
"""Whether retrying the same operation might succeed (e.g. a transient rate limit or server error)."""
30+
31+
def __init__(
32+
self,
33+
message: str | None = None,
34+
*,
35+
code: str | None = None,
36+
retryable: bool | None = None,
37+
) -> None:
38+
super().__init__(message)
39+
if code is not None:
40+
self.code = code
41+
if retryable is not None:
42+
self.retryable = retryable
43+
44+
@classmethod
45+
def from_client_error(cls, error: Exception) -> ActorError:
46+
"""Map an `apify_client` exception to the matching domain-level error.
47+
48+
The mapping is driven by the client's typed, HTTP-status-based exceptions. Unmapped client errors (and any
49+
other exception) fall back to a plain `ActorError`. The original exception is not chained automatically;
50+
callers should use `raise ActorError.from_client_error(err) from err`.
51+
52+
Args:
53+
error: The exception raised by `apify_client`.
54+
55+
Returns:
56+
The corresponding domain-level error.
57+
"""
58+
if isinstance(error, (_UnauthorizedError, _ForbiddenError)):
59+
return ActorAuthenticationError(str(error))
60+
61+
if isinstance(error, _RateLimitError):
62+
return ActorRateLimitError(str(error))
63+
64+
if isinstance(error, _ServerError):
65+
return ActorError(str(error), retryable=True)
66+
67+
if isinstance(error, _InvalidRequestError):
68+
return ActorInputValidationError(str(error))
69+
70+
return ActorError(str(error))
71+
72+
73+
@docs_group('Errors')
74+
class ActorRunError(ActorError):
75+
"""Raised when an Actor run reaches a terminal failure state (e.g. `FAILED` or `ABORTED`).
76+
77+
Unlike the HTTP-derived errors, this one is derived from the run itself, so it exposes the run metadata needed
78+
to decide what to do next.
79+
"""
80+
81+
code = 'actor-run-failed'
82+
83+
def __init__(self, run: Run) -> None:
84+
self.run_id = run.id
85+
self.status = run.status
86+
self.exit_code = run.exit_code
87+
self.status_message = run.status_message
88+
89+
message = f'Actor run {run.id!r} ended with status {run.status!r}'
90+
if run.status_message:
91+
message = f'{message}: {run.status_message}'
92+
93+
super().__init__(message)
94+
95+
@classmethod
96+
def from_run(cls, run: Run) -> ActorRunError:
97+
"""Build the most specific run error for a terminal Actor run.
98+
99+
Args:
100+
run: The terminal Actor run.
101+
102+
Returns:
103+
An `ActorTimeoutError` for a timed-out run, otherwise an `ActorRunError`.
104+
"""
105+
if run.status == 'TIMED-OUT':
106+
return ActorTimeoutError(run)
107+
return ActorRunError(run)
108+
109+
110+
@docs_group('Errors')
111+
class ActorTimeoutError(ActorRunError):
112+
"""Raised when an Actor run exceeds its timeout (`TIMED-OUT`). Retrying with a longer timeout may help."""
113+
114+
code = 'actor-timed-out'
115+
retryable = True
116+
117+
118+
@docs_group('Errors')
119+
class ActorInputValidationError(ActorError, ValueError):
120+
"""Raised when input fails validation.
121+
122+
Subclasses `ValueError` so existing `except ValueError` handlers keep catching it.
123+
"""
124+
125+
code = 'input-validation-error'
126+
127+
128+
@docs_group('Errors')
129+
class ActorChargeLimitExceededError(ActorError):
130+
"""Raised when an Actor run hits its configured maximum total charge (`max_total_charge_usd`)."""
131+
132+
code = 'charge-limit-exceeded'
133+
134+
135+
@docs_group('Errors')
136+
class ActorAuthenticationError(ActorError):
137+
"""Raised when an API request is unauthorized or forbidden (HTTP 401 / 403)."""
138+
139+
code = 'authentication-error'
140+
141+
142+
@docs_group('Errors')
143+
class ActorRateLimitError(ActorError):
144+
"""Raised when the Apify API rate limit is exceeded (HTTP 429). Retryable after a backoff."""
145+
146+
code = 'rate-limit-exceeded'
147+
retryable = True
148+
149+
150+
__all__ = [
151+
'ActorAuthenticationError',
152+
'ActorChargeLimitExceededError',
153+
'ActorError',
154+
'ActorInputValidationError',
155+
'ActorRateLimitError',
156+
'ActorRunError',
157+
'ActorTimeoutError',
158+
]

tests/unit/actor/test_configuration.py

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -392,21 +392,3 @@ def test_actor_storage_json_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
392392
assert config.actor_storages['datasets'] == datasets
393393
assert config.actor_storages['request_queues'] == request_queues
394394
assert config.actor_storages['key_value_stores'] == key_value_stores
395-
396-
397-
@pytest.mark.parametrize(
398-
('env_var', 'attr', 'expected'),
399-
[
400-
('APIFY_TIMEOUT_AT', 'timeout_at', None),
401-
('ACTOR_MAX_PAID_DATASET_ITEMS', 'max_paid_dataset_items', None),
402-
('ACTOR_MAX_TOTAL_CHARGE_USD', 'max_total_charge_usd', None),
403-
('APIFY_USER_IS_PAYING', 'user_is_paying', False),
404-
],
405-
)
406-
def test_typed_env_var_empty_string_falls_back_to_default(
407-
monkeypatch: pytest.MonkeyPatch, env_var: str, attr: str, expected: object
408-
) -> None:
409-
"""Platform may set a typed env var to '' instead of leaving it unset; that must not crash `Actor.init()`."""
410-
monkeypatch.setenv(env_var, '')
411-
config = ApifyConfiguration()
412-
assert getattr(config, attr) == expected

tests/unit/test_errors.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
from __future__ import annotations
2+
3+
from datetime import UTC, datetime
4+
from typing import Any, cast
5+
6+
import pytest
7+
8+
from apify_client._models import Run
9+
from apify_client.errors import (
10+
ApifyApiError,
11+
ConflictError,
12+
ForbiddenError,
13+
InvalidRequestError,
14+
NotFoundError,
15+
ServerError,
16+
UnauthorizedError,
17+
)
18+
from apify_client.errors import RateLimitError as ClientRateLimitError
19+
20+
import apify
21+
from apify.errors import (
22+
ActorAuthenticationError,
23+
ActorChargeLimitExceededError,
24+
ActorError,
25+
ActorInputValidationError,
26+
ActorRateLimitError,
27+
ActorRunError,
28+
ActorTimeoutError,
29+
)
30+
31+
32+
class _FakeResponse:
33+
"""Minimal stand-in for `apify_client`'s HTTP response, enough to build its API errors."""
34+
35+
def __init__(self, status_code: int) -> None:
36+
self.status_code = status_code
37+
self.text = 'error text'
38+
39+
def json(self) -> dict[str, Any]:
40+
return {'error': {'message': 'boom', 'type': 'some-error-type'}}
41+
42+
43+
def _client_error(error_cls: type[ApifyApiError], status_code: int) -> ApifyApiError:
44+
return error_cls(cast('Any', _FakeResponse(status_code)), 1)
45+
46+
47+
def _make_run(*, status: str, exit_code: int | None = None, status_message: str | None = None) -> Run:
48+
return Run.model_validate(
49+
{
50+
'id': 'run123',
51+
'actId': 'act123',
52+
'userId': 'user123',
53+
'startedAt': datetime.now(UTC).isoformat(),
54+
'status': status,
55+
'statusMessage': status_message,
56+
'exitCode': exit_code,
57+
'meta': {'origin': 'DEVELOPMENT'},
58+
'buildId': 'build123',
59+
'defaultDatasetId': 'ds123',
60+
'defaultKeyValueStoreId': 'kvs123',
61+
'defaultRequestQueueId': 'rq123',
62+
'containerUrl': 'https://container',
63+
'buildNumber': '0.0.1',
64+
'generalAccess': 'RESTRICTED',
65+
'stats': {'restartCount': 0, 'resurrectCount': 0, 'computeUnits': 1},
66+
'options': {'build': 'latest', 'timeoutSecs': 4, 'memoryMbytes': 1024, 'diskMbytes': 1024},
67+
}
68+
)
69+
70+
71+
def test_actor_error_defaults() -> None:
72+
error = ActorError('something went wrong')
73+
assert error.code == 'apify-error'
74+
assert error.retryable is False
75+
assert str(error) == 'something went wrong'
76+
77+
78+
def test_actor_error_overrides_are_instance_scoped() -> None:
79+
error = ActorError('boom', code='custom', retryable=True)
80+
assert error.code == 'custom'
81+
assert error.retryable is True
82+
# Overriding on an instance must not leak to the class default.
83+
assert ActorError.code == 'apify-error'
84+
assert ActorError.retryable is False
85+
86+
87+
@pytest.mark.parametrize(
88+
('error_cls', 'expected_code', 'expected_retryable'),
89+
[
90+
(ActorRateLimitError, 'rate-limit-exceeded', True),
91+
(ActorTimeoutError, 'actor-timed-out', True),
92+
(ActorAuthenticationError, 'authentication-error', False),
93+
(ActorChargeLimitExceededError, 'charge-limit-exceeded', False),
94+
(ActorInputValidationError, 'input-validation-error', False),
95+
(ActorRunError, 'actor-run-failed', False),
96+
],
97+
)
98+
def test_subclass_codes_and_retryable(
99+
error_cls: type[ActorError], expected_code: str, *, expected_retryable: bool
100+
) -> None:
101+
assert error_cls.code == expected_code
102+
assert error_cls.retryable is expected_retryable
103+
assert issubclass(error_cls, ActorError)
104+
105+
106+
def test_input_validation_error_is_value_error() -> None:
107+
"""`except ValueError` must still catch `ActorInputValidationError`."""
108+
with pytest.raises(ValueError, match='bad input'):
109+
raise ActorInputValidationError('bad input')
110+
111+
112+
def test_actor_timeout_error_is_actor_run_error() -> None:
113+
assert issubclass(ActorTimeoutError, ActorRunError)
114+
115+
116+
def test_actor_run_error_carries_run_metadata() -> None:
117+
run = _make_run(status='FAILED', exit_code=1, status_message='Actor crashed')
118+
error = ActorRunError(run)
119+
assert error.run_id == 'run123'
120+
assert error.status == 'FAILED'
121+
assert error.exit_code == 1
122+
assert error.status_message == 'Actor crashed'
123+
assert error.retryable is False
124+
assert 'run123' in str(error)
125+
assert 'Actor crashed' in str(error)
126+
127+
128+
def test_actor_run_error_from_run_failed() -> None:
129+
error = ActorRunError.from_run(_make_run(status='FAILED'))
130+
assert type(error) is ActorRunError
131+
assert not error.retryable
132+
133+
134+
def test_actor_run_error_from_run_timed_out() -> None:
135+
error = ActorRunError.from_run(_make_run(status='TIMED-OUT'))
136+
assert isinstance(error, ActorTimeoutError)
137+
assert error.retryable is True
138+
assert error.run_id == 'run123'
139+
assert error.code == 'actor-timed-out'
140+
141+
142+
@pytest.mark.parametrize(
143+
('client_error', 'expected_cls', 'expected_retryable'),
144+
[
145+
(_client_error(UnauthorizedError, 401), ActorAuthenticationError, False),
146+
(_client_error(ForbiddenError, 403), ActorAuthenticationError, False),
147+
(_client_error(ClientRateLimitError, 429), ActorRateLimitError, True),
148+
(_client_error(ServerError, 500), ActorError, True),
149+
(_client_error(InvalidRequestError, 400), ActorInputValidationError, False),
150+
(_client_error(NotFoundError, 404), ActorError, False),
151+
(_client_error(ConflictError, 409), ActorError, False),
152+
],
153+
)
154+
def test_from_client_error_mapping(
155+
client_error: ApifyApiError,
156+
expected_cls: type[ActorError],
157+
*,
158+
expected_retryable: bool,
159+
) -> None:
160+
mapped = ActorError.from_client_error(client_error)
161+
assert type(mapped) is expected_cls
162+
assert mapped.retryable is expected_retryable
163+
164+
165+
def test_from_client_error_unknown_exception_falls_back() -> None:
166+
mapped = ActorError.from_client_error(RuntimeError('not a client error'))
167+
assert type(mapped) is ActorError
168+
assert mapped.retryable is False
169+
assert 'not a client error' in str(mapped)
170+
171+
172+
def test_errors_exported_from_top_level() -> None:
173+
for name in (
174+
'ActorError',
175+
'ActorRunError',
176+
'ActorTimeoutError',
177+
'ActorAuthenticationError',
178+
'ActorChargeLimitExceededError',
179+
'ActorInputValidationError',
180+
'ActorRateLimitError',
181+
):
182+
assert hasattr(apify, name)
183+
assert name in apify.__all__
184+
assert getattr(apify, name) is getattr(apify.errors, name)

website/docusaurus.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const GROUP_ORDER = [
99
'Actor',
1010
'Charging',
1111
'Configuration',
12+
'Errors',
1213
'Event data',
1314
'Event managers',
1415
'Events',

0 commit comments

Comments
 (0)