Skip to content

Commit 08e293a

Browse files
authored
feat: Classify non-retryable customer errors for Durable API calls (#358)
Non-retryable customer errors from Lambda (e.g., KMSAccessDeniedException, KMSDisabledException) arrive as HTTP 502 during CheckpointDurableExecution and GetDurableExecutionState API calls. Without this change, these 502s are classified as retryable invocation errors, causing the SDK to retry invocations that will never self-resolve. Extract shared classification logic into BotoClientError with a _NON_RETRYABLE_CUSTOMER_ERROR_CODES set and _classify_error_category method. Both CheckpointError and GetExecutionStateError now inherit from_exception() with unified classification so that non-retryable errors (KMS 502s, 4xx client errors) return Status: FAILED immediately, while retryable errors (5xx, 429, network errors) continue to raise for Lambda retry. Add is_retryable() to InvocationError hierarchy so execution.py handlers use a single interface instead of isinstance checks. Add backward-compatible aliases for CheckpointErrorCategory and is_retriable(). Log Durable API errors during state fetch, and save partially fetched state. Testing — parameterized classification tests across both error types for all four KMS codes, 4xx, 429, 5xx, and 502 scenarios. Integration tests for non-retryable/retryable errors across all three execution.py code paths: initial pagination, background thread, and user thread. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 72a2ba4 commit 08e293a

6 files changed

Lines changed: 547 additions & 100 deletions

File tree

src/aws_durable_execution_sdk_python/exceptions.py

Lines changed: 100 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@
1313
BAD_REQUEST_ERROR: int = 400
1414
TOO_MANY_REQUESTS_ERROR: int = 429
1515
SERVICE_ERROR: int = 500
16+
INVALID_PARAMETER_VALUE_EXCEPTION: str = "InvalidParameterValueException"
17+
INVALID_CHECKPOINT_TOKEN_PREFIX: str = "Invalid Checkpoint Token"
18+
19+
# Non-retryable customer error codes that arrive as non-4xx (e.g. HTTP 502) from Lambda.
20+
# Unlike typical 5xx errors, these require customer intervention (e.g., fixing
21+
# a KMS key configuration) and will never succeed on retry.
22+
# Add new non-retryable error codes here — they are automatically classified
23+
# as EXECUTION (non-retryable) by _classify_error_category().
24+
_NON_RETRYABLE_CUSTOMER_ERROR_CODES: frozenset[str] = frozenset(
25+
{
26+
"KMSAccessDeniedException",
27+
"KMSDisabledException",
28+
"KMSInvalidStateException",
29+
"KMSNotFoundException",
30+
}
31+
)
1632

1733
if TYPE_CHECKING:
1834
import datetime
@@ -77,6 +93,14 @@ def __init__(
7793
):
7894
super().__init__(message, termination_reason)
7995

96+
def is_retryable(self) -> bool:
97+
"""Whether this error is retryable. Returns True by default.
98+
99+
Subclasses override to implement classification logic based on
100+
error codes and HTTP status codes.
101+
"""
102+
return True
103+
80104

81105
class CallbackError(ExecutionError):
82106
"""Error in callback handling."""
@@ -86,27 +110,98 @@ def __init__(self, message: str, callback_id: str | None = None):
86110
self.callback_id = callback_id
87111

88112

113+
class DurableApiErrorCategory(Enum):
114+
INVOCATION = "INVOCATION"
115+
EXECUTION = "EXECUTION"
116+
117+
118+
# Backward-compatible alias
119+
CheckpointErrorCategory = DurableApiErrorCategory
120+
121+
89122
class BotoClientError(InvocationError):
123+
"""Error from a Lambda API call (e.g., CheckpointDurableExecution, GetDurableExecutionState).
124+
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+
"""
130+
90131
def __init__(
91132
self,
92133
message: str,
134+
error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
93135
error: AwsErrorObj | None = None,
94136
response_metadata: AwsErrorMetadata | None = None,
95137
termination_reason=TerminationReason.INVOCATION_ERROR,
96138
):
97139
super().__init__(message=message, termination_reason=termination_reason)
98140
self.error: AwsErrorObj | None = error
99141
self.response_metadata: AwsErrorMetadata | None = response_metadata
142+
self.error_category: DurableApiErrorCategory = error_category
100143

101144
@classmethod
102145
def from_exception(cls, exception: Exception) -> Self:
103146
response = getattr(exception, "response", {})
104147
response_metadata = response.get("ResponseMetadata")
105148
error = response.get("Error")
149+
error_category = BotoClientError._classify_error_category(
150+
error, response_metadata
151+
)
106152
return cls(
107-
message=str(exception), error=error, response_metadata=response_metadata
153+
message=str(exception),
154+
error_category=error_category,
155+
error=error,
156+
response_metadata=response_metadata,
108157
)
109158

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).
165+
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 and error.get("Code")) or None
176+
if error_code and error_code in _NON_RETRYABLE_CUSTOMER_ERROR_CODES:
177+
return DurableApiErrorCategory.EXECUTION
178+
179+
status_code: int | None = (
180+
response_metadata and response_metadata.get("HTTPStatusCode")
181+
) or None
182+
if (
183+
status_code
184+
and BAD_REQUEST_ERROR <= status_code < SERVICE_ERROR
185+
and status_code != TOO_MANY_REQUESTS_ERROR
186+
and error
187+
and not (
188+
(error.get("Code") or "") == INVALID_PARAMETER_VALUE_EXCEPTION
189+
and (error.get("Message") or "").startswith(
190+
INVALID_CHECKPOINT_TOKEN_PREFIX
191+
)
192+
)
193+
):
194+
return DurableApiErrorCategory.EXECUTION
195+
196+
return DurableApiErrorCategory.INVOCATION
197+
198+
def is_retryable(self) -> bool:
199+
"""Whether this error is retryable based on error_category."""
200+
return self.error_category == DurableApiErrorCategory.INVOCATION
201+
202+
# Backward-compatible alias
203+
is_retriable = is_retryable
204+
110205
def build_logger_extras(self) -> dict:
111206
extras: dict = {}
112207
# preserve PascalCase to be consistent with other langauges
@@ -125,55 +220,23 @@ def __init__(self, message: str, step_id: str | None = None):
125220
self.step_id = step_id
126221

127222

128-
class CheckpointErrorCategory(Enum):
129-
INVOCATION = "INVOCATION"
130-
EXECUTION = "EXECUTION"
131-
132-
133223
class CheckpointError(BotoClientError):
134224
"""Failure to checkpoint. Will terminate the lambda."""
135225

136226
def __init__(
137227
self,
138228
message: str,
139-
error_category: CheckpointErrorCategory,
229+
error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
140230
error: AwsErrorObj | None = None,
141231
response_metadata: AwsErrorMetadata | None = None,
142232
):
143233
super().__init__(
144234
message,
235+
error_category,
145236
error,
146237
response_metadata,
147238
termination_reason=TerminationReason.CHECKPOINT_FAILED,
148239
)
149-
self.error_category: CheckpointErrorCategory = error_category
150-
151-
@classmethod
152-
def from_exception(cls, exception: Exception) -> CheckpointError:
153-
base = BotoClientError.from_exception(exception)
154-
metadata: AwsErrorMetadata | None = base.response_metadata
155-
error: AwsErrorObj | None = base.error
156-
error_category: CheckpointErrorCategory = CheckpointErrorCategory.INVOCATION
157-
158-
# 4xx errors (except 429) are permanent failures (EXECUTION), unless it's an
159-
# InvalidParameterValueException with "Invalid Checkpoint Token" which is retriable (INVOCATION).
160-
# 5xx, 429, and network errors are retriable (INVOCATION).
161-
status_code: int | None = (metadata and metadata.get("HTTPStatusCode")) or None
162-
if (
163-
status_code
164-
and BAD_REQUEST_ERROR <= status_code < SERVICE_ERROR
165-
and status_code != TOO_MANY_REQUESTS_ERROR
166-
and error
167-
and not (
168-
(error.get("Code") or "") == "InvalidParameterValueException"
169-
and (error.get("Message") or "").startswith("Invalid Checkpoint Token")
170-
)
171-
):
172-
error_category = CheckpointErrorCategory.EXECUTION
173-
return CheckpointError(str(exception), error_category, error, metadata)
174-
175-
def is_retriable(self):
176-
return self.error_category == CheckpointErrorCategory.INVOCATION
177240

178241

179242
class ValidationError(DurableExecutionsError):
@@ -186,11 +249,13 @@ class GetExecutionStateError(BotoClientError):
186249
def __init__(
187250
self,
188251
message: str,
252+
error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
189253
error: AwsErrorObj | None = None,
190254
response_metadata: AwsErrorMetadata | None = None,
191255
):
192256
super().__init__(
193257
message,
258+
error_category,
194259
error,
195260
response_metadata,
196261
termination_reason=TerminationReason.INVOCATION_ERROR,

src/aws_durable_execution_sdk_python/execution.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,26 @@ def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
260260
else ReplayStatus.NEW,
261261
)
262262

263-
execution_state.fetch_paginated_operations(
264-
invocation_input.initial_execution_state.operations,
265-
invocation_input.checkpoint_token,
266-
invocation_input.initial_execution_state.next_marker,
267-
)
263+
try:
264+
execution_state.fetch_paginated_operations(
265+
invocation_input.initial_execution_state.operations,
266+
invocation_input.checkpoint_token,
267+
invocation_input.initial_execution_state.next_marker,
268+
)
269+
except BotoClientError as e:
270+
# Non-retryable Durable API errors (e.g., customer configuration issues,
271+
# 4xx client errors) will never succeed on retry — fail the execution immediately.
272+
if not e.is_retryable():
273+
logger.exception(
274+
"Non-retryable Durable API error during initial state fetch. Must fail execution "
275+
"without retry.",
276+
extra=e.build_logger_extras(),
277+
)
278+
return DurableExecutionInvocationOutput(
279+
status=InvocationStatus.FAILED,
280+
error=ErrorObject.from_exception(e),
281+
).to_dict()
282+
raise
268283

269284
raw_input_payload: str | None = execution_state.get_input_payload()
270285

@@ -356,11 +371,20 @@ def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
356371
"Checkpoint processing failed",
357372
extra=bg_error.source_exception.build_logger_extras(),
358373
)
374+
# Non-retryable Durable API errors (e.g., customer configuration issues,
375+
# 4xx client errors) will never succeed on retry — fail the execution immediately.
376+
if not bg_error.source_exception.is_retryable():
377+
logger.exception(
378+
"Non-retryable Durable API error from background thread. Must fail execution "
379+
"without retry.",
380+
extra=bg_error.source_exception.build_logger_extras(),
381+
)
382+
return DurableExecutionInvocationOutput(
383+
status=InvocationStatus.FAILED,
384+
error=ErrorObject.from_exception(bg_error.source_exception),
385+
).to_dict()
359386
else:
360387
logger.exception("Checkpoint processing failed")
361-
# handle the original exception
362-
if isinstance(bg_error.source_exception, CheckpointError):
363-
return handle_checkpoint_error(bg_error.source_exception).to_dict()
364388
raise bg_error.source_exception from bg_error
365389

366390
except SuspendExecution:
@@ -377,12 +401,23 @@ def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
377401
extra=e.build_logger_extras(),
378402
)
379403
return handle_checkpoint_error(e).to_dict()
380-
except InvocationError:
404+
except InvocationError as e:
405+
# Non-retryable Durable API errors (e.g., customer configuration issues,
406+
# 4xx client errors) will never succeed on retry — fail the execution immediately.
407+
if not e.is_retryable():
408+
logger.exception(
409+
"Non-retryable Durable API error. Must fail execution without retry.",
410+
extra=e.build_logger_extras(), # type: ignore[attr-defined]
411+
)
412+
return DurableExecutionInvocationOutput(
413+
status=InvocationStatus.FAILED,
414+
error=ErrorObject.from_exception(e),
415+
).to_dict()
381416
logger.exception("Invocation error. Must terminate.")
382417
# Throw the error to trigger Lambda retry
383418
raise
384419
except ExecutionError as e:
385-
logger.exception("Execution error. Must terminate without retry.")
420+
logger.exception("Execution error. Must fail execution without retry.")
386421
return DurableExecutionInvocationOutput(
387422
status=InvocationStatus.FAILED,
388423
error=ErrorObject.from_exception(e),
@@ -428,7 +463,7 @@ def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
428463

429464

430465
def handle_checkpoint_error(error: CheckpointError) -> DurableExecutionInvocationOutput:
431-
if error.is_retriable():
466+
if error.is_retryable():
432467
raise error from None # Terminate Lambda immediately and have it be retried
433468
return DurableExecutionInvocationOutput(
434469
status=InvocationStatus.FAILED, error=ErrorObject.from_exception(error)

src/aws_durable_execution_sdk_python/state.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
BackgroundThreadError,
1717
CallableRuntimeError,
1818
DurableExecutionsError,
19+
GetExecutionStateError,
1920
OrphanedChildException,
2021
)
2122
from aws_durable_execution_sdk_python.lambda_service import (
@@ -275,20 +276,38 @@ def fetch_paginated_operations(
275276
initial_operations: initial operations to be added to ExecutionState
276277
checkpoint_token: checkpoint token used to call Durable Functions API.
277278
next_marker: a marker indicates that there are paginated operations.
279+
280+
Raises:
281+
GetExecutionStateError: If the API call fails. The error is logged
282+
with structured extras before re-raising. Callers are responsible
283+
for deciding whether to fail the execution or allow Lambda retry
284+
based on is_retryable().
278285
"""
279286
all_operations: list[Operation] = (
280287
initial_operations.copy() if initial_operations else []
281288
)
282-
while next_marker:
283-
output: StateOutput = self._service_client.get_execution_state(
284-
durable_execution_arn=self.durable_execution_arn,
285-
checkpoint_token=checkpoint_token,
286-
next_marker=next_marker,
289+
try:
290+
while next_marker:
291+
output: StateOutput = self._service_client.get_execution_state(
292+
durable_execution_arn=self.durable_execution_arn,
293+
checkpoint_token=checkpoint_token,
294+
next_marker=next_marker,
295+
)
296+
all_operations.extend(output.operations)
297+
next_marker = output.next_marker
298+
except GetExecutionStateError as e:
299+
logger.exception(
300+
"Durable API error during state fetch.",
301+
extra=e.build_logger_extras(),
287302
)
288-
all_operations.extend(output.operations)
289-
next_marker = output.next_marker
290-
with self._operations_lock:
291-
self.operations.update({op.operation_id: op for op in all_operations})
303+
raise
304+
finally:
305+
# Always store whatever operations we successfully fetched
306+
if all_operations:
307+
with self._operations_lock:
308+
self.operations.update(
309+
{op.operation_id: op for op in all_operations}
310+
)
292311

293312
def get_input_payload(self) -> str | None:
294313
# It is possible that backend will not provide an execution operation

0 commit comments

Comments
 (0)