Skip to content

Commit 7fd1628

Browse files
committed
feat: add example tests and integration tests for new RetryableOperation util
1 parent 2fe64a7 commit 7fd1628

8 files changed

Lines changed: 870 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.examples.callback;
4+
5+
import java.time.Duration;
6+
import software.amazon.lambda.durable.DurableContext;
7+
import software.amazon.lambda.durable.DurableHandler;
8+
import software.amazon.lambda.durable.config.RetryOperationConfig;
9+
import software.amazon.lambda.durable.examples.types.ApprovalRequest;
10+
import software.amazon.lambda.durable.retry.RetryDecision;
11+
import software.amazon.lambda.durable.util.RetryOperationHelper;
12+
13+
/**
14+
* Example demonstrating {@link RetryOperationHelper} with {@code context.waitForCallback}.
15+
*
16+
* <p>Submits an approval request to an external system via a callback. If the callback fails (e.g., the external system
17+
* rejects the request), the helper retries the entire waitForCallback cycle — creating a fresh callback with a new ID
18+
* each time.
19+
*
20+
* <p>Each attempt uses a unique callback name ({@code "approval-1"}, {@code "approval-2"}, etc.) so the execution
21+
* history stays clean and replay-safe. The anonymous form is used, so attempts run directly in the caller's context.
22+
*/
23+
public class RetryWaitForCallbackExample extends DurableHandler<ApprovalRequest, String> {
24+
25+
private static final int MAX_ATTEMPTS = 3;
26+
27+
@Override
28+
public String handleRequest(ApprovalRequest input, DurableContext context) {
29+
// Step 1: Prepare the approval request
30+
var prepared = context.step(
31+
"prepare",
32+
String.class,
33+
stepCtx -> "Approval for: " + input.description() + " ($" + input.amount() + ")");
34+
35+
// Step 2: waitForCallback with retry — if the external system fails, try again with a fresh callback
36+
var approvalResult = RetryOperationHelper.retryOperation(
37+
context,
38+
(ctx, attempt) -> ctx.waitForCallback(
39+
"approval-" + attempt, String.class, (callbackId, stepCtx) -> stepCtx.getLogger()
40+
.info("Attempt {}: sending callback {} to approval system", attempt, callbackId)),
41+
RetryOperationConfig.builder()
42+
.retryStrategy((error, attempt) -> attempt < MAX_ATTEMPTS
43+
? RetryDecision.retry(Duration.ofSeconds(2))
44+
: RetryDecision.fail())
45+
.build());
46+
47+
// Step 3: Process the result
48+
return context.step("process-result", String.class, stepCtx -> prepared + " - Result: " + approvalResult);
49+
}
50+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.examples.invoke;
4+
5+
import java.time.Duration;
6+
import software.amazon.lambda.durable.DurableContext;
7+
import software.amazon.lambda.durable.DurableHandler;
8+
import software.amazon.lambda.durable.config.RetryOperationConfig;
9+
import software.amazon.lambda.durable.examples.types.GreetingRequest;
10+
import software.amazon.lambda.durable.retry.RetryDecision;
11+
import software.amazon.lambda.durable.util.RetryOperationHelper;
12+
13+
/**
14+
* Example demonstrating {@link RetryOperationHelper} with {@code context.invoke}.
15+
*
16+
* <p>Retries a chained Lambda invocation up to 3 times with a fixed 2-second backoff between attempts. Each attempt
17+
* uses a unique operation name ({@code "call-greeting-1"}, {@code "call-greeting-2"}, etc.) so the execution history
18+
* stays clean and replay-safe.
19+
*
20+
* <p>The anonymous form is used, so attempts run directly in the caller's context without child-context wrapping.
21+
*/
22+
public class RetryInvokeExample extends DurableHandler<GreetingRequest, String> {
23+
24+
private static final int MAX_ATTEMPTS = 3;
25+
26+
@Override
27+
public String handleRequest(GreetingRequest input, DurableContext context) {
28+
return RetryOperationHelper.retryOperation(
29+
context,
30+
(ctx, attempt) -> ctx.invoke(
31+
"call-greeting-" + attempt,
32+
"simple-step-example" + input.getName() + ":$LATEST",
33+
input,
34+
String.class),
35+
RetryOperationConfig.builder()
36+
.retryStrategy((error, attempt) -> attempt < MAX_ATTEMPTS
37+
? RetryDecision.retry(Duration.ofSeconds(2))
38+
: RetryDecision.fail())
39+
.build());
40+
}
41+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.examples.callback;
4+
5+
import static org.junit.jupiter.api.Assertions.*;
6+
7+
import org.junit.jupiter.api.Test;
8+
import software.amazon.awssdk.services.lambda.model.ErrorObject;
9+
import software.amazon.lambda.durable.examples.types.ApprovalRequest;
10+
import software.amazon.lambda.durable.model.ExecutionStatus;
11+
import software.amazon.lambda.durable.testing.LocalDurableTestRunner;
12+
13+
class RetryWaitForCallbackExampleTest {
14+
15+
@Test
16+
void succeedsOnFirstAttempt() {
17+
var handler = new RetryWaitForCallbackExample();
18+
var runner = LocalDurableTestRunner.create(ApprovalRequest.class, handler);
19+
var input = new ApprovalRequest("New laptop", 1500.00);
20+
21+
// First run — prepares request, starts waitForCallback, suspends
22+
var result = runner.run(input);
23+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
24+
25+
// Complete the callback (waitForCallback names it "approval-1-callback" internally)
26+
var callbackId = runner.getCallbackId("approval-1-callback");
27+
assertNotNull(callbackId, "Callback 'approval-1-callback' should have been created");
28+
runner.completeCallback(callbackId, "\"Approved by manager\"");
29+
30+
// Run to completion
31+
result = runner.runUntilComplete(input);
32+
33+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
34+
assertEquals(
35+
"Approval for: New laptop ($1500.0) - Result: Approved by manager", result.getResult(String.class));
36+
}
37+
38+
@Test
39+
void retriesAfterFirstCallbackFails() {
40+
var handler = new RetryWaitForCallbackExample();
41+
var runner = LocalDurableTestRunner.create(ApprovalRequest.class, handler);
42+
var input = new ApprovalRequest("Server upgrade", 5000.00);
43+
44+
// First run — prepares, starts waitForCallback attempt 1, suspends
45+
var result = runner.run(input);
46+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
47+
48+
// Fail the first callback
49+
var callbackId1 = runner.getCallbackId("approval-1-callback");
50+
assertNotNull(callbackId1);
51+
runner.failCallback(
52+
callbackId1,
53+
ErrorObject.builder()
54+
.errorType("RejectedError")
55+
.errorMessage("Rejected by first reviewer")
56+
.build());
57+
58+
// Run — processes failure, hits backoff wait, suspends
59+
result = runner.run(input);
60+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
61+
62+
// Advance past the backoff wait
63+
runner.advanceTime();
64+
65+
// Run — starts waitForCallback attempt 2, suspends
66+
result = runner.run(input);
67+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
68+
69+
// Complete the second callback
70+
var callbackId2 = runner.getCallbackId("approval-2-callback");
71+
assertNotNull(callbackId2, "Callback 'approval-2-callback' should have been created after retry");
72+
runner.completeCallback(callbackId2, "\"Approved on second try\"");
73+
74+
// Run to completion
75+
result = runner.runUntilComplete(input);
76+
77+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
78+
assertEquals(
79+
"Approval for: Server upgrade ($5000.0) - Result: Approved on second try",
80+
result.getResult(String.class));
81+
}
82+
83+
@Test
84+
void failsAfterAllRetriesExhausted() {
85+
var handler = new RetryWaitForCallbackExample();
86+
var runner = LocalDurableTestRunner.create(ApprovalRequest.class, handler);
87+
var input = new ApprovalRequest("Expensive item", 10000.00);
88+
89+
// First run — starts waitForCallback attempt 1
90+
var result = runner.run(input);
91+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
92+
93+
// Fail callback attempt 1
94+
var callbackId1 = runner.getCallbackId("approval-1-callback");
95+
runner.failCallback(
96+
callbackId1,
97+
ErrorObject.builder()
98+
.errorType("Rejected")
99+
.errorMessage("fail 1")
100+
.build());
101+
result = runner.run(input);
102+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
103+
104+
// Advance past backoff 1, run to start attempt 2
105+
runner.advanceTime();
106+
result = runner.run(input);
107+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
108+
109+
// Fail callback attempt 2
110+
var callbackId2 = runner.getCallbackId("approval-2-callback");
111+
runner.failCallback(
112+
callbackId2,
113+
ErrorObject.builder()
114+
.errorType("Rejected")
115+
.errorMessage("fail 2")
116+
.build());
117+
result = runner.run(input);
118+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
119+
120+
// Advance past backoff 2, run to start attempt 3
121+
runner.advanceTime();
122+
result = runner.run(input);
123+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
124+
125+
// Fail callback attempt 3 — last attempt, retryStrategy returns fail()
126+
var callbackId3 = runner.getCallbackId("approval-3-callback");
127+
runner.failCallback(
128+
callbackId3,
129+
ErrorObject.builder()
130+
.errorType("Rejected")
131+
.errorMessage("fail 3")
132+
.build());
133+
result = runner.run(input);
134+
135+
assertEquals(ExecutionStatus.FAILED, result.getStatus());
136+
}
137+
138+
@Test
139+
void suspendsOnFirstRun() {
140+
var handler = new RetryWaitForCallbackExample();
141+
var runner = LocalDurableTestRunner.create(ApprovalRequest.class, handler);
142+
var input = new ApprovalRequest("Test item", 100.00);
143+
144+
var result = runner.run(input);
145+
146+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
147+
}
148+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
package software.amazon.lambda.durable.examples.invoke;
4+
5+
import static org.junit.jupiter.api.Assertions.*;
6+
7+
import org.junit.jupiter.api.Test;
8+
import software.amazon.awssdk.services.lambda.model.ErrorObject;
9+
import software.amazon.lambda.durable.examples.types.GreetingRequest;
10+
import software.amazon.lambda.durable.model.ExecutionStatus;
11+
import software.amazon.lambda.durable.testing.LocalDurableTestRunner;
12+
13+
class RetryInvokeExampleTest {
14+
15+
@Test
16+
void succeedsOnFirstAttempt() {
17+
var handler = new RetryInvokeExample();
18+
var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler);
19+
var input = new GreetingRequest("world");
20+
21+
// First run — starts the invoke, suspends waiting for result
22+
var result = runner.run(input);
23+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
24+
25+
// Complete the first invoke attempt
26+
runner.completeChainedInvoke("call-greeting-1", "\"hello world\"");
27+
result = runner.run(input);
28+
29+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
30+
assertEquals("hello world", result.getResult(String.class));
31+
}
32+
33+
@Test
34+
void retriesAfterFirstAttemptFails() {
35+
var handler = new RetryInvokeExample();
36+
var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler);
37+
var input = new GreetingRequest("world");
38+
39+
// First run — starts invoke attempt 1
40+
var result = runner.run(input);
41+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
42+
43+
// Fail the first invoke attempt
44+
runner.failChainedInvoke(
45+
"call-greeting-1",
46+
ErrorObject.builder()
47+
.errorType("TransientError")
48+
.errorMessage("Service unavailable")
49+
.build());
50+
51+
// Second run — processes the failure, does backoff wait, starts invoke attempt 2
52+
result = runner.run(input);
53+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
54+
55+
// Advance past the backoff wait
56+
runner.advanceTime();
57+
result = runner.run(input);
58+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
59+
60+
// Complete the second invoke attempt
61+
runner.completeChainedInvoke("call-greeting-2", "\"hello on retry\"");
62+
result = runner.run(input);
63+
64+
assertEquals(ExecutionStatus.SUCCEEDED, result.getStatus());
65+
assertEquals("hello on retry", result.getResult(String.class));
66+
}
67+
68+
@Test
69+
void failsAfterAllRetriesExhausted() {
70+
var handler = new RetryInvokeExample();
71+
var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler);
72+
var input = new GreetingRequest("world");
73+
74+
// First run — starts invoke attempt 1
75+
var result = runner.run(input);
76+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
77+
78+
// Fail attempt 1
79+
runner.failChainedInvoke(
80+
"call-greeting-1",
81+
ErrorObject.builder()
82+
.errorType("TransientError")
83+
.errorMessage("fail 1")
84+
.build());
85+
result = runner.run(input);
86+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
87+
88+
// Advance past backoff wait 1
89+
runner.advanceTime();
90+
result = runner.run(input);
91+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
92+
93+
// Fail attempt 2
94+
runner.failChainedInvoke(
95+
"call-greeting-2",
96+
ErrorObject.builder()
97+
.errorType("TransientError")
98+
.errorMessage("fail 2")
99+
.build());
100+
result = runner.run(input);
101+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
102+
103+
// Advance past backoff wait 2
104+
runner.advanceTime();
105+
result = runner.run(input);
106+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
107+
108+
// Fail attempt 3 — this is the last attempt, retryStrategy returns fail()
109+
runner.failChainedInvoke(
110+
"call-greeting-3",
111+
ErrorObject.builder()
112+
.errorType("TransientError")
113+
.errorMessage("fail 3")
114+
.build());
115+
result = runner.run(input);
116+
117+
assertEquals(ExecutionStatus.FAILED, result.getStatus());
118+
}
119+
120+
@Test
121+
void suspendsOnFirstRun() {
122+
var handler = new RetryInvokeExample();
123+
var runner = LocalDurableTestRunner.create(GreetingRequest.class, handler);
124+
var input = new GreetingRequest("test");
125+
126+
var result = runner.run(input);
127+
128+
assertEquals(ExecutionStatus.PENDING, result.getStatus());
129+
}
130+
}

0 commit comments

Comments
 (0)