Skip to content

Commit 693ff2d

Browse files
ParidelPooyaPooya Paridel
andcommitted
feat(sdk): improve serdes resilience for external dependencies (#90)
*Description of changes:* - Change SerializationFailedError and DeserializationFailedError to extend UnrecoverableInvocationError instead of UnrecoverableExecutionError - Update error handling in withDurableFunctions to throw UnrecoverableInvocationError instead of returning error envelope - Consolidate error handling logic to use single isUnrecoverableInvocationError check - Add comprehensive test coverage for both UnrecoverableInvocationError and UnrecoverableExecutionError handling This enables automatic recovery from temporary failures in external services used by custom serdes implementations (e.g., S3, DynamoDB) by terminating the current Lambda invocation for retry rather than permanently failing the execution. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. Co-authored-by: Pooya Paridel <parpooya@amazon.com>
1 parent 650fd31 commit 693ff2d

File tree

4 files changed

+104
-23
lines changed

4 files changed

+104
-23
lines changed

packages/aws-durable-execution-sdk-js/src/errors/serdes-errors/serdes-errors.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
} from "./serdes-errors";
88
import {
99
UnrecoverableError,
10-
UnrecoverableExecutionError,
10+
UnrecoverableInvocationError,
1111
isUnrecoverableError,
12-
isUnrecoverableExecutionError,
12+
isUnrecoverableInvocationError,
1313
} from "../unrecoverable-error/unrecoverable-error";
1414
import { TerminationReason } from "../../termination-manager/types";
1515
import { TerminationManager } from "../../termination-manager/termination-manager";
@@ -26,17 +26,17 @@ describe("Serdes Errors", () => {
2626
);
2727

2828
expect(error.name).toBe("SerializationFailedError");
29-
expect(error.message).toContain("[Unrecoverable Execution]");
29+
expect(error.message).toContain("[Unrecoverable Invocation]");
3030
expect(error.message).toContain(
3131
'Serialization failed for step "test-step" (step-1)',
3232
);
3333
expect(error.message).toContain("JSON.stringify failed");
3434
expect(error.terminationReason).toBe(TerminationReason.CUSTOM);
3535
expect(error.isUnrecoverable).toBe(true);
36-
expect(error.isUnrecoverableExecution).toBe(true);
36+
expect(error.isUnrecoverableInvocation).toBe(true);
3737
expect(error.originalError).toBe(originalError);
3838
expect(error).toBeInstanceOf(UnrecoverableError);
39-
expect(error).toBeInstanceOf(UnrecoverableExecutionError);
39+
expect(error).toBeInstanceOf(UnrecoverableInvocationError);
4040
});
4141

4242
it("should create error without step name", () => {
@@ -61,7 +61,7 @@ describe("Serdes Errors", () => {
6161
it("should create error without originalError", () => {
6262
const error = new SerializationFailedError("step-1", "test-step");
6363

64-
expect(error.message).toContain("[Unrecoverable Execution]");
64+
expect(error.message).toContain("[Unrecoverable Invocation]");
6565
expect(error.message).toContain(
6666
'Serialization failed for step "test-step" (step-1)',
6767
);
@@ -77,7 +77,7 @@ describe("Serdes Errors", () => {
7777
originalError,
7878
);
7979

80-
expect(error.message).toContain("[Unrecoverable Execution]");
80+
expect(error.message).toContain("[Unrecoverable Invocation]");
8181
expect(error.message).toContain(
8282
'Serialization failed for step "test-step" (step-1)',
8383
);
@@ -96,17 +96,17 @@ describe("Serdes Errors", () => {
9696
);
9797

9898
expect(error.name).toBe("DeserializationFailedError");
99-
expect(error.message).toContain("[Unrecoverable Execution]");
99+
expect(error.message).toContain("[Unrecoverable Invocation]");
100100
expect(error.message).toContain(
101101
'Deserialization failed for step "test-step" (step-1)',
102102
);
103103
expect(error.message).toContain("JSON.parse failed");
104104
expect(error.terminationReason).toBe(TerminationReason.CUSTOM);
105105
expect(error.isUnrecoverable).toBe(true);
106-
expect(error.isUnrecoverableExecution).toBe(true);
106+
expect(error.isUnrecoverableInvocation).toBe(true);
107107
expect(error.originalError).toBe(originalError);
108108
expect(error).toBeInstanceOf(UnrecoverableError);
109-
expect(error).toBeInstanceOf(UnrecoverableExecutionError);
109+
expect(error).toBeInstanceOf(UnrecoverableInvocationError);
110110
});
111111

112112
it("should create error without step name", () => {
@@ -121,7 +121,7 @@ describe("Serdes Errors", () => {
121121
it("should create error without originalError", () => {
122122
const error = new DeserializationFailedError("step-1", "test-step");
123123

124-
expect(error.message).toContain("[Unrecoverable Execution]");
124+
expect(error.message).toContain("[Unrecoverable Invocation]");
125125
expect(error.message).toContain(
126126
'Deserialization failed for step "test-step" (step-1)',
127127
);
@@ -137,7 +137,7 @@ describe("Serdes Errors", () => {
137137
originalError,
138138
);
139139

140-
expect(error.message).toContain("[Unrecoverable Execution]");
140+
expect(error.message).toContain("[Unrecoverable Invocation]");
141141
expect(error.message).toContain(
142142
'Deserialization failed for step "test-step" (step-1)',
143143
);
@@ -174,13 +174,13 @@ describe("Serdes Errors", () => {
174174
it("should return true for SerializationFailedError", () => {
175175
const error = new SerializationFailedError("step-1");
176176
expect(isUnrecoverableError(error)).toBe(true);
177-
expect(isUnrecoverableExecutionError(error)).toBe(true);
177+
expect(isUnrecoverableInvocationError(error)).toBe(true);
178178
});
179179

180180
it("should return true for DeserializationFailedError", () => {
181181
const error = new DeserializationFailedError("step-1");
182182
expect(isUnrecoverableError(error)).toBe(true);
183-
expect(isUnrecoverableExecutionError(error)).toBe(true);
183+
expect(isUnrecoverableInvocationError(error)).toBe(true);
184184
});
185185
});
186186

packages/aws-durable-execution-sdk-js/src/errors/serdes-errors/serdes-errors.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { TerminationReason } from "../../termination-manager/types";
22
import { TerminationManager } from "../../termination-manager/termination-manager";
3-
import { UnrecoverableExecutionError } from "../unrecoverable-error/unrecoverable-error";
3+
import { UnrecoverableInvocationError } from "../unrecoverable-error/unrecoverable-error";
44
import { log } from "../../utils/logger/logger";
55
import { SerdesContext } from "../../utils/serdes/serdes";
66

77
/**
88
* Error thrown when serialization fails
9-
* This is an unrecoverable execution error that will terminate the entire execution
9+
* This is an unrecoverable invocation error that will terminate the current Lambda invocation
1010
* because data corruption or incompatible formats indicate a fundamental problem
1111
*/
12-
export class SerializationFailedError extends UnrecoverableExecutionError {
12+
export class SerializationFailedError extends UnrecoverableInvocationError {
1313
readonly terminationReason = TerminationReason.CUSTOM;
1414

1515
constructor(stepId: string, stepName?: string, originalError?: Error) {
@@ -20,10 +20,10 @@ export class SerializationFailedError extends UnrecoverableExecutionError {
2020

2121
/**
2222
* Error thrown when deserialization fails
23-
* This is an unrecoverable execution error that will terminate the entire execution
23+
* This is an unrecoverable invocation error that will terminate the current Lambda invocation
2424
* because data corruption or incompatible formats indicate a fundamental problem
2525
*/
26-
export class DeserializationFailedError extends UnrecoverableExecutionError {
26+
export class DeserializationFailedError extends UnrecoverableInvocationError {
2727
readonly terminationReason = TerminationReason.CUSTOM;
2828

2929
constructor(stepId: string, stepName?: string, originalError?: Error) {
@@ -34,7 +34,7 @@ export class DeserializationFailedError extends UnrecoverableExecutionError {
3434

3535
/**
3636
* Type guard to check if an error is a Serdes error
37-
* @deprecated Use isUnrecoverableExecutionError instead for broader error detection
37+
* @deprecated Use isUnrecoverableInvocationError instead for broader error detection
3838
*/
3939
export function isSerdesError(
4040
error: unknown,

packages/aws-durable-execution-sdk-js/src/with-durable-functions.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { withDurableFunctions } from "./with-durable-functions";
22
import { initializeExecutionContext } from "./context/execution-context/execution-context";
33
import { createDurableContext } from "./context/durable-context/durable-context";
44
import { CheckpointFailedError } from "./errors/checkpoint-errors/checkpoint-errors";
5+
import {
6+
UnrecoverableInvocationError,
7+
UnrecoverableExecutionError
8+
} from "./errors/unrecoverable-error/unrecoverable-error";
9+
import { SerializationFailedError } from "./errors/serdes-errors/serdes-errors";
510
import { TerminationReason } from "./termination-manager/types";
611
import { Context } from "aws-lambda";
712
import { log } from "./utils/logger/logger";
@@ -383,6 +388,81 @@ describe("withDurableFunctions", () => {
383388
);
384389
});
385390

391+
it("should throw error when handler throws UnrecoverableInvocationError", async () => {
392+
// Setup
393+
const serdesError = new SerializationFailedError("step-1", "test-step");
394+
const mockHandler = jest.fn().mockRejectedValue(serdesError);
395+
mockTerminationManager.getTerminationPromise.mockReturnValue(
396+
new Promise(() => {}),
397+
); // Never resolves
398+
399+
// Execute & Verify
400+
const wrappedHandler = withDurableFunctions(mockHandler);
401+
await expect(wrappedHandler(mockEvent, mockContext)).rejects.toThrow(
402+
SerializationFailedError,
403+
);
404+
expect(mockHandler).toHaveBeenCalledWith(
405+
mockCustomerHandlerEvent,
406+
mockDurableContext,
407+
);
408+
});
409+
410+
it("should throw error when handler throws custom UnrecoverableInvocationError", async () => {
411+
// Setup - Create a custom UnrecoverableInvocationError
412+
class CustomInvocationError extends UnrecoverableInvocationError {
413+
readonly terminationReason = TerminationReason.CUSTOM;
414+
constructor(message: string) {
415+
super(message);
416+
}
417+
}
418+
419+
const customError = new CustomInvocationError("Custom invocation error");
420+
const mockHandler = jest.fn().mockRejectedValue(customError);
421+
mockTerminationManager.getTerminationPromise.mockReturnValue(
422+
new Promise(() => {}),
423+
); // Never resolves
424+
425+
// Execute & Verify
426+
const wrappedHandler = withDurableFunctions(mockHandler);
427+
await expect(wrappedHandler(mockEvent, mockContext)).rejects.toThrow(
428+
CustomInvocationError,
429+
);
430+
expect(mockHandler).toHaveBeenCalledWith(
431+
mockCustomerHandlerEvent,
432+
mockDurableContext,
433+
);
434+
});
435+
436+
it("should return error response when handler throws UnrecoverableExecutionError", async () => {
437+
// Setup - Create a custom UnrecoverableExecutionError
438+
class CustomExecutionError extends UnrecoverableExecutionError {
439+
readonly terminationReason = TerminationReason.CUSTOM;
440+
constructor(message: string) {
441+
super(message);
442+
}
443+
}
444+
445+
const executionError = new CustomExecutionError("Custom execution error");
446+
const mockHandler = jest.fn().mockRejectedValue(executionError);
447+
mockTerminationManager.getTerminationPromise.mockReturnValue(
448+
new Promise(() => {}),
449+
); // Never resolves
450+
451+
// Execute
452+
const wrappedHandler = withDurableFunctions(mockHandler);
453+
const response = await wrappedHandler(mockEvent, mockContext);
454+
455+
// Verify - UnrecoverableExecutionError should be returned as failed invocation, not thrown
456+
expect(mockHandler).toHaveBeenCalledWith(
457+
mockCustomerHandlerEvent,
458+
mockDurableContext,
459+
);
460+
expect(response).toEqual({
461+
Status: InvocationStatus.FAILED,
462+
Error: createErrorObjectFromError(executionError),
463+
});
464+
});
465+
386466
it("should call deleteCheckpoint when initializing durable function", async () => {
387467
// Setup
388468
const mockResult = { success: true };

packages/aws-durable-execution-sdk-js/src/with-durable-functions.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createDurableContext } from "./context/durable-context/durable-context"
44

55
import { initializeExecutionContext } from "./context/execution-context/execution-context";
66
import { CheckpointFailedError } from "./errors/checkpoint-errors/checkpoint-errors";
7+
import { isUnrecoverableInvocationError } from "./errors/unrecoverable-error/unrecoverable-error";
78
import {
89
createCheckpoint,
910
deleteCheckpoint,
@@ -183,12 +184,12 @@ async function runHandler<Input, Output>(
183184
} catch (error) {
184185
log(executionContext.isVerbose, "❌", "Handler threw an error:", error);
185186

186-
// Check if this is a checkpoint failure
187-
if (error instanceof CheckpointFailedError) {
187+
// Check if this is an unrecoverable invocation error (includes checkpoint failures and serdes errors)
188+
if (isUnrecoverableInvocationError(error)) {
188189
log(
189190
executionContext.isVerbose,
190191
"🛑",
191-
"Checkpoint failed - terminating Lambda execution",
192+
"Unrecoverable invocation error - terminating Lambda execution",
192193
);
193194
throw error; // Re-throw the error to terminate Lambda execution
194195
}

0 commit comments

Comments
 (0)