Skip to content

Commit ba2f2d5

Browse files
authored
feat(error-handling): Support specific type error reconstruction from… (#15)
1 parent 73e0f95 commit ba2f2d5

13 files changed

Lines changed: 461 additions & 49 deletions

File tree

examples/src/main/java/com/amazonaws/lambda/durable/examples/ErrorHandlingExample.java

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import com.amazonaws.lambda.durable.DurableHandler;
77
import com.amazonaws.lambda.durable.StepConfig;
88
import com.amazonaws.lambda.durable.StepSemantics;
9-
import com.amazonaws.lambda.durable.exception.StepFailedException;
109
import com.amazonaws.lambda.durable.exception.StepInterruptedException;
1110
import com.amazonaws.lambda.durable.retry.RetryStrategies;
1211
import org.slf4j.Logger;
@@ -20,31 +19,54 @@
2019
* <ul>
2120
* <li>{@link StepFailedException} - when a step exhausts all retry attempts
2221
* <li>{@link StepInterruptedException} - when an AT_MOST_ONCE step is interrupted
22+
* <li>Custom exceptions - original exception types are preserved and can be caught directly
2323
* </ul>
2424
*
2525
* <p>Note: {@code NonDeterministicExecutionException} is thrown by the SDK when code changes between executions (e.g.,
2626
* step order/names changed). It should be fixed in code, not caught.
2727
*/
28-
public class ErrorHandlingExample extends DurableHandler<String, String> {
28+
public class ErrorHandlingExample extends DurableHandler<Object, String> {
2929

3030
private static final Logger logger = LoggerFactory.getLogger(ErrorHandlingExample.class);
3131

32+
/** Custom exception to demonstrate that original exception types are preserved across checkpoints. */
33+
public static class ServiceUnavailableException extends RuntimeException {
34+
private String serviceName;
35+
36+
/** Default constructor required for Jackson deserialization. */
37+
public ServiceUnavailableException() {
38+
super();
39+
}
40+
41+
public ServiceUnavailableException(String serviceName, String message) {
42+
super(message);
43+
this.serviceName = serviceName;
44+
}
45+
46+
public String getServiceName() {
47+
return serviceName;
48+
}
49+
}
50+
3251
@Override
33-
public String handleRequest(String input, DurableContext context) {
34-
// Example 1: Catching StepFailedException with fallback logic
52+
public String handleRequest(Object input, DurableContext context) {
53+
// Example 1: Catching a custom exception type with fallback logic
54+
// The SDK preserves the original exception type, so you can catch specific exceptions directly.
55+
// NOTE: Exception type needs to be serializable by your SerDes implementation.
3556
String primaryResult;
3657
try {
3758
primaryResult = context.step(
3859
"call-primary-service",
3960
String.class,
4061
() -> {
41-
throw new RuntimeException("Primary service unavailable");
62+
throw new ServiceUnavailableException("primary-api", "Primary service unavailable");
4263
},
4364
StepConfig.builder()
4465
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
4566
.build());
46-
} catch (StepFailedException e) {
47-
logger.warn("Primary service failed, using fallback: {}", e.getMessage());
67+
} catch (ServiceUnavailableException e) {
68+
// Catch the specific custom exception type - the SDK reconstructs the original exception
69+
logger.warn("Service '{}' unavailable, using fallback: {}", e.getServiceName(), e.getMessage());
4870
primaryResult = context.step("call-fallback-service", String.class, () -> "fallback-result");
4971
}
5072

examples/src/test/java/com/amazonaws/lambda/durable/examples/CloudBasedIntegrationTest.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,4 +223,18 @@ void testCustomConfigExample() {
223223
assertTrue(stepResult.contains("user_age"));
224224
assertTrue(stepResult.contains("email_address"));
225225
}
226+
227+
@Test
228+
void testErrorHandlingExample() {
229+
var runner = CloudDurableTestRunner.create(arn("error-handling-example"), String.class, String.class);
230+
var result = runner.run("test-input");
231+
232+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
233+
234+
var finalResult = result.getResult(String.class);
235+
assertNotNull(finalResult);
236+
assertTrue(finalResult.startsWith("Completed: "));
237+
assertTrue(finalResult.contains("fallback-result"));
238+
assertTrue(finalResult.contains("payment-"));
239+
}
226240
}

examples/src/test/java/com/amazonaws/lambda/durable/examples/ErrorHandlingExampleTest.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class ErrorHandlingExampleTest {
1313
@Test
1414
void testErrorHandlingWithFallback() {
1515
var handler = new ErrorHandlingExample();
16-
var runner = LocalDurableTestRunner.create(String.class, handler);
16+
var runner = LocalDurableTestRunner.create(Object.class, handler);
1717

1818
var result = runner.run("test-input");
1919

@@ -24,7 +24,7 @@ void testErrorHandlingWithFallback() {
2424
@Test
2525
void testPaymentStepCompletes() {
2626
var handler = new ErrorHandlingExample();
27-
var runner = LocalDurableTestRunner.create(String.class, handler);
27+
var runner = LocalDurableTestRunner.create(Object.class, handler);
2828

2929
var result = runner.run("order-123");
3030

@@ -36,7 +36,7 @@ void testPaymentStepCompletes() {
3636
@Test
3737
void testPaymentStepInterruptedRecovery() {
3838
var handler = new ErrorHandlingExample();
39-
var runner = LocalDurableTestRunner.create(String.class, handler);
39+
var runner = LocalDurableTestRunner.create(Object.class, handler);
4040

4141
// First run: both steps complete normally
4242
var result1 = runner.run("order-456");

examples/template.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,28 @@ Resources:
208208
DockerContext: ../
209209
DockerTag: durable-examples
210210

211+
ErrorHandlingExampleFunction:
212+
Type: AWS::Serverless::Function
213+
Properties:
214+
PackageType: Image
215+
FunctionName: error-handling-example
216+
ImageConfig:
217+
Command: ["com.amazonaws.lambda.durable.examples.ErrorHandlingExample::handleRequest"]
218+
DurableConfig:
219+
ExecutionTimeout: 300
220+
RetentionPeriodInDays: 7
221+
Policies:
222+
- Statement:
223+
- Effect: Allow
224+
Action:
225+
- lambda:CheckpointDurableExecutions
226+
- lambda:GetDurableExecutionState
227+
Resource: !Sub "arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:error-handling-example"
228+
Metadata:
229+
Dockerfile: examples/Dockerfile
230+
DockerContext: ../
231+
DockerTag: durable-examples
232+
211233
Outputs:
212234
SimpleStepExampleFunction:
213235
Description: Simple Step Example Function ARN
@@ -280,3 +302,11 @@ Outputs:
280302
LoggingExampleFunctionName:
281303
Description: Logging Example Function Name
282304
Value: !Ref LoggingExampleFunction
305+
306+
ErrorHandlingExampleFunction:
307+
Description: Error Handling Example Function ARN
308+
Value: !GetAtt ErrorHandlingExampleFunction.Arn
309+
310+
ErrorHandlingExampleFunctionName:
311+
Description: Error Handling Example Function Name
312+
Value: !Ref ErrorHandlingExampleFunction

sdk-integration-tests/src/test/java/com/amazonaws/lambda/durable/ExceptionIntegrationTest.java

Lines changed: 111 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
// SPDX-License-Identifier: Apache-2.0
33
package com.amazonaws.lambda.durable;
44

5-
import static org.junit.jupiter.api.Assertions.*;
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
import static org.junit.jupiter.api.Assertions.assertNotNull;
7+
import static org.junit.jupiter.api.Assertions.assertTrue;
68

7-
import com.amazonaws.lambda.durable.exception.StepFailedException;
89
import com.amazonaws.lambda.durable.exception.StepInterruptedException;
910
import com.amazonaws.lambda.durable.model.ExecutionStatus;
1011
import com.amazonaws.lambda.durable.retry.RetryStrategies;
@@ -47,7 +48,7 @@ void testStepFailedExceptionCanBeCaughtWithFallback() {
4748
StepConfig.builder()
4849
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
4950
.build());
50-
} catch (StepFailedException e) {
51+
} catch (RuntimeException e) {
5152
return ctx.step("fallback", String.class, () -> "fallback-result");
5253
}
5354
});
@@ -58,6 +59,98 @@ void testStepFailedExceptionCanBeCaughtWithFallback() {
5859
assertEquals("fallback-result", result.getResult(String.class));
5960
}
6061

62+
@Test
63+
void testOriginalExceptionTypeIsPreserved() {
64+
var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> {
65+
ctx.step(
66+
"throws-illegal-arg",
67+
String.class,
68+
() -> {
69+
throw new IllegalArgumentException("Invalid parameter");
70+
},
71+
StepConfig.builder()
72+
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
73+
.build());
74+
return "should-not-reach";
75+
});
76+
77+
// First run - exception is thrown and checkpointed
78+
var result = runner.run("test");
79+
assertEquals(ExecutionStatus.FAILED, result.getStatus());
80+
81+
// Verify the operation failed with the correct exception type
82+
var failedOp = result.getOperation("throws-illegal-arg");
83+
assertNotNull(failedOp);
84+
var error = failedOp.getError();
85+
assertNotNull(error);
86+
assertEquals("java.lang.IllegalArgumentException", error.errorType());
87+
assertEquals("Invalid parameter", error.errorMessage());
88+
89+
// Verify stackTrace is preserved
90+
assertNotNull(error.stackTrace());
91+
assertTrue(error.stackTrace().size() > 0, "Stack trace should not be empty");
92+
93+
// Verify errorData contains serialized exception
94+
assertNotNull(error.errorData());
95+
assertTrue(error.errorData().contains("Invalid parameter"), "errorData should contain the exception message");
96+
}
97+
98+
@Test
99+
void testOriginalExceptionTypeCanBeCaughtSpecifically() {
100+
var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> {
101+
try {
102+
return ctx.step(
103+
"throws-illegal-state",
104+
String.class,
105+
() -> {
106+
throw new IllegalStateException("Invalid state");
107+
},
108+
StepConfig.builder()
109+
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
110+
.build());
111+
} catch (IllegalStateException e) {
112+
// Catch specific exception type
113+
return ctx.step("handle-illegal-state", String.class, () -> "recovered-from-illegal-state");
114+
} catch (Exception e) {
115+
// This should NOT be caught
116+
return ctx.step("handle-illegal-arg", String.class, () -> "recovered-from-exception");
117+
}
118+
});
119+
120+
var result = runner.runUntilComplete("test");
121+
122+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
123+
assertEquals("recovered-from-illegal-state", result.getResult(String.class));
124+
}
125+
126+
@Test
127+
void testCustomExceptionTypeIsPreserved() {
128+
var runner = LocalDurableTestRunner.create(String.class, (input, ctx) -> {
129+
ctx.step(
130+
"throws-custom",
131+
String.class,
132+
() -> {
133+
throw new CustomBusinessException("Business rule violated", 42);
134+
},
135+
StepConfig.builder()
136+
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
137+
.build());
138+
return "should-not-reach";
139+
});
140+
141+
var result = runner.runUntilComplete("test");
142+
143+
assertEquals(ExecutionStatus.FAILED, result.getStatus());
144+
145+
// Verify the operation failed with the correct exception type
146+
var failedOp = result.getOperation("throws-custom");
147+
assertNotNull(failedOp);
148+
var error = failedOp.getError();
149+
assertNotNull(error);
150+
assertTrue(error.errorType().contains("CustomBusinessException"));
151+
assertEquals("Business rule violated", error.errorMessage());
152+
}
153+
61154
@Test
62155
void testStepInterruptedExceptionForAtMostOnceAfterCheckpointLoss() {
63156
var executionCount = new AtomicInteger(0);
@@ -87,6 +180,7 @@ void testStepInterruptedExceptionForAtMostOnceAfterCheckpointLoss() {
87180

88181
assertEquals(ExecutionStatus.FAILED, result.getStatus());
89182
assertEquals(1, executionCount.get()); // Should NOT have re-executed
183+
assertEquals(result.getError().get().errorType(), StepInterruptedException.class.getName());
90184
}
91185

92186
@Test
@@ -144,4 +238,18 @@ void testNonDeterministicExceptionOnStepNameChange() {
144238

145239
assertEquals(ExecutionStatus.FAILED, result.getStatus());
146240
}
241+
242+
// Custom exception for testing exception preservation
243+
public static class CustomBusinessException extends RuntimeException {
244+
private final int errorCode;
245+
246+
public CustomBusinessException(String message, int errorCode) {
247+
super(message);
248+
this.errorCode = errorCode;
249+
}
250+
251+
public int getErrorCode() {
252+
return errorCode;
253+
}
254+
}
147255
}

sdk/src/main/java/com/amazonaws/lambda/durable/DurableExecutor.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ public static <I, O> DurableExecutionOutput execute(
102102
} catch (Exception e) {
103103
Throwable cause = e.getCause() != null ? e.getCause() : e;
104104
logger.debug("Execution failed: {}", cause.getMessage());
105-
return DurableExecutionOutput.failure(cause);
105+
return DurableExecutionOutput.failure(cause, serDes);
106106
}
107107
}
108108

@@ -138,7 +138,7 @@ public static <I, O> DurableExecutionOutput execute(
138138
return DurableExecutionOutput.success(outputPayload);
139139
} catch (Exception e) {
140140
Throwable cause = e.getCause() != null ? e.getCause() : e;
141-
return DurableExecutionOutput.failure(cause);
141+
return DurableExecutionOutput.failure(cause, serDes);
142142
} finally {
143143
// We shutdown the execution to make sure remaining checkpoint calls in the queue are drained
144144
executionManager.shutdown();

sdk/src/main/java/com/amazonaws/lambda/durable/model/DurableExecutionOutput.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package com.amazonaws.lambda.durable.model;
44

55
import com.amazonaws.lambda.durable.exception.StepFailedException;
6+
import com.amazonaws.lambda.durable.serde.SerDes;
67
import software.amazon.awssdk.services.lambda.model.ErrorObject;
78

89
public record DurableExecutionOutput(ExecutionStatus status, String result, ErrorObject error) {
@@ -14,12 +15,12 @@ public static DurableExecutionOutput pending() {
1415
return new DurableExecutionOutput(ExecutionStatus.PENDING, null, null);
1516
}
1617

17-
public static DurableExecutionOutput failure(Throwable e) {
18+
public static DurableExecutionOutput failure(Throwable e, SerDes serDes) {
1819
var errorObject = ErrorObject.builder()
19-
.errorType(e.getClass().getSimpleName())
20+
.errorType(e.getClass().getName())
2021
.errorMessage(e.getMessage())
2122
.stackTrace(StepFailedException.serializeStackTrace(e.getStackTrace()))
22-
// TODO: Add errorData object once we support polymorphic object mappers
23+
.errorData(serDes.serialize(e))
2324
.build();
2425
return new DurableExecutionOutput(ExecutionStatus.FAILED, null, errorObject);
2526
}

0 commit comments

Comments
 (0)