Skip to content

Commit d030d52

Browse files
committed
fix(sdk): checkpoint the wrapped error for failed contexts
A context that failed because an SDK error escaped it checkpointed the inner operation's raw ErrorObject. For a timed-out callback or invoke that object is null, and for a stopped invoke it is the stop error (for example States.Stopped), so the context checkpoint lost which error failed it. The context now checkpoints the wrapped error: the original ErrorObject of a wrapper such as StepFailedException when it has a type, otherwise an ErrorObject typed with the SDK error's class name (CallbackTimeoutException, InvokeTimedOutException, InvokeStoppedException, or the exception class when the error has no type), keeping message, data and stack trace. SDK operation exception types are not rebuilt from error data on translation. waitForCallback keeps the callback's own ErrorObject, because it rethrows the callback's exception live.
1 parent 1c49222 commit d030d52

2 files changed

Lines changed: 369 additions & 3 deletions

File tree

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable;
4+
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
import static org.junit.jupiter.api.Assertions.assertNotNull;
7+
8+
import java.time.Duration;
9+
import java.util.ArrayList;
10+
import java.util.Collections;
11+
import java.util.List;
12+
import java.util.function.Consumer;
13+
import java.util.function.Function;
14+
import org.junit.jupiter.params.ParameterizedTest;
15+
import org.junit.jupiter.params.provider.EnumSource;
16+
import software.amazon.awssdk.services.lambda.model.ErrorObject;
17+
import software.amazon.lambda.durable.config.StepConfig;
18+
import software.amazon.lambda.durable.exception.CallbackFailedException;
19+
import software.amazon.lambda.durable.exception.CallbackTimeoutException;
20+
import software.amazon.lambda.durable.exception.ChildContextFailedException;
21+
import software.amazon.lambda.durable.exception.InvokeStoppedException;
22+
import software.amazon.lambda.durable.exception.InvokeTimedOutException;
23+
import software.amazon.lambda.durable.execution.SuspendExecutionException;
24+
import software.amazon.lambda.durable.model.ExecutionStatus;
25+
import software.amazon.lambda.durable.retry.RetryStrategies;
26+
import software.amazon.lambda.durable.testing.LocalDurableTestRunner;
27+
import software.amazon.lambda.durable.testing.TestResult;
28+
29+
/**
30+
* A failed context checkpoints the wrapped (original) error, never the SDK wrapper, and the live and replayed failure
31+
* that user code catches has the same type and error object.
32+
*/
33+
class ChildContextWrappedErrorIntegrationTest {
34+
35+
static final String MSG = "card declined";
36+
37+
public static class PaymentError extends RuntimeException {
38+
public PaymentError(String message) {
39+
super(message);
40+
}
41+
}
42+
43+
private static final StepConfig NO_RETRY =
44+
StepConfig.builder().retryStrategy(RetryStrategies.Presets.NO_RETRY).build();
45+
46+
/** What user code caught: the exception class and the type and message of its error object. */
47+
private record Caught(String exception, String errorType, String errorMessage) {}
48+
49+
private static Caught caught(Throwable t) {
50+
var eo = t instanceof software.amazon.lambda.durable.exception.DurableOperationException d
51+
? d.getErrorObject()
52+
: null;
53+
return new Caught(
54+
t.getClass().getName(),
55+
eo == null ? null : eo.errorType(),
56+
eo == null ? t.getMessage() : eo.errorMessage());
57+
}
58+
59+
private record Outcome(Caught live, Caught replayed, TestResult<String> result) {}
60+
61+
/**
62+
* Runs {@code failing}, records what it threw, waits so that a new invocation starts, and records what the replay
63+
* of {@code failing} throws.
64+
*/
65+
private static Outcome run(
66+
Function<DurableContext, Object> failing, Consumer<LocalDurableTestRunner<String, String>> drive) {
67+
List<Caught> seen = Collections.synchronizedList(new ArrayList<>());
68+
var runner = LocalDurableTestRunner.create(String.class, (String in, DurableContext ctx) -> {
69+
try {
70+
failing.apply(ctx);
71+
throw new IllegalStateException("did not fail");
72+
} catch (SuspendExecutionException e) {
73+
throw e;
74+
} catch (RuntimeException e) {
75+
seen.add(caught(e));
76+
}
77+
ctx.wait("pause", Duration.ofSeconds(1));
78+
return "done";
79+
});
80+
if (drive != null) {
81+
runner.run("in");
82+
drive.accept(runner);
83+
}
84+
var result = runner.runUntilComplete("in");
85+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
86+
assertEquals(2, seen.size(), "one live and one replayed failure: " + seen);
87+
return new Outcome(seen.get(0), seen.get(1), result);
88+
}
89+
90+
private static ErrorObject contextError(TestResult<String> result, String name) {
91+
var details = result.getOperation(name).getContextDetails();
92+
assertNotNull(details, name + " context details");
93+
return details.error();
94+
}
95+
96+
private static ErrorObject error(String type) {
97+
return ErrorObject.builder().errorType(type).errorMessage(MSG).build();
98+
}
99+
100+
private static Object invokeInChild(DurableContext ctx) {
101+
return ctx.runInChildContext("child", String.class, cc -> cc.invoke("callee", "fn", "x", String.class));
102+
}
103+
104+
private static Object callbackInChild(DurableContext ctx) {
105+
return ctx.runInChildContext(
106+
"child",
107+
String.class,
108+
cc -> cc.createCallback("cb", String.class).get());
109+
}
110+
111+
enum Scenario {
112+
STEP_ERROR,
113+
INVOKE_FAILED,
114+
INVOKE_STOPPED,
115+
INVOKE_STOPPED_USER_TYPE,
116+
INVOKE_TIMED_OUT,
117+
CALLBACK_FAILED,
118+
CALLBACK_FAILED_NO_TYPE,
119+
CALLBACK_FAILED_TYPE_ERROR,
120+
CALLBACK_TIMED_OUT
121+
}
122+
123+
@ParameterizedTest
124+
@EnumSource(Scenario.class)
125+
void contextCheckpointsTheWrappedErrorAndReplaysTheSameShape(Scenario scenario) {
126+
Outcome outcome;
127+
String type;
128+
String message = MSG;
129+
switch (scenario) {
130+
case STEP_ERROR -> {
131+
outcome = run(
132+
c -> c.runInChildContext(
133+
"child",
134+
String.class,
135+
cc -> cc.step(
136+
"charge",
137+
String.class,
138+
sc -> {
139+
throw new PaymentError(MSG);
140+
},
141+
NO_RETRY)),
142+
null);
143+
type = PaymentError.class.getName();
144+
}
145+
case INVOKE_FAILED -> {
146+
outcome = run(
147+
ChildContextWrappedErrorIntegrationTest::invokeInChild,
148+
r -> r.failChainedInvoke("callee", error("com.acme.PaymentError")));
149+
type = "com.acme.PaymentError";
150+
}
151+
case INVOKE_STOPPED -> {
152+
outcome = run(
153+
ChildContextWrappedErrorIntegrationTest::invokeInChild,
154+
r -> r.stopChainedInvoke("callee", error("States.Stopped")));
155+
type = InvokeStoppedException.class.getName();
156+
}
157+
case INVOKE_STOPPED_USER_TYPE -> {
158+
outcome = run(
159+
ChildContextWrappedErrorIntegrationTest::invokeInChild,
160+
r -> r.stopChainedInvoke("callee", error("com.acme.PaymentError")));
161+
type = InvokeStoppedException.class.getName();
162+
}
163+
case INVOKE_TIMED_OUT -> {
164+
outcome = run(
165+
ChildContextWrappedErrorIntegrationTest::invokeInChild, r -> r.timeoutChainedInvoke("callee"));
166+
type = InvokeTimedOutException.class.getName();
167+
message = null;
168+
}
169+
case CALLBACK_FAILED -> {
170+
outcome = run(
171+
ChildContextWrappedErrorIntegrationTest::callbackInChild,
172+
r -> r.failCallback(r.getCallbackId("cb"), error("com.acme.PaymentError")));
173+
type = "com.acme.PaymentError";
174+
}
175+
case CALLBACK_FAILED_NO_TYPE -> {
176+
outcome = run(
177+
ChildContextWrappedErrorIntegrationTest::callbackInChild,
178+
r -> r.failCallback(r.getCallbackId("cb"), error(null)));
179+
type = CallbackFailedException.class.getName();
180+
}
181+
case CALLBACK_FAILED_TYPE_ERROR -> {
182+
// The explicit type "Error" is a type: it is kept, unlike a missing one.
183+
outcome = run(
184+
ChildContextWrappedErrorIntegrationTest::callbackInChild,
185+
r -> r.failCallback(r.getCallbackId("cb"), error("Error")));
186+
type = "Error";
187+
}
188+
case CALLBACK_TIMED_OUT -> {
189+
outcome = run(
190+
ChildContextWrappedErrorIntegrationTest::callbackInChild,
191+
r -> r.timeoutCallback(r.getCallbackId("cb")));
192+
type = CallbackTimeoutException.class.getName();
193+
message = null; // SDK message with the callback id, checked below
194+
}
195+
default -> throw new IllegalArgumentException(scenario.name());
196+
}
197+
198+
var checkpoint = contextError(outcome.result(), "child");
199+
assertNotNull(checkpoint, "context checkpoint error");
200+
assertEquals(type, checkpoint.errorType(), "context checkpoint errorType");
201+
if (scenario == Scenario.CALLBACK_TIMED_OUT) {
202+
assertEquals(
203+
"Callback timed out: "
204+
+ outcome.result()
205+
.getOperation("cb")
206+
.getCallbackDetails()
207+
.callbackId(),
208+
checkpoint.errorMessage());
209+
} else {
210+
assertEquals(message, checkpoint.errorMessage(), "context checkpoint errorMessage");
211+
}
212+
213+
assertEquals(outcome.live(), outcome.replayed(), "live and replayed failures have the same shape");
214+
if (scenario == Scenario.STEP_ERROR) {
215+
assertEquals(PaymentError.class.getName(), outcome.live().exception());
216+
} else {
217+
assertEquals(
218+
ChildContextFailedException.class.getName(), outcome.live().exception());
219+
assertEquals(type, outcome.live().errorType());
220+
}
221+
}
222+
223+
@ParameterizedTest
224+
@EnumSource(
225+
value = Scenario.class,
226+
names = {"INVOKE_STOPPED", "INVOKE_TIMED_OUT"})
227+
void nestedContextsKeepTheSdkErrorIdentity(Scenario scenario) {
228+
var outcome = run(
229+
c -> c.runInChildContext(
230+
"outer", String.class, oc -> invokeInChild(oc).toString()),
231+
r -> {
232+
if (scenario == Scenario.INVOKE_STOPPED) {
233+
r.stopChainedInvoke("callee", error("States.Stopped"));
234+
} else {
235+
r.timeoutChainedInvoke("callee");
236+
}
237+
});
238+
String type = scenario == Scenario.INVOKE_STOPPED
239+
? InvokeStoppedException.class.getName()
240+
: InvokeTimedOutException.class.getName();
241+
assertEquals(type, contextError(outcome.result(), "child").errorType(), "inner context");
242+
assertEquals(type, contextError(outcome.result(), "outer").errorType(), "outer context");
243+
assertEquals(outcome.live(), outcome.replayed());
244+
assertEquals(type, outcome.live().errorType());
245+
}
246+
247+
@ParameterizedTest
248+
@EnumSource(
249+
value = Scenario.class,
250+
names = {"INVOKE_STOPPED", "CALLBACK_TIMED_OUT"})
251+
void mapItemCheckpointCarriesTheSdkErrorType(Scenario scenario) {
252+
var runner = LocalDurableTestRunner.create(String.class, (String in, DurableContext ctx) -> {
253+
var result = ctx.map("fanout", List.of("a"), String.class, (item, i, mc) -> {
254+
if (scenario == Scenario.INVOKE_STOPPED) {
255+
return mc.invoke("callee", "fn", "x", String.class);
256+
}
257+
return mc.createCallback("cb", String.class).get();
258+
});
259+
return result.getError(0) == null ? "no error" : "failed";
260+
});
261+
runner.run("in");
262+
if (scenario == Scenario.INVOKE_STOPPED) {
263+
runner.stopChainedInvoke("callee", error("States.Stopped"));
264+
} else {
265+
runner.timeoutCallback(runner.getCallbackId("cb"));
266+
}
267+
var result = runner.runUntilComplete("in");
268+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
269+
String type = scenario == Scenario.INVOKE_STOPPED
270+
? InvokeStoppedException.class.getName()
271+
: CallbackTimeoutException.class.getName();
272+
assertEquals("failed", result.getResult(String.class));
273+
assertEquals(type, contextError(result, "fanout-iteration-0").errorType());
274+
}
275+
276+
/**
277+
* {@code waitForCallback} rethrows the callback's own exception live, so its context keeps the callback's own error
278+
* object: a replay that can read only the completed context then reports the same error object.
279+
*/
280+
@ParameterizedTest
281+
@EnumSource(
282+
value = Scenario.class,
283+
names = {"CALLBACK_FAILED", "CALLBACK_FAILED_NO_TYPE", "CALLBACK_TIMED_OUT"})
284+
void waitForCallbackContextKeepsTheCallbackErrorObject(Scenario scenario) {
285+
var outcome = run(c -> c.waitForCallback("wfc", String.class, (id, sc) -> {}), r -> {
286+
switch (scenario) {
287+
case CALLBACK_TIMED_OUT -> r.timeoutCallback(r.getCallbackId("wfc-callback"));
288+
case CALLBACK_FAILED -> r.failCallback(r.getCallbackId("wfc-callback"), error("com.acme.PaymentError"));
289+
default -> r.failCallback(r.getCallbackId("wfc-callback"), error(null));
290+
}
291+
});
292+
var callbackError = outcome.result()
293+
.getOperation("wfc-callback")
294+
.getCallbackDetails()
295+
.error();
296+
assertEquals(callbackError, contextError(outcome.result(), "wfc"));
297+
assertEquals(outcome.live().errorType(), outcome.replayed().errorType());
298+
String live = scenario == Scenario.CALLBACK_TIMED_OUT
299+
? CallbackTimeoutException.class.getName()
300+
: CallbackFailedException.class.getName();
301+
assertEquals(live, outcome.live().exception());
302+
}
303+
}

0 commit comments

Comments
 (0)