Skip to content

Commit 082f090

Browse files
committed
feat: Add apify.errors domain-level error taxonomy
1 parent b5f026f commit 082f090

5 files changed

Lines changed: 384 additions & 0 deletions

File tree

src/apify/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@
1717
from apify._consts import ActorEnvVars, ApifyEnvVars
1818
from apify._proxy_configuration import ProxyConfiguration, ProxyInfo
1919
from apify._webhook import Webhook
20+
from apify.errors import (
21+
ActorRunError,
22+
ActorTimeoutError,
23+
ApifyError,
24+
AuthenticationError,
25+
ChargeLimitExceededError,
26+
InputValidationError,
27+
RateLimitError,
28+
)
2029
from apify.events._types import ActorEventTypes
2130

2231
__version__ = metadata.version('apify')
@@ -25,7 +34,12 @@
2534
'Actor',
2635
'ActorEnvVars',
2736
'ActorEventTypes',
37+
'ActorRunError',
38+
'ActorTimeoutError',
2839
'ApifyEnvVars',
40+
'ApifyError',
41+
'AuthenticationError',
42+
'ChargeLimitExceededError',
2943
'Configuration',
3044
'Event',
3145
'EventAbortingData',
@@ -34,8 +48,10 @@
3448
'EventMigratingData',
3549
'EventPersistStateData',
3650
'EventSystemInfoData',
51+
'InputValidationError',
3752
'ProxyConfiguration',
3853
'ProxyInfo',
54+
'RateLimitError',
3955
'Request',
4056
'Webhook',
4157
'WebhookEventType',

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

0 commit comments

Comments
 (0)