Skip to content

Commit 9eac975

Browse files
feat(infra): implement real idempotency storage and replay for S10/S11/S13 (STA-72) (#185)
* feat(infra): implement real idempotency storage and replay for S10/S11/S13 (STA-72) Replace validation-only IdempotencyKeyFilter in api-gateway-iam (S10), merchant-onboarding (S11), and merchant-iam (S13) with full idempotency implementation that persists key + SHA-256 request hash + response body, replays cached responses on duplicate requests, and returns 422 on hash mismatches. Changes per service: - Flyway migration to create {prefix}_idempotency_keys table - IdempotencyKeyFilter rewritten with JdbcTemplate persistence, CachedBodyRequestWrapper for body re-reading, and ContentCachingResponseWrapper for response capture - IdempotencyCleanupJob for hourly expired key cleanup - application.yml additions for TTL and cleanup config - 5 unit tests (IdempotencyKeyFilterTest) - 4 integration tests (IdempotencyKeyFilterIT) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(infra): address PR review — scoped PK, TOCTOU race fix, batched cleanup (STA-72) - Add request_method + request_path to composite PK for endpoint-scoped idempotency - INSERT-first reservation pattern to prevent TOCTOU race on concurrent duplicates - Filter expired keys at read time (AND expires_at > NOW()) - Batched DELETE in cleanup jobs to avoid lock spikes - Remove raw idempotency keys from log messages - Add missing table cleanup in S13 IT @beforeeach - Add 409 Conflict response for in-flight duplicate requests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(infra): add JVM shutdown hooks to stop Testcontainers after test execution Testcontainers started in static initializers were never stopped, causing orphaned PostgreSQL, Kafka, Redis, and Mailpit containers to accumulate after each test run (~30-50 containers, 5-15 GB leaked memory). Add Runtime.getRuntime().addShutdownHook() to all 10 AbstractIntegrationTest classes to ensure containers are stopped when the JVM exits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(infra): address CodeRabbit review — only persist 2xx, exception safety, expired reclaim (STA-72) - Only persist idempotency records for 2xx responses; delete reservation for non-2xx so failed requests can be retried - Wrap chain.doFilter in try-catch to clean up reservation on exception, preventing orphaned in-flight rows that block retries until TTL expires - Reclaim expired rows on INSERT conflict: if cleanup hasn't run and an expired row blocks the INSERT, delete it and retry once - Add PUT method unit test to all 3 services - Strengthen ProceedAndPersist test assertion to verify no error codes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d4a8289 commit 9eac975

26 files changed

Lines changed: 2020 additions & 36 deletions

File tree

api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ void cleanDatabase() {
5555
oauth_clients,
5656
rate_limit_events,
5757
gateway_audit_log,
58+
gatewayiam_idempotency_keys,
5859
merchants
5960
CASCADE
6061
""");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package com.stablecoin.payments.gateway.iam.application.config;
2+
3+
import com.stablecoin.payments.gateway.iam.AbstractIntegrationTest;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.beans.factory.annotation.Autowired;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.jdbc.core.JdbcTemplate;
9+
import org.springframework.test.web.servlet.MockMvc;
10+
11+
import java.sql.Timestamp;
12+
import java.time.Instant;
13+
import java.time.temporal.ChronoUnit;
14+
import java.util.UUID;
15+
16+
import static org.assertj.core.api.Assertions.assertThat;
17+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
18+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
19+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
20+
21+
@DisplayName("IdempotencyKeyFilter IT")
22+
class IdempotencyKeyFilterIT extends AbstractIntegrationTest {
23+
24+
@Autowired
25+
private MockMvc mockMvc;
26+
27+
@Autowired
28+
private JdbcTemplate jdbcTemplate;
29+
30+
private static final String MERCHANT_ENDPOINT = "/v1/merchants";
31+
32+
@Test
33+
@DisplayName("should persist idempotency key after successful mutation")
34+
void shouldPersistIdempotencyKey_afterSuccessfulMutation() throws Exception {
35+
var idempotencyKey = UUID.randomUUID().toString();
36+
var externalId = UUID.randomUUID();
37+
38+
mockMvc.perform(post(MERCHANT_ENDPOINT)
39+
.contentType(MediaType.APPLICATION_JSON)
40+
.header("Idempotency-Key", idempotencyKey)
41+
.content("""
42+
{
43+
"externalId": "%s",
44+
"name": "Idempotency Test Corp",
45+
"country": "US"
46+
}
47+
""".formatted(externalId)))
48+
.andExpect(status().isCreated());
49+
50+
var count = jdbcTemplate.queryForObject(
51+
"SELECT COUNT(*) FROM gatewayiam_idempotency_keys WHERE idempotency_key = ?",
52+
Integer.class, idempotencyKey);
53+
54+
assertThat(count).isEqualTo(1);
55+
}
56+
57+
@Test
58+
@DisplayName("should replay response on duplicate request with same key and body")
59+
void shouldReplayResponse_onDuplicateRequest() throws Exception {
60+
var idempotencyKey = UUID.randomUUID().toString();
61+
var externalId = UUID.randomUUID();
62+
var requestBody = """
63+
{
64+
"externalId": "%s",
65+
"name": "Replay Test Corp",
66+
"country": "US"
67+
}
68+
""".formatted(externalId);
69+
70+
// First request
71+
var firstResponse = mockMvc.perform(post(MERCHANT_ENDPOINT)
72+
.contentType(MediaType.APPLICATION_JSON)
73+
.header("Idempotency-Key", idempotencyKey)
74+
.content(requestBody))
75+
.andExpect(status().isCreated())
76+
.andReturn();
77+
78+
// Second request — same key, same body
79+
var secondResponse = mockMvc.perform(post(MERCHANT_ENDPOINT)
80+
.contentType(MediaType.APPLICATION_JSON)
81+
.header("Idempotency-Key", idempotencyKey)
82+
.content(requestBody))
83+
.andExpect(status().isCreated())
84+
.andExpect(header().string("Idempotency-Replay", "true"))
85+
.andReturn();
86+
87+
assertThat(secondResponse.getResponse().getContentAsString())
88+
.isEqualTo(firstResponse.getResponse().getContentAsString());
89+
}
90+
91+
@Test
92+
@DisplayName("should return 422 when same key but different body")
93+
void shouldReturn422_whenSameKeyDifferentBody() throws Exception {
94+
var idempotencyKey = UUID.randomUUID().toString();
95+
96+
// First request
97+
mockMvc.perform(post(MERCHANT_ENDPOINT)
98+
.contentType(MediaType.APPLICATION_JSON)
99+
.header("Idempotency-Key", idempotencyKey)
100+
.content("""
101+
{
102+
"externalId": "%s",
103+
"name": "First Corp",
104+
"country": "US"
105+
}
106+
""".formatted(UUID.randomUUID())))
107+
.andExpect(status().isCreated());
108+
109+
// Second request — same key, different body
110+
mockMvc.perform(post(MERCHANT_ENDPOINT)
111+
.contentType(MediaType.APPLICATION_JSON)
112+
.header("Idempotency-Key", idempotencyKey)
113+
.content("""
114+
{
115+
"externalId": "%s",
116+
"name": "Different Corp",
117+
"country": "GB"
118+
}
119+
""".formatted(UUID.randomUUID())))
120+
.andExpect(status().isUnprocessableEntity());
121+
}
122+
123+
@Test
124+
@DisplayName("should delete expired keys when cleanup job runs")
125+
void shouldDeleteExpiredKeys_whenCleanupJobRuns() throws Exception {
126+
// Insert an expired idempotency key directly
127+
jdbcTemplate.update(
128+
"INSERT INTO gatewayiam_idempotency_keys"
129+
+ " (idempotency_key, request_method, request_path, request_hash, response_body, status_code, expires_at)"
130+
+ " VALUES (?, ?, ?, ?, ?, ?, ?)",
131+
"expired-key", "POST", "/v1/merchants", "somehash", "{}", 200,
132+
Timestamp.from(Instant.now().minus(1, ChronoUnit.HOURS)));
133+
134+
var cleanupJob = new com.stablecoin.payments.gateway.iam.application.job.IdempotencyCleanupJob(jdbcTemplate);
135+
cleanupJob.cleanExpiredKeys();
136+
137+
var count = jdbcTemplate.queryForObject(
138+
"SELECT COUNT(*) FROM gatewayiam_idempotency_keys WHERE idempotency_key = ?",
139+
Integer.class, "expired-key");
140+
141+
assertThat(count).isEqualTo(0);
142+
}
143+
}

0 commit comments

Comments
 (0)