Skip to content

Commit 3dc7ae5

Browse files
feat(wcp): retry_context + idempotency_key (#1673 P1+P2)
Closes #1673 Phase 1+2 for Java SDK.
1 parent ae0825e commit 3dc7ae5

9 files changed

Lines changed: 1196 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,62 @@ All notable changes to the AxonFlow Java SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [5.6.0] - 2026-04-21
9+
10+
### Added
11+
12+
- **`retry_context` and `idempotency_key` support on the step gate**
13+
`StepGateResponse` now carries a `RetryContext` object on every gate call with the
14+
true `(workflow_id, step_id)` lifecycle: `gateCount`, `completionCount`,
15+
`priorCompletionStatus` (`PriorCompletionStatus` enum —
16+
`NONE` / `COMPLETED` / `GATED_NOT_COMPLETED`), `priorOutputAvailable`,
17+
`priorOutput`, `priorCompletionAt`, `firstAttemptAt`, `lastAttemptAt`,
18+
`lastDecision`, and `idempotencyKey`. Prefer these to the legacy `cached` /
19+
`decisionSource` fields.
20+
- **`stepGate(workflowId, stepId, request, options)` overload** — new 4-arg overload
21+
taking `StepGateOptions`. Use `StepGateOptions.includePriorOutput()` to send
22+
`?include_prior_output=true` so `retryContext.priorOutput` is populated when a prior
23+
`/complete` has landed. Existing 3-arg overload keeps its signature and delegates
24+
with `StepGateOptions.defaults()`.
25+
- **`StepGateRequest.idempotencyKey`** — caller-supplied opaque business-level key
26+
(max 255 chars; validated at construction). Immutable once recorded on the first gate
27+
call for a `(workflow, step)`; subsequent gate/complete calls must pass the same key.
28+
- **`MarkStepCompletedRequest.idempotencyKey`** — must match the key set on the
29+
corresponding gate call, if any. Mismatch (including missing-vs-set on either side)
30+
surfaces as a typed `IdempotencyKeyMismatchException`.
31+
- **`IdempotencyKeyMismatchException`** — new typed exception in
32+
`com.getaxonflow.sdk.exceptions`. Thrown by `stepGate` and `markStepCompleted` when
33+
the platform returns HTTP 409 with `error.code == "IDEMPOTENCY_KEY_MISMATCH"`.
34+
Surfaces `workflowId`, `stepId`, `expectedIdempotencyKey`, `receivedIdempotencyKey`,
35+
plus inherited `statusCode=409` and `errorCode="IDEMPOTENCY_KEY_MISMATCH"`.
36+
- **`RetryContext`, `PriorCompletionStatus`, `StepGateOptions`** — exported in
37+
`WorkflowTypes`.
38+
39+
### Fixed
40+
41+
- **409 dispatch on step gate/complete** — previously all 409 responses on
42+
`markStepCompleted` fell through to a generic `AxonFlowException(..., 409,
43+
"VERSION_CONFLICT")`, conflating step idempotency conflicts with plan version
44+
conflicts. The step gate/complete call sites now inspect the 409 body and dispatch
45+
to `IdempotencyKeyMismatchException` when `error.code` matches, falling back to a
46+
generic `AxonFlowException` otherwise. Plan update paths (which also use 409) are
47+
untouched.
48+
49+
### Deprecated
50+
51+
- **`StepGateResponse.isCached()`** and **`StepGateResponse.getDecisionSource()`**
52+
marked `@Deprecated`. Use `getRetryContext().getGateCount() > 1` and
53+
`getRetryContext().getPriorCompletionStatus()` instead. Planned for removal in a
54+
future major version.
55+
56+
### Compatibility
57+
58+
Companion to the platform change that introduces `retry_context` on
59+
`POST /api/v1/workflows/{workflow_id}/steps/{step_id}/gate`. Additive only — existing
60+
callers that never set `idempotencyKey` or pass `StepGateOptions` see no behavior
61+
change. Binary-compatibility preserved: old `StepGateRequest`, `StepGateResponse`, and
62+
`MarkStepCompletedRequest` constructors kept alongside new ones.
63+
864
## [5.5.0] - 2026-04-20
965

1066
### Added
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
target/
2+
*.class
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0">
3+
<modelVersion>4.0.0</modelVersion>
4+
5+
<groupId>com.getaxonflow.examples</groupId>
6+
<artifactId>wcp-retry-idempotency</artifactId>
7+
<version>1.0-SNAPSHOT</version>
8+
<packaging>jar</packaging>
9+
10+
<name>WCP retry_context + idempotency_key E2E Example</name>
11+
<description>
12+
E2E validation of #1673 Phase 1 + Phase 2 through the Java SDK.
13+
</description>
14+
15+
<properties>
16+
<maven.compiler.source>11</maven.compiler.source>
17+
<maven.compiler.target>11</maven.compiler.target>
18+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
19+
</properties>
20+
21+
<dependencies>
22+
<dependency>
23+
<groupId>com.getaxonflow</groupId>
24+
<artifactId>axonflow-sdk</artifactId>
25+
<version>5.5.0</version>
26+
</dependency>
27+
</dependencies>
28+
29+
<build>
30+
<plugins>
31+
<plugin>
32+
<groupId>org.codehaus.mojo</groupId>
33+
<artifactId>exec-maven-plugin</artifactId>
34+
<version>3.1.0</version>
35+
<configuration>
36+
<mainClass>com.getaxonflow.examples.WcpRetryIdempotency</mainClass>
37+
</configuration>
38+
</plugin>
39+
</plugins>
40+
</build>
41+
</project>
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Copyright 2026 AxonFlow
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// WCP retry_context + idempotency_key E2E example (Issue #1673 Phase 1 + 2).
5+
//
6+
// Exercises the new Java SDK surface end-to-end against a running v7.3.0
7+
// enterprise stack. Every assertion fails the process with System.exit(1).
8+
//
9+
// Run from this directory:
10+
// mvn install -DskipTests=true # from the SDK root, first time only
11+
// source /tmp/axonflow-e2e-env.sh
12+
// export AXONFLOW_BASE_URL=http://localhost:8080
13+
// mvn -q compile exec:java
14+
package com.getaxonflow.examples;
15+
16+
import com.getaxonflow.sdk.AxonFlow;
17+
import com.getaxonflow.sdk.AxonFlowConfig;
18+
import com.getaxonflow.sdk.exceptions.IdempotencyKeyMismatchException;
19+
import com.getaxonflow.sdk.types.workflow.WorkflowTypes;
20+
21+
import java.util.HashMap;
22+
import java.util.Map;
23+
24+
public class WcpRetryIdempotency {
25+
26+
public static void main(String[] args) {
27+
String endpoint = envOrDefault("AXONFLOW_BASE_URL", "http://localhost:8080");
28+
String clientId = mustEnv("AXONFLOW_CLIENT_ID");
29+
String clientSecret = mustEnv("AXONFLOW_CLIENT_SECRET");
30+
31+
AxonFlow client = AxonFlow.create(
32+
AxonFlowConfig.builder()
33+
.endpoint(endpoint)
34+
.clientId(clientId)
35+
.clientSecret(clientSecret)
36+
.build());
37+
38+
banner("Act 1 — retry_context (Java SDK)");
39+
act1(client);
40+
41+
banner("Act 2 — idempotency_key (Java SDK)");
42+
act2(client);
43+
44+
banner("All assertions passed ✔");
45+
}
46+
47+
private static void act1(AxonFlow client) {
48+
WorkflowTypes.CreateWorkflowResponse wf = client.createWorkflow(
49+
WorkflowTypes.CreateWorkflowRequest.builder()
50+
.workflowName("java-sdk-retry-context")
51+
.build());
52+
System.out.println("workflow: " + wf.getWorkflowId());
53+
54+
// 1) First gate — first-call invariants
55+
WorkflowTypes.StepGateResponse first = client.stepGate(
56+
wf.getWorkflowId(),
57+
"step-1",
58+
WorkflowTypes.StepGateRequest.builder()
59+
.stepName("first-step")
60+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
61+
.build());
62+
WorkflowTypes.RetryContext rc = first.getRetryContext();
63+
assertTrue("retry_context not null", rc != null);
64+
assertEqInt("first gate_count", 1, rc.getGateCount());
65+
assertEqInt("first completion_count", 0, rc.getCompletionCount());
66+
assertEqStr("first prior_completion_status",
67+
WorkflowTypes.PriorCompletionStatus.NONE.name(),
68+
rc.getPriorCompletionStatus().name());
69+
assertTrue("first !prior_output_available", !rc.isPriorOutputAvailable());
70+
assertEqStr("first last_decision (first-call invariant)",
71+
first.getDecision().name(), rc.getLastDecision().name());
72+
assertTrue("first FirstAttemptAt == LastAttemptAt",
73+
rc.getFirstAttemptAt().equals(rc.getLastAttemptAt()));
74+
System.out.println(" first gate invariants ✔");
75+
76+
// 2) Complete, then re-gate
77+
Map<String, Object> output = new HashMap<>();
78+
output.put("transfer_id", "TXN-java-1");
79+
output.put("amount", 500);
80+
client.markStepCompleted(
81+
wf.getWorkflowId(),
82+
"step-1",
83+
WorkflowTypes.MarkStepCompletedRequest.builder().output(output).build());
84+
WorkflowTypes.StepGateResponse reGate = client.stepGate(
85+
wf.getWorkflowId(),
86+
"step-1",
87+
WorkflowTypes.StepGateRequest.builder()
88+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
89+
.build());
90+
assertEqInt("re-gate post-complete gate_count", 2,
91+
reGate.getRetryContext().getGateCount());
92+
assertEqInt("re-gate post-complete completion_count", 1,
93+
reGate.getRetryContext().getCompletionCount());
94+
assertEqStr("re-gate post-complete prior_completion_status",
95+
WorkflowTypes.PriorCompletionStatus.COMPLETED.name(),
96+
reGate.getRetryContext().getPriorCompletionStatus().name());
97+
assertTrue("re-gate post-complete prior_output_available",
98+
reGate.getRetryContext().isPriorOutputAvailable());
99+
assertTrue("re-gate post-complete prior_output omitted by default",
100+
reGate.getRetryContext().getPriorOutput() == null);
101+
assertTrue("re-gate post-complete cached==true", reGate.isCached());
102+
System.out.println(" re-gate post-complete ✔");
103+
104+
// 3) Gate on step-2 without completion (agent-crash simulation)
105+
client.stepGate(
106+
wf.getWorkflowId(),
107+
"step-2",
108+
WorkflowTypes.StepGateRequest.builder()
109+
.stepName("second-step")
110+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
111+
.build());
112+
WorkflowTypes.StepGateResponse reGate2 = client.stepGate(
113+
wf.getWorkflowId(),
114+
"step-2",
115+
WorkflowTypes.StepGateRequest.builder()
116+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
117+
.build());
118+
assertEqStr("gated_not_completed status",
119+
WorkflowTypes.PriorCompletionStatus.GATED_NOT_COMPLETED.name(),
120+
reGate2.getRetryContext().getPriorCompletionStatus().name());
121+
assertEqInt("gated_not_completed completion_count", 0,
122+
reGate2.getRetryContext().getCompletionCount());
123+
System.out.println(" gated_not_completed ✔");
124+
125+
// 4) include_prior_output=true recovers the payload
126+
WorkflowTypes.StepGateResponse withPrior = client.stepGate(
127+
wf.getWorkflowId(),
128+
"step-1",
129+
WorkflowTypes.StepGateRequest.builder()
130+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
131+
.build(),
132+
WorkflowTypes.StepGateOptions.includePriorOutput());
133+
assertTrue("prior_output populated",
134+
withPrior.getRetryContext().getPriorOutput() != null);
135+
assertEqStr("prior_output[transfer_id]", "TXN-java-1",
136+
String.valueOf(withPrior.getRetryContext().getPriorOutput().get("transfer_id")));
137+
System.out.println(" prior_output recovery ✔");
138+
}
139+
140+
private static void act2(AxonFlow client) {
141+
WorkflowTypes.CreateWorkflowResponse wf = client.createWorkflow(
142+
WorkflowTypes.CreateWorkflowRequest.builder()
143+
.workflowName("java-sdk-idempotency-key")
144+
.build());
145+
System.out.println("workflow: " + wf.getWorkflowId());
146+
147+
String originalKey = "payment:wire:java-sdk-invoice-1";
148+
149+
// 5) Gate with key — retry_context.idempotency_key echoes
150+
WorkflowTypes.StepGateResponse first = client.stepGate(
151+
wf.getWorkflowId(),
152+
"step-1",
153+
WorkflowTypes.StepGateRequest.builder()
154+
.stepName("wire")
155+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
156+
.idempotencyKey(originalKey)
157+
.build());
158+
assertEqStr("retry_context.idempotency_key echo",
159+
originalKey, first.getRetryContext().getIdempotencyKey());
160+
System.out.println(" key round-trip ✔");
161+
162+
// 6) Re-gate with different key → IdempotencyKeyMismatchException
163+
try {
164+
client.stepGate(
165+
wf.getWorkflowId(),
166+
"step-1",
167+
WorkflowTypes.StepGateRequest.builder()
168+
.stepType(WorkflowTypes.StepType.TOOL_CALL)
169+
.idempotencyKey("payment:wire:different-2")
170+
.build());
171+
fail("expected IdempotencyKeyMismatchException on gate with different key");
172+
} catch (IdempotencyKeyMismatchException e) {
173+
assertEqStr("mismatch expected_key", originalKey, e.getExpectedIdempotencyKey());
174+
assertEqStr("mismatch received_key", "payment:wire:different-2",
175+
e.getReceivedIdempotencyKey());
176+
assertTrue("mismatch workflow_id populated",
177+
e.getWorkflowId() != null && e.getWorkflowId().startsWith("wf_"));
178+
assertEqStr("mismatch step_id", "step-1", e.getStepId());
179+
}
180+
System.out.println(" typed 409 error ✔");
181+
182+
// 7) Complete with matching key
183+
Map<String, Object> output = new HashMap<>();
184+
output.put("transfer_id", "TXN-K1");
185+
client.markStepCompleted(
186+
wf.getWorkflowId(),
187+
"step-1",
188+
WorkflowTypes.MarkStepCompletedRequest.builder()
189+
.output(output)
190+
.idempotencyKey(originalKey)
191+
.build());
192+
System.out.println(" complete with matching key ✔");
193+
}
194+
195+
// --- helpers ---
196+
197+
private static String envOrDefault(String name, String fallback) {
198+
String v = System.getenv(name);
199+
return v == null || v.isEmpty() ? fallback : v;
200+
}
201+
202+
private static String mustEnv(String name) {
203+
String v = System.getenv(name);
204+
if (v == null || v.isEmpty()) {
205+
fail("missing env: " + name);
206+
}
207+
return v;
208+
}
209+
210+
private static void assertTrue(String label, boolean cond) {
211+
if (!cond) fail("assertion failed: " + label);
212+
}
213+
214+
private static void assertEqStr(String label, String want, String got) {
215+
if (want == null ? got != null : !want.equals(got)) {
216+
fail(label + ": want \"" + want + "\", got \"" + got + "\"");
217+
}
218+
}
219+
220+
private static void assertEqInt(String label, int want, int got) {
221+
if (want != got) fail(label + ": want " + want + ", got " + got);
222+
}
223+
224+
private static void fail(String msg) {
225+
System.err.println("FAIL: " + msg);
226+
System.exit(1);
227+
}
228+
229+
private static void banner(String s) {
230+
System.out.println();
231+
System.out.println("━━━ " + s + " ━━━");
232+
}
233+
}

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>com.getaxonflow</groupId>
88
<artifactId>axonflow-sdk</artifactId>
9-
<version>5.5.0</version>
9+
<version>5.6.0</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AxonFlow Java SDK</name>

0 commit comments

Comments
 (0)