Skip to content

Commit 4d36162

Browse files
committed
feat: map apify-client errors to domain-level errors at API call sites
1 parent 9351189 commit 4d36162

7 files changed

Lines changed: 175 additions & 114 deletions

File tree

src/apify/_actor.py

Lines changed: 67 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from apify._proxy_configuration import ProxyConfiguration
3333
from apify._utils import docs_group, docs_name, ensure_context, get_system_info, is_running_in_ipython
3434
from apify._webhook import to_client_representations
35+
from apify.errors import map_client_errors
3536
from apify.events import ApifyEventManager, EventManager, LocalEventManager
3637
from apify.log import _configure_logging, logger
3738
from apify.storage_clients import ApifyStorageClient, SmartApifyStorageClient
@@ -936,17 +937,18 @@ async def start(
936937
raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.')
937938

938939
actor_client = client.actor(actor_id)
939-
return await actor_client.start(
940-
run_input=run_input,
941-
content_type=content_type,
942-
build=build,
943-
max_total_charge_usd=max_total_charge_usd,
944-
restart_on_error=restart_on_error,
945-
memory_mbytes=memory_mbytes,
946-
run_timeout=actor_start_timeout,
947-
force_permission_level=force_permission_level,
948-
webhooks=to_client_representations(webhooks),
949-
)
940+
with map_client_errors():
941+
return await actor_client.start(
942+
run_input=run_input,
943+
content_type=content_type,
944+
build=build,
945+
max_total_charge_usd=max_total_charge_usd,
946+
restart_on_error=restart_on_error,
947+
memory_mbytes=memory_mbytes,
948+
run_timeout=actor_start_timeout,
949+
force_permission_level=force_permission_level,
950+
webhooks=to_client_representations(webhooks),
951+
)
950952

951953
@_ensure_context
952954
async def abort(
@@ -975,10 +977,11 @@ async def abort(
975977
client = self.new_client(token=token) if token else self.apify_client
976978
run_client = client.run(run_id)
977979

978-
if status_message:
979-
await run_client.update(status_message=status_message)
980+
with map_client_errors():
981+
if status_message:
982+
await run_client.update(status_message=status_message)
980983

981-
run = await run_client.abort(gracefully=gracefully)
984+
run = await run_client.abort(gracefully=gracefully)
982985

983986
if run is None:
984987
raise RuntimeError(f'Failed to abort Actor run with ID "{run_id}".')
@@ -1047,19 +1050,20 @@ async def call(
10471050
raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.')
10481051

10491052
actor_client = client.actor(actor_id)
1050-
run = await actor_client.call(
1051-
run_input=run_input,
1052-
content_type=content_type,
1053-
build=build,
1054-
max_total_charge_usd=max_total_charge_usd,
1055-
restart_on_error=restart_on_error,
1056-
memory_mbytes=memory_mbytes,
1057-
run_timeout=actor_call_timeout,
1058-
force_permission_level=force_permission_level,
1059-
webhooks=to_client_representations(webhooks),
1060-
wait_duration=wait,
1061-
logger=logger,
1062-
)
1053+
with map_client_errors():
1054+
run = await actor_client.call(
1055+
run_input=run_input,
1056+
content_type=content_type,
1057+
build=build,
1058+
max_total_charge_usd=max_total_charge_usd,
1059+
restart_on_error=restart_on_error,
1060+
memory_mbytes=memory_mbytes,
1061+
run_timeout=actor_call_timeout,
1062+
force_permission_level=force_permission_level,
1063+
webhooks=to_client_representations(webhooks),
1064+
wait_duration=wait,
1065+
logger=logger,
1066+
)
10631067

10641068
if run is None:
10651069
raise RuntimeError(f'Failed to call Actor with ID "{actor_id}".')
@@ -1120,15 +1124,16 @@ async def call_task(
11201124
raise ValueError(f'Invalid timeout {timeout!r}: expected `None`, `"inherit"`, or a `timedelta`.')
11211125

11221126
task_client = client.task(task_id)
1123-
run = await task_client.call(
1124-
task_input=task_input,
1125-
build=build,
1126-
restart_on_error=restart_on_error,
1127-
memory_mbytes=memory_mbytes,
1128-
run_timeout=task_call_timeout,
1129-
webhooks=to_client_representations(webhooks),
1130-
wait_duration=wait,
1131-
)
1127+
with map_client_errors():
1128+
run = await task_client.call(
1129+
task_input=task_input,
1130+
build=build,
1131+
restart_on_error=restart_on_error,
1132+
memory_mbytes=memory_mbytes,
1133+
run_timeout=task_call_timeout,
1134+
webhooks=to_client_representations(webhooks),
1135+
wait_duration=wait,
1136+
)
11321137

11331138
if run is None:
11341139
raise RuntimeError(f'Failed to call Task with ID "{task_id}".')
@@ -1171,12 +1176,13 @@ async def metamorph(
11711176
if not self.configuration.actor_run_id:
11721177
raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.')
11731178

1174-
await self.apify_client.run(self.configuration.actor_run_id).metamorph(
1175-
target_actor_id=target_actor_id,
1176-
run_input=run_input,
1177-
target_actor_build=target_actor_build,
1178-
content_type=content_type,
1179-
)
1179+
with map_client_errors():
1180+
await self.apify_client.run(self.configuration.actor_run_id).metamorph(
1181+
target_actor_id=target_actor_id,
1182+
run_input=run_input,
1183+
target_actor_build=target_actor_build,
1184+
content_type=content_type,
1185+
)
11801186

11811187
if custom_after_sleep:
11821188
await asyncio.sleep(custom_after_sleep.total_seconds())
@@ -1242,7 +1248,8 @@ async def safe_dispatch(listener: Any, data: Any) -> None:
12421248
except TimeoutError:
12431249
self.log.warning('Pre-reboot event listeners did not finish within timeout; proceeding with reboot')
12441250

1245-
await self.apify_client.run(self.configuration.actor_run_id).reboot()
1251+
with map_client_errors():
1252+
await self.apify_client.run(self.configuration.actor_run_id).reboot()
12461253
except BaseException:
12471254
# Reset the flag so that a failed or cancelled reboot can be retried.
12481255
self._is_rebooting = False
@@ -1283,17 +1290,18 @@ async def add_webhook(self, webhook: Webhook, *, idempotency_key: str | None = N
12831290
if not self.configuration.actor_run_id:
12841291
raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.')
12851292

1286-
await self.apify_client.webhooks().create(
1287-
actor_run_id=self.configuration.actor_run_id,
1288-
event_types=webhook.event_types,
1289-
request_url=webhook.request_url,
1290-
payload_template=webhook.payload_template,
1291-
headers_template=webhook.headers_template,
1292-
ignore_ssl_errors=webhook.ignore_ssl_errors,
1293-
do_not_retry=webhook.do_not_retry,
1294-
idempotency_key=idempotency_key if idempotency_key is not None else webhook.idempotency_key,
1295-
is_ad_hoc=True,
1296-
)
1293+
with map_client_errors():
1294+
await self.apify_client.webhooks().create(
1295+
actor_run_id=self.configuration.actor_run_id,
1296+
event_types=webhook.event_types,
1297+
request_url=webhook.request_url,
1298+
payload_template=webhook.payload_template,
1299+
headers_template=webhook.headers_template,
1300+
ignore_ssl_errors=webhook.ignore_ssl_errors,
1301+
do_not_retry=webhook.do_not_retry,
1302+
idempotency_key=idempotency_key if idempotency_key is not None else webhook.idempotency_key,
1303+
is_ad_hoc=True,
1304+
)
12971305

12981306
@_ensure_context
12991307
async def set_status_message(
@@ -1321,10 +1329,11 @@ async def set_status_message(
13211329
raise RuntimeError('actor_run_id cannot be None when running on the Apify platform.')
13221330

13231331
run_client = self.apify_client.run(self.configuration.actor_run_id)
1324-
run = await run_client.update(
1325-
status_message=status_message,
1326-
is_status_message_terminal=is_terminal,
1327-
)
1332+
with map_client_errors():
1333+
run = await run_client.update(
1334+
status_message=status_message,
1335+
is_status_message_terminal=is_terminal,
1336+
)
13281337

13291338
if run is None:
13301339
raise RuntimeError(

src/apify/_charging.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from apify_client._models import PricingPerEvent as ClientPricingPerEvent
2020

2121
from apify._utils import ReentrantLock, docs_group, ensure_context
22+
from apify.errors import map_client_errors
2223
from apify.log import logger
2324
from apify.storages import Dataset
2425

@@ -449,7 +450,8 @@ async def charge(self, event_name: str, *, count: int = 1) -> ChargeResult:
449450
# the platform handles them automatically based on dataset writes.
450451
pass
451452
elif event_name in self._pricing_info:
452-
await self._client.run(self._actor_run_id).charge(event_name, count=charged_count)
453+
with map_client_errors():
454+
await self._client.run(self._actor_run_id).charge(event_name, count=charged_count)
453455
elif event_name in self._tier_priced_events:
454456
logger.warning(
455457
f"Event '{event_name}' is tier-priced and is not chargeable via the pay-per-event API."
@@ -572,7 +574,8 @@ async def _fetch_pricing_info(self) -> _FetchedPricingInfoDict:
572574
if self._actor_run_id is None:
573575
raise RuntimeError('Actor run ID not found even though the Actor is running on Apify')
574576

575-
run = await self._client.run(self._actor_run_id).get()
577+
with map_client_errors():
578+
run = await self._client.run(self._actor_run_id).get()
576579

577580
if run is None:
578581
raise RuntimeError('Actor run not found')

src/apify/errors.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING
3+
import contextlib
4+
import functools
5+
from typing import TYPE_CHECKING, ParamSpec, TypeVar
46

7+
from apify_client.errors import ApifyApiError
58
from apify_client.errors import ForbiddenError as _ForbiddenError
69
from apify_client.errors import InvalidRequestError as _InvalidRequestError
710
from apify_client.errors import RateLimitError as _RateLimitError
@@ -11,8 +14,14 @@
1114
from apify._utils import docs_group
1215

1316
if TYPE_CHECKING:
17+
from collections.abc import Awaitable, Callable, Coroutine, Iterator
18+
from typing import Any
19+
1420
from apify_client._models import Run
1521

22+
_P = ParamSpec('_P')
23+
_R = TypeVar('_R')
24+
1625

1726
@docs_group('Errors')
1827
class ActorError(Exception):
@@ -147,6 +156,35 @@ class ActorRateLimitError(ActorError):
147156
retryable = True
148157

149158

159+
@contextlib.contextmanager
160+
def map_client_errors() -> Iterator[None]:
161+
"""Translate `apify_client` API errors into domain-level `ActorError`s.
162+
163+
Wrap any `apify_client` call with this context manager so that an `ApifyApiError` (e.g. an HTTP 401/403/429/5xx
164+
response) surfaces as the matching `ActorError` subclass instead of a raw client exception. The original error
165+
is preserved as the `__cause__` of the raised `ActorError`.
166+
"""
167+
try:
168+
yield
169+
except ApifyApiError as error:
170+
raise ActorError.from_client_error(error) from error
171+
172+
173+
def catch_client_errors(func: Callable[_P, Awaitable[_R]]) -> Callable[_P, Coroutine[Any, Any, _R]]:
174+
"""Decorate an async function so the `apify_client` errors it raises become domain-level `ActorError`s.
175+
176+
This is the method-level counterpart of `map_client_errors`, intended for thin wrappers around `apify_client`
177+
calls such as the storage client operations.
178+
"""
179+
180+
@functools.wraps(func)
181+
async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
182+
with map_client_errors():
183+
return await func(*args, **kwargs)
184+
185+
return wrapper
186+
187+
150188
__all__ = [
151189
'ActorAuthenticationError',
152190
'ActorChargeLimitExceededError',

src/apify/storage_clients/_apify/_dataset_client.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from crawlee.storage_clients.models import DatasetItemsListPage, DatasetMetadata
1313

1414
from ._api_client_creation import create_storage_api_client
15+
from apify.errors import ActorError, catch_client_errors, map_client_errors
1516
from apify.storage_clients._ppe_dataset_mixin import DatasetClientPpeMixin
1617

1718
if TYPE_CHECKING:
@@ -57,11 +58,12 @@ def __init__(
5758
"""A lock to ensure that only one operation is performed at a time."""
5859

5960
@override
61+
@catch_client_errors
6062
async def get_metadata(self) -> DatasetMetadata:
6163
metadata = await self._api_client.get()
6264

6365
if metadata is None:
64-
raise ValueError('Failed to retrieve dataset metadata.')
66+
raise ActorError('Failed to retrieve dataset metadata.')
6567

6668
return DatasetMetadata(
6769
id=metadata.id,
@@ -73,6 +75,7 @@ async def get_metadata(self) -> DatasetMetadata:
7375
)
7476

7577
@classmethod
78+
@catch_client_errors
7679
async def open(
7780
cls,
7881
*,
@@ -132,11 +135,13 @@ async def purge(self) -> None:
132135
)
133136

134137
@override
138+
@catch_client_errors
135139
async def drop(self) -> None:
136140
async with self._lock:
137141
await self._api_client.delete()
138142

139143
@override
144+
@catch_client_errors
140145
async def push_data(self, data: Sequence[Mapping[str, JsonSerializable]] | Mapping[str, JsonSerializable]) -> None:
141146
async def payloads_generator(items: Sequence[Mapping[str, JsonSerializable]]) -> AsyncIterator[str]:
142147
for index, item in enumerate(items):
@@ -155,6 +160,7 @@ async def payloads_generator(items: Sequence[Mapping[str, JsonSerializable]]) ->
155160
await self._charge_for_items(count_items=limit)
156161

157162
@override
163+
@catch_client_errors
158164
async def get_data(
159165
self,
160166
*,
@@ -199,18 +205,19 @@ async def iterate_items(
199205
skip_empty: bool = False,
200206
skip_hidden: bool = False,
201207
) -> AsyncIterator[dict]:
202-
async for item in self._api_client.iterate_items(
203-
offset=offset,
204-
limit=limit,
205-
clean=clean,
206-
desc=desc,
207-
fields=fields,
208-
omit=omit,
209-
unwind=unwind,
210-
skip_empty=skip_empty,
211-
skip_hidden=skip_hidden,
212-
):
213-
yield item
208+
with map_client_errors():
209+
async for item in self._api_client.iterate_items(
210+
offset=offset,
211+
limit=limit,
212+
clean=clean,
213+
desc=desc,
214+
fields=fields,
215+
omit=omit,
216+
unwind=unwind,
217+
skip_empty=skip_empty,
218+
skip_hidden=skip_hidden,
219+
):
220+
yield item
214221

215222
@classmethod
216223
async def _check_and_serialize(cls, item: Mapping[str, JsonSerializable], index: int | None = None) -> str:

0 commit comments

Comments
 (0)