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