Skip to content

Commit d12c0e0

Browse files
committed
refactor: drop SDK error classes, keep apify.errors as a pure re-export
1 parent d921ed5 commit d12c0e0

7 files changed

Lines changed: 51 additions & 214 deletions

File tree

docs/02_concepts/13_exceptions.mdx

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import RetryTimedOutSource from '!!raw-loader!roa-loader!./code/13_retry_timed_o
99
import ApiLink from '@theme/ApiLink';
1010
import RunnableCodeBlock from '@site/src/components/RunnableCodeBlock';
1111

12-
When an Actor runs, failures surface from two places. The [Apify API client](https://docs.apify.com/api/client/python) raises typed exceptions for failed API requests. The SDK adds a small set of its own errors for outcomes the client cannot express, such as a sub-Actor run that finishes in a failure state. Both are available from the `apify.errors` module.
12+
When you run an Actor, exceptions come from a few layers: the Apify API client for failed API requests, the Apify SDK for misuse and invalid input, and the libraries you build on, such as Crawlee. This page maps the ones you are most likely to meet and how to approach each.
1313

1414
## Errors from the Apify API
1515

16-
Every SDK operation that talks to the Apify API can raise `ApifyApiError`. This includes <ApiLink to="class/Actor#start">`Actor.start`</ApiLink>, <ApiLink to="class/Actor#call">`Actor.call`</ApiLink>, `Actor.abort`, `Actor.metamorph`, `Actor.add_webhook`, charging, and all storage operations on datasets, key-value stores, and request queues. The SDK raises these client exceptions as-is. It does not wrap them, so you keep the HTTP status code, the error type, and the response data on the exception.
16+
Every SDK operation that talks to the Apify API can raise `ApifyApiError`. This includes <ApiLink to="class/Actor#start">`Actor.start`</ApiLink>, <ApiLink to="class/Actor#call">`Actor.call`</ApiLink>, `Actor.abort`, `Actor.metamorph`, `Actor.add_webhook`, charging, and all storage operations on datasets, key-value stores, and request queues. The SDK raises these client exceptions as-is, so you keep the HTTP status code, the error type, and the response data on the exception.
1717

1818
`ApifyApiError` dispatches to a subclass based on the HTTP status code:
1919

@@ -24,34 +24,39 @@ Every SDK operation that talks to the Apify API can raise `ApifyApiError`. This
2424
- `ServerError` for any 5xx response.
2525
- `InvalidRequestError` (400) when the API rejects the request as malformed.
2626

27-
The client retries rate-limited and server errors on its own, so you only see `RateLimitError` or `ServerError` once those retries are exhausted. For convenience, `apify.errors` re-exports the whole client error hierarchy, so you can import everything from one place:
27+
The client retries rate-limited and server errors on its own, so you only see `RateLimitError` or `ServerError` once those retries are exhausted. The `apify.errors` module re-exports the whole client error hierarchy, so you can import everything from one place:
2828

2929
```python
3030
from apify.errors import ApifyApiError, NotFoundError, RateLimitError
3131
```
3232

33-
## Actor run failures
34-
35-
<ApiLink to="class/Actor#call">`Actor.call`</ApiLink> and <ApiLink to="class/Actor#call_task">`Actor.call_task`</ApiLink> wait for the run to finish and return it, whatever its final status. A finished run can be `SUCCEEDED`, `FAILED`, `ABORTED`, or `TIMED-OUT`, so check `run.status` before you rely on the run's output.
36-
37-
To turn a failed run into an exception, build one from the run with <ApiLink to="class/ActorRunError">`ActorRunError`</ApiLink>`.from_run()`. It returns an <ApiLink to="class/ActorTimeoutError">`ActorTimeoutError`</ApiLink> for a timed-out run, and an `ActorRunError` otherwise. Both carry the run metadata you need to decide what to do next: `run_id`, `status`, `exit_code`, and `status_message`. Every SDK error also derives from <ApiLink to="class/ActorError">`ActorError`</ApiLink> and exposes a stable `code` and a `retryable` flag.
38-
39-
## Handling errors
40-
41-
Wrap the call to catch an API failure, then inspect the finished run and escalate it if needed:
33+
Catch `ApifyApiError` to handle any API failure in one place, then branch on the subclass or the HTTP `status_code`. To react to a specific failure, catch its subclass first:
4234

4335
<RunnableCodeBlock className="language-python" language="python">
4436
{HandleCallErrorsSource}
4537
</RunnableCodeBlock>
4638

47-
## Retrying a timed-out run
39+
## Misuse and invalid input
40+
41+
The SDK raises standard Python exceptions when it is used incorrectly or given invalid input. These point to a bug or a bad argument in your code, so the fix is to correct the call rather than to catch the exception.
4842

49-
A timed-out run is the one failure where retrying can help, as long as you give it more time. `ActorTimeoutError` sets `retryable` to `True` to mark this. Retry with a longer timeout rather than the same one:
43+
- `RuntimeError` when an `Actor` method is used outside the `async with Actor:` block, either before initialization or after exit, or when the Actor is initialized twice.
44+
- `ValueError` for an invalid argument, such as a malformed `timeout`, an invalid proxy configuration, charging an automatically charged event by hand, or pushing data that is not JSON-serializable or is over the size limit.
45+
- `TypeError` for an argument of the wrong type.
46+
- `ConnectionError` when <ApiLink to="class/Actor#create_proxy_configuration">`Actor.create_proxy_configuration`</ApiLink> verifies Apify Proxy access and the proxy reports that you have none.
47+
48+
## Run failures
49+
50+
<ApiLink to="class/Actor#call">`Actor.call`</ApiLink> and <ApiLink to="class/Actor#call_task">`Actor.call_task`</ApiLink> wait for the run to finish and return it, whatever its final status. A finished run can be `SUCCEEDED`, `FAILED`, `ABORTED`, or `TIMED-OUT`, so check `run.status` before you rely on the run's output. A timed-out run is the one case where retrying can help, as long as you give it more time:
5051

5152
<RunnableCodeBlock className="language-python" language="python">
5253
{RetryTimedOutSource}
5354
</RunnableCodeBlock>
5455

56+
## Errors while crawling
57+
58+
If your Actor runs a [Crawlee](https://crawlee.dev/python) crawler, failures inside request handlers surface as Crawlee exceptions, and Crawlee manages retries and session rotation around them. For details, see the [Crawlee documentation](https://crawlee.dev/python).
59+
5560
## The pay-per-event charge limit
5661

5762
Reaching the pay-per-event charge limit does not raise an error. The SDK caps charging and data pushing instead, and your Actor keeps running. To detect the limit, check the `event_charge_limit_reached` field on the `ChargeResult` returned by <ApiLink to="class/Actor#charge">`Actor.charge`</ApiLink> or `Actor.push_data`. For details, see [Pay-per-event monetization](./pay-per-event).

docs/02_concepts/code/13_handle_call_errors.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
import asyncio
22

33
from apify import Actor
4-
from apify.errors import ActorRunError, ApifyApiError
4+
from apify.errors import ApifyApiError, NotFoundError
55

66

77
async def main() -> None:
88
async with Actor:
99
try:
1010
run = await Actor.call('apify/web-scraper', run_input={'startUrls': []})
11+
except NotFoundError:
12+
# Catch a specific subclass first.
13+
Actor.log.error('The Actor to call does not exist.')
14+
return
1115
except ApifyApiError as exc:
12-
# The Apify API rejected the request, e.g. the Actor does not exist or
13-
# the token is invalid. The HTTP status code is on the exception.
14-
Actor.log.error(f'Could not start the Actor: {exc} (HTTP {exc.status_code}).')
16+
# Any other API failure, e.g. an invalid token or a server error.
17+
Actor.log.error(f'Calling the Actor failed: {exc} (HTTP {exc.status_code}).')
1518
return
1619

1720
# `Actor.call` returns the finished run whatever its status, so check it.
1821
if run.status != 'SUCCEEDED':
19-
raise ActorRunError.from_run(run)
22+
Actor.log.error(f'Run {run.id} ended with status {run.status}.')
23+
return
2024

2125
Actor.log.info(f'Run {run.id} finished successfully.')
2226

docs/02_concepts/code/13_retry_timed_out.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from datetime import timedelta
33

44
from apify import Actor
5-
from apify.errors import ActorRunError, ActorTimeoutError
65

76

87
async def main() -> None:
@@ -13,18 +12,12 @@ async def main() -> None:
1312
for attempt in range(1, max_attempts + 1):
1413
run = await Actor.call('apify/web-scraper', timeout=timeout)
1514

16-
if run.status == 'SUCCEEDED':
17-
Actor.log.info(f'Run {run.id} finished.')
15+
if run.status != 'TIMED-OUT' or attempt == max_attempts:
16+
Actor.log.info(f'Run {run.id} ended with status {run.status}.')
1817
break
1918

20-
# Build a typed error from the finished run so we can branch on it.
21-
error = ActorRunError.from_run(run)
22-
if isinstance(error, ActorTimeoutError) and attempt < max_attempts:
23-
timeout *= 2
24-
Actor.log.warning(f'Timed out, retrying with timeout {timeout}.')
25-
continue
26-
27-
raise error
19+
timeout *= 2
20+
Actor.log.warning(f'Timed out, retrying with timeout {timeout}.')
2821

2922

3023
if __name__ == '__main__':

src/apify/_utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ def is_running_in_ipython() -> bool:
7474
'Actor',
7575
'Charging',
7676
'Configuration',
77-
'Errors',
7877
'Event data',
7978
'Event managers',
8079
'Events',

src/apify/errors.py

Lines changed: 3 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
4-
5-
# Re-export the Apify API client's error hierarchy so callers have a single import location for every error the SDK
6-
# can surface. Any operation that talks to the Apify API raises these as-is; the SDK does not wrap them in its own
7-
# types. See https://docs.apify.com/api/client/python for the full client error reference.
3+
# `apify.errors` re-exports the Apify API client's error hierarchy so callers have a single import location for every
4+
# error raised by an operation that talks to the Apify API. The SDK raises these client exceptions as-is and does not
5+
# wrap them in its own types. See https://docs.apify.com/api/client/python for the full client error reference.
86
from apify_client.errors import (
97
ApifyApiError,
108
ApifyClientError,
@@ -18,94 +16,7 @@
1816
UnauthorizedError,
1917
)
2018

21-
from apify._utils import docs_group
22-
23-
if TYPE_CHECKING:
24-
from apify_client._models import Run
25-
26-
27-
@docs_group('Errors')
28-
class ActorError(Exception):
29-
"""Base class for the Apify SDK's own domain-level errors.
30-
31-
These describe outcomes that the Apify API client cannot express on its own, such as a finished Actor run that
32-
ended in a failure state. Errors that originate from the Apify API surface as `apify_client` exceptions (e.g.
33-
`ApifyApiError` and its subclasses), which the SDK re-exports from this module but does not wrap.
34-
35-
Carries a machine-readable `code` and a `retryable` flag so callers can branch on a failure without parsing the
36-
human-readable error message.
37-
"""
38-
39-
code: str = 'actor-error'
40-
"""Stable, machine-readable identifier of the error category."""
41-
42-
retryable: bool = False
43-
"""Whether retrying the same operation might succeed (e.g. an Actor run that timed out)."""
44-
45-
def __init__(
46-
self,
47-
message: str | None = None,
48-
*,
49-
code: str | None = None,
50-
retryable: bool | None = None,
51-
) -> None:
52-
super().__init__(message)
53-
if code is not None:
54-
self.code = code
55-
if retryable is not None:
56-
self.retryable = retryable
57-
58-
59-
@docs_group('Errors')
60-
class ActorRunError(ActorError):
61-
"""Represents an Actor run that reached a terminal failure state (e.g. `FAILED` or `ABORTED`).
62-
63-
Exposes the run metadata needed to decide what to do next. The SDK does not raise this automatically. `Actor.call`
64-
and `Actor.call_task` return the finished run regardless of its status, mirroring the Apify API client. Build this
65-
error from a finished run with `from_run` when you want a failed run to surface as an exception in your own code.
66-
"""
67-
68-
code = 'actor-run-failed'
69-
70-
def __init__(self, run: Run) -> None:
71-
self.run_id = run.id
72-
self.status = run.status
73-
self.exit_code = run.exit_code
74-
self.status_message = run.status_message
75-
76-
message = f'Actor run {run.id!r} ended with status {run.status!r}'
77-
if run.status_message:
78-
message = f'{message}: {run.status_message}'
79-
80-
super().__init__(message)
81-
82-
@classmethod
83-
def from_run(cls, run: Run) -> ActorRunError:
84-
"""Build the most specific run error for a terminal Actor run.
85-
86-
Args:
87-
run: The terminal Actor run.
88-
89-
Returns:
90-
An `ActorTimeoutError` for a timed-out run, otherwise an `ActorRunError`.
91-
"""
92-
if run.status == 'TIMED-OUT':
93-
return ActorTimeoutError(run)
94-
return ActorRunError(run)
95-
96-
97-
@docs_group('Errors')
98-
class ActorTimeoutError(ActorRunError):
99-
"""Represents an Actor run that exceeded its timeout (`TIMED-OUT`). Retrying with a longer timeout may help."""
100-
101-
code = 'actor-timed-out'
102-
retryable = True
103-
104-
10519
__all__ = [
106-
'ActorError',
107-
'ActorRunError',
108-
'ActorTimeoutError',
10920
'ApifyApiError',
11021
'ApifyClientError',
11122
'ConflictError',

tests/unit/test_errors.py

Lines changed: 16 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,24 @@
11
from __future__ import annotations
22

3-
from datetime import UTC, datetime
4-
53
import apify_client.errors as client_errors
6-
from apify_client._models import Run
7-
8-
from apify.errors import ActorError, ActorRunError, ActorTimeoutError
9-
10-
11-
def _make_run(*, status: str, exit_code: int | None = None, status_message: str | None = None) -> Run:
12-
return Run.model_validate(
13-
{
14-
'id': 'run123',
15-
'actId': 'act123',
16-
'userId': 'user123',
17-
'startedAt': datetime.now(UTC).isoformat(),
18-
'status': status,
19-
'statusMessage': status_message,
20-
'exitCode': exit_code,
21-
'meta': {'origin': 'DEVELOPMENT'},
22-
'buildId': 'build123',
23-
'defaultDatasetId': 'ds123',
24-
'defaultKeyValueStoreId': 'kvs123',
25-
'defaultRequestQueueId': 'rq123',
26-
'containerUrl': 'https://container',
27-
'buildNumber': '0.0.1',
28-
'generalAccess': 'RESTRICTED',
29-
'stats': {'restartCount': 0, 'resurrectCount': 0, 'computeUnits': 1},
30-
'options': {'build': 'latest', 'timeoutSecs': 4, 'memoryMbytes': 1024, 'diskMbytes': 1024},
31-
}
32-
)
33-
34-
35-
def test_actor_error_defaults() -> None:
36-
error = ActorError('something went wrong')
37-
assert error.code == 'actor-error'
38-
assert error.retryable is False
39-
assert str(error) == 'something went wrong'
40-
41-
42-
def test_actor_error_overrides_are_instance_scoped() -> None:
43-
error = ActorError('boom', code='custom', retryable=True)
44-
assert error.code == 'custom'
45-
assert error.retryable is True
46-
# Overriding on an instance must not leak to the class default.
47-
assert ActorError.code == 'actor-error'
48-
assert ActorError.retryable is False
49-
504

51-
def test_actor_run_error_is_actor_error() -> None:
52-
assert issubclass(ActorRunError, ActorError)
53-
assert ActorRunError.code == 'actor-run-failed'
54-
assert ActorRunError.retryable is False
55-
56-
57-
def test_actor_timeout_error_is_actor_run_error() -> None:
58-
assert issubclass(ActorTimeoutError, ActorRunError)
59-
assert ActorTimeoutError.code == 'actor-timed-out'
60-
assert ActorTimeoutError.retryable is True
61-
62-
63-
def test_actor_run_error_carries_run_metadata() -> None:
64-
run = _make_run(status='FAILED', exit_code=1, status_message='Actor crashed')
65-
error = ActorRunError(run)
66-
assert error.run_id == 'run123'
67-
assert error.status == 'FAILED'
68-
assert error.exit_code == 1
69-
assert error.status_message == 'Actor crashed'
70-
assert error.retryable is False
71-
assert 'run123' in str(error)
72-
assert 'Actor crashed' in str(error)
73-
74-
75-
def test_actor_run_error_from_run_failed() -> None:
76-
error = ActorRunError.from_run(_make_run(status='FAILED'))
77-
assert type(error) is ActorRunError
78-
assert not error.retryable
79-
80-
81-
def test_actor_run_error_from_run_timed_out() -> None:
82-
error = ActorRunError.from_run(_make_run(status='TIMED-OUT'))
83-
assert isinstance(error, ActorTimeoutError)
84-
assert error.retryable is True
85-
assert error.run_id == 'run123'
86-
assert error.code == 'actor-timed-out'
5+
import apify.errors as sdk_errors
876

887

898
def test_client_errors_are_re_exported() -> None:
909
"""`apify.errors` re-exports the API client error hierarchy so callers have a single import location."""
91-
from apify.errors import ApifyApiError, ApifyClientError, NotFoundError, RateLimitError
92-
93-
assert ApifyApiError is client_errors.ApifyApiError
94-
assert ApifyClientError is client_errors.ApifyClientError
95-
assert NotFoundError is client_errors.NotFoundError
96-
assert RateLimitError is client_errors.RateLimitError
97-
# The re-exported API errors are independent of the SDK's own `ActorError` tree.
98-
assert not issubclass(client_errors.ApifyApiError, ActorError)
10+
names = [
11+
'ApifyApiError',
12+
'ApifyClientError',
13+
'ConflictError',
14+
'ForbiddenError',
15+
'InvalidRequestError',
16+
'InvalidResponseBodyError',
17+
'NotFoundError',
18+
'RateLimitError',
19+
'ServerError',
20+
'UnauthorizedError',
21+
]
22+
assert set(sdk_errors.__all__) == set(names)
23+
for name in names:
24+
assert getattr(sdk_errors, name) is getattr(client_errors, name)

website/docusaurus.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ const GROUP_ORDER = [
99
'Actor',
1010
'Charging',
1111
'Configuration',
12-
'Errors',
1312
'Event data',
1413
'Event managers',
1514
'Events',

0 commit comments

Comments
 (0)