Coverage for async-durable-execution/src/async_durable_execution/exceptions.py: 97%
148 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
1"""Exceptions for the Durable Executions SDK.
3Avoid any non-stdlib references in this module, it is at the bottom of the dependency chain.
4"""
6from __future__ import annotations
8import datetime
9import time
10from dataclasses import dataclass
11from enum import Enum
12from typing import Any, NoReturn, TypedDict
14BAD_REQUEST_ERROR: int = 400
15TOO_MANY_REQUESTS_ERROR: int = 429
16SERVICE_ERROR: int = 500
17INVALID_PARAMETER_VALUE_EXCEPTION: str = "InvalidParameterValueException"
18INVALID_CHECKPOINT_TOKEN_PREFIX: str = "Invalid Checkpoint Token"
20# Non-retryable customer error codes that arrive as non-4xx (e.g. HTTP 502) from Lambda.
21# Unlike typical 5xx errors, these require customer intervention (e.g., fixing
22# a KMS key configuration) and will never succeed on retry.
23# Add new non-retryable error codes here — they are automatically classified
24# as EXECUTION (non-retryable) by _classify_error_category().
25_NON_RETRYABLE_CUSTOMER_ERROR_CODES: frozenset[str] = frozenset(
26 {
27 "KMSAccessDeniedException",
28 "KMSDisabledException",
29 "KMSInvalidStateException",
30 "KMSNotFoundException",
31 }
32)
35class AwsErrorObj(TypedDict):
36 """Subset of a boto-style AWS error payload."""
38 Code: str | None
39 Message: str | None
42class AwsErrorMetadata(TypedDict, total=False):
43 """Subset of boto response metadata used for retry classification."""
45 RequestId: str | None
46 HostId: str | None
47 HTTPStatusCode: int | None
48 HTTPHeaders: str | None
49 RetryAttempts: str | None
52class TerminationReason(Enum):
53 """Reasons why a durable execution terminated."""
55 UNHANDLED_ERROR = "UNHANDLED_ERROR"
56 INVOCATION_ERROR = "INVOCATION_ERROR"
57 EXECUTION_ERROR = "EXECUTION_ERROR"
58 CHECKPOINT_FAILED = "CHECKPOINT_FAILED"
59 NON_DETERMINISTIC_EXECUTION = "NON_DETERMINISTIC_EXECUTION"
60 STEP_INTERRUPTED = "STEP_INTERRUPTED"
61 CALLBACK_ERROR = "CALLBACK_ERROR"
62 SERIALIZATION_ERROR = "SERIALIZATION_ERROR"
65class DurableExecutionsError(Exception):
66 """Base class for Durable Executions exceptions"""
69class UnrecoverableError(DurableExecutionsError):
70 """Base class for errors that terminate execution."""
72 def __init__(self, message: str, termination_reason: TerminationReason):
73 super().__init__(message)
74 self.termination_reason = termination_reason
77class ExecutionError(UnrecoverableError):
78 """Error that returns FAILED status without retry."""
80 def __init__(
81 self,
82 message: str,
83 termination_reason: TerminationReason = TerminationReason.EXECUTION_ERROR,
84 ):
85 super().__init__(message, termination_reason)
88class InvocationError(UnrecoverableError):
89 """Error that should cause Lambda retry by throwing from handler."""
91 def __init__(
92 self,
93 message: str,
94 termination_reason: TerminationReason = TerminationReason.INVOCATION_ERROR,
95 ):
96 super().__init__(message, termination_reason)
98 def is_retryable(self) -> bool:
99 """Whether this error is retryable. Returns True by default.
101 Subclasses override to implement classification logic based on
102 error codes and HTTP status codes.
103 """
104 return True
106 def build_logger_extras(self) -> dict:
107 """Return structured logging extras for retryable invocation errors."""
108 return {}
111class DurableApiErrorCategory(Enum):
112 """Whether a durable API failure should retry the Lambda or fail execution."""
114 INVOCATION = "INVOCATION"
115 EXECUTION = "EXECUTION"
118# Backward-compatible alias
119CheckpointErrorCategory = DurableApiErrorCategory
122class BotoClientError(InvocationError):
123 """Error from a Lambda API call (e.g., CheckpointDurableExecution, GetDurableExecutionState).
125 Extends InvocationError because the default behavior for API failures is to retry
126 the Lambda invocation. However, some errors are non-retryable (e.g., 4xx client errors,
127 KMS key misconfiguration) and should fail the execution instead. The error_category field
128 and is_retryable() method distinguish these cases at runtime.
129 """
131 def __init__(
132 self,
133 message: str,
134 error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
135 error: AwsErrorObj | None = None,
136 response_metadata: AwsErrorMetadata | None = None,
137 termination_reason=TerminationReason.INVOCATION_ERROR,
138 ):
139 super().__init__(message=message, termination_reason=termination_reason)
140 self.error: AwsErrorObj | None = error
141 self.response_metadata: AwsErrorMetadata | None = response_metadata
142 self.error_category: DurableApiErrorCategory = error_category
144 @classmethod
145 def from_exception(cls, exception: Exception) -> BotoClientError:
146 response = getattr(exception, "response", {})
147 response_metadata = response.get("ResponseMetadata")
148 error = response.get("Error")
149 error_category = BotoClientError._classify_error_category(
150 error, response_metadata
151 )
152 return cls(
153 message=str(exception),
154 error_category=error_category,
155 error=error,
156 response_metadata=response_metadata,
157 )
159 @staticmethod
160 def _classify_error_category(
161 error: AwsErrorObj | None,
162 response_metadata: AwsErrorMetadata | None,
163 ) -> DurableApiErrorCategory:
164 """Classify a Durable API error as retryable (INVOCATION) or non-retryable (EXECUTION).
166 Classification rules:
167 - Non-retryable customer error codes (e.g., KMS key issues) → EXECUTION
168 These arrive as HTTP 502 but require customer intervention to fix.
169 - 4xx errors → EXECUTION, except:
170 - 429 (TooManyRequests) → INVOCATION (throttling is transient)
171 - InvalidParameterValueException with "Invalid Checkpoint Token" → INVOCATION
172 (stale token from a concurrent checkpoint; next invocation gets a fresh token)
173 - 5xx, network errors → INVOCATION
174 """
175 error_code: str | None = error.get("Code") if error else None
176 if error_code and error_code in _NON_RETRYABLE_CUSTOMER_ERROR_CODES:
177 return DurableApiErrorCategory.EXECUTION
179 status_code: int | None = (
180 response_metadata.get("HTTPStatusCode") if response_metadata else None
181 )
183 if (
184 status_code
185 and BAD_REQUEST_ERROR <= status_code < SERVICE_ERROR
186 and status_code != TOO_MANY_REQUESTS_ERROR
187 and error
188 and not (
189 (error.get("Code") or "") == INVALID_PARAMETER_VALUE_EXCEPTION
190 and (error.get("Message") or "").startswith(
191 INVALID_CHECKPOINT_TOKEN_PREFIX
192 )
193 )
194 ):
195 return DurableApiErrorCategory.EXECUTION
197 return DurableApiErrorCategory.INVOCATION
199 def is_retryable(self) -> bool:
200 """Whether this error is retryable based on error_category."""
201 return self.error_category == DurableApiErrorCategory.INVOCATION
203 # Backward-compatible alias
204 is_retriable = is_retryable
206 def build_logger_extras(self) -> dict:
207 extras: dict = {}
208 # preserve PascalCase to be consistent with other langauges
209 if error := self.error:
210 extras["Error"] = error
211 if response_metadata := self.response_metadata:
212 extras["ResponseMetadata"] = response_metadata
213 return extras
216class NonDeterministicExecutionError(ExecutionError):
217 """Error when execution is non-deterministic."""
219 def __init__(self, message: str, step_id: str | None = None):
220 super().__init__(message, TerminationReason.NON_DETERMINISTIC_EXECUTION)
221 self.step_id = step_id
224class CheckpointError(BotoClientError):
225 """Failure to checkpoint. Will terminate the lambda."""
227 def __init__(
228 self,
229 message: str,
230 error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
231 error: AwsErrorObj | None = None,
232 response_metadata: AwsErrorMetadata | None = None,
233 ):
234 super().__init__(
235 message,
236 error_category,
237 error,
238 response_metadata,
239 termination_reason=TerminationReason.CHECKPOINT_FAILED,
240 )
243class ValidationError(DurableExecutionsError):
244 """Incorrect arguments to a Durable Function operation."""
247class GetExecutionStateError(BotoClientError):
248 """Raised when failing to retrieve execution state"""
250 def __init__(
251 self,
252 message: str,
253 error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
254 error: AwsErrorObj | None = None,
255 response_metadata: AwsErrorMetadata | None = None,
256 ):
257 super().__init__(
258 message,
259 error_category,
260 error,
261 response_metadata,
262 termination_reason=TerminationReason.INVOCATION_ERROR,
263 )
266class InvalidStateError(DurableExecutionsError):
267 """Raised when an operation is attempted on an object in an invalid state."""
270class UserlandError(DurableExecutionsError):
271 """Failure in user-land - i.e code passed into durable executions from the caller."""
274class CallableRuntimeError(UserlandError):
275 """This error wraps any failure from inside the callable code that you pass to a Durable Function operation."""
277 def __init__(
278 self,
279 message: str | None,
280 error_type: str | None,
281 data: str | None,
282 stack_trace: list[str] | None,
283 ) -> None:
284 super().__init__(message)
285 self.message = message
286 self.error_type = error_type
287 self.data = data
288 self.stack_trace = stack_trace
290 @classmethod
291 def from_error_object(cls, error_object: Any) -> CallableRuntimeError:
292 return cls(
293 message=error_object.message,
294 error_type=error_object.type,
295 data=error_object.data,
296 stack_trace=error_object.stack_trace,
297 )
300class BackgroundThreadError(BaseException):
301 """Critical error from background checkpoint thread.
303 Derives from BaseException to bypass normal exception handlers.
304 Similar to KeyboardInterrupt or SystemExit - this is a system-level
305 error that should terminate execution immediately without attempting
306 to checkpoint or process the error.
308 This exception is raised in the user thread when the background
309 checkpoint processing thread encounters a fatal error. It propagates
310 through the awaiting checkpoint future to interrupt blocked user code.
312 Attributes:
313 source_exception: The original exception from the background thread
314 """
316 def __init__(self, message: str, source_exception: Exception):
317 super().__init__(message)
318 self.source_exception = source_exception
321class SuspendExecution(BaseException):
322 """Raise this exception to suspend the current execution by returning PENDING to DAR.
324 Note this derives from BaseException - in keeping with system-exiting exceptions like
325 KeyboardInterrupt or SystemExit.
326 """
328 def __init__(self, message: str):
329 super().__init__(message)
332class TimedSuspendExecution(SuspendExecution):
333 """Suspend execution until a specific timestamp.
335 This is a specialized form of SuspendExecution that includes a scheduled resume time.
337 Attributes:
338 scheduled_timestamp (float): Unix timestamp in seconds at which to resume.
339 """
341 def __init__(self, message: str, scheduled_timestamp: float):
342 super().__init__(message)
343 self.scheduled_timestamp = scheduled_timestamp
345 @classmethod
346 def from_delay(cls, message: str, delay_seconds: int) -> TimedSuspendExecution:
347 """Create a timed suspension with the delay calculated from now.
349 Args:
350 message: Descriptive message for the suspension
351 delay_seconds: Number of seconds to suspend from current time
353 Returns:
354 TimedSuspendExecution: Instance with calculated resume time
356 Example:
357 >>> exception = TimedSuspendExecution.from_delay("Waiting for callback", 30)
358 >>> # Will suspend for 30 seconds from now
359 """
360 resume_time = time.time() + delay_seconds
361 return cls(message, scheduled_timestamp=resume_time)
363 @classmethod
364 def from_datetime(
365 cls, message: str, datetime_timestamp: datetime.datetime
366 ) -> TimedSuspendExecution:
367 """Create a timed suspension with the delay calculated from now.
369 Args:
370 message: Descriptive message for the suspension
371 datetime_timestamp: Unix datetime timestamp in seconds at which to resume
373 Returns:
374 TimedSuspendExecution: Instance with calculated resume time
375 """
376 return cls(message, scheduled_timestamp=datetime_timestamp.timestamp())
379def suspend_with_optional_resume_timestamp(
380 msg: str, datetime_timestamp: datetime.datetime | None = None
381) -> NoReturn:
382 """Suspend execution with an optional target resume timestamp."""
384 if datetime_timestamp is None:
385 msg = f"No timestamp provided. Suspending without retry timestamp. Original operation: [{msg}]"
386 raise SuspendExecution(msg)
388 if datetime_timestamp < datetime.datetime.now(tz=datetime.timezone.utc):
389 msg = f"Invalid timestamp {datetime_timestamp}, suspending with immediate retry, original operation: [{msg}]"
390 raise TimedSuspendExecution.from_datetime(
391 msg, datetime.datetime.now(tz=datetime.timezone.utc)
392 )
394 raise TimedSuspendExecution.from_datetime(msg, datetime_timestamp)
397def suspend_with_optional_resume_delay(
398 msg: str, delay_seconds: int | None = None
399) -> NoReturn:
400 """Suspend execution with an optional delay before resuming."""
402 if delay_seconds is None:
403 msg = f"No delay_seconds provided, suspending without retry timestamp, original operation: [{msg}]"
404 raise SuspendExecution(msg)
406 if delay_seconds < 0:
407 msg = f"Invalid delay_seconds {delay_seconds}, suspending with delay 0, original operation: [{msg}]"
408 raise TimedSuspendExecution.from_delay(msg, 0)
410 raise TimedSuspendExecution.from_delay(msg, delay_seconds)
413@dataclass(frozen=True)
414class CallableRuntimeErrorSerializableDetails:
415 """Serializable error details."""
417 type: str
418 message: str
420 @classmethod
421 def from_exception(
422 cls, exception: Exception
423 ) -> CallableRuntimeErrorSerializableDetails:
424 """Create an instance from an Exception, using its type and message.
426 Args:
427 exception: An Exception instance
429 Returns:
430 A CallableRuntimeErrorDetails instance with the exception's type name and message
431 """
432 return cls(type=exception.__class__.__name__, message=str(exception))
434 def __str__(self) -> str:
435 """
436 Return a string representation of the object.
438 Returns:
439 A string in the format "type: message"
440 """
441 return f"{self.type}: {self.message}"
444class SerDesError(DurableExecutionsError):
445 """Raised when serialization fails."""