Skip to content

Commit 401d7dd

Browse files
committed
Improve idempotency handling with deterministic key generation
Introduce deterministic idempotency key generation when a client-provided key is absent. The key is derived from canonical request payload with a time window to support safe retries. Enhancements: - deterministic retry behavior without client-provided keys - proper JSON canonicalization (ordering, nested objects, null handling) This change extends the existing idempotency mechanism without replacing it.
1 parent 2ee6f22 commit 401d7dd

File tree

2 files changed

+98
-2
lines changed

2 files changed

+98
-2
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.apache.fineract.commands.service;
2+
3+
4+
import com.fasterxml.jackson.databind.JsonNode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.fasterxml.jackson.databind.node.ArrayNode;
7+
import com.fasterxml.jackson.databind.node.ObjectNode;
8+
import lombok.RequiredArgsConstructor;
9+
import org.apache.fineract.commands.domain.CommandWrapper;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.nio.charset.StandardCharsets;
13+
import java.security.MessageDigest;
14+
import java.time.Instant;
15+
import java.util.ArrayList;
16+
import java.util.Base64;
17+
import java.util.Collections;
18+
import java.util.List;
19+
20+
@Component
21+
@RequiredArgsConstructor
22+
public class DeterministicIdempotencyKeyGenerator {
23+
24+
private final ObjectMapper objectMapper;
25+
26+
public String generate(String json) {
27+
String canonical = toCanonicalString(json);
28+
String window = currentTimeWindow();
29+
return hash(canonical + ":" + window);
30+
}
31+
32+
private String toCanonicalString(String json) {
33+
try {
34+
JsonNode node = objectMapper.readTree(json);
35+
JsonNode canonical = canonicalize(node);
36+
return objectMapper.writeValueAsString(canonical);
37+
} catch (Exception e) {
38+
throw new RuntimeException("Failed to canonicalize JSON", e);
39+
}
40+
}
41+
42+
private JsonNode canonicalize(JsonNode node) {
43+
if (node.isObject()) {
44+
ObjectNode sorted = objectMapper.createObjectNode();
45+
46+
List<String> fieldNames = new ArrayList<>();
47+
node.fieldNames().forEachRemaining(fieldNames::add);
48+
Collections.sort(fieldNames);
49+
50+
for (String field : fieldNames) {
51+
sorted.set(field, canonicalize(node.get(field))); // recursion to resolve nested obj
52+
}
53+
54+
return sorted;
55+
}
56+
57+
if (node.isArray()) {
58+
ArrayNode arrayNode = objectMapper.createArrayNode();
59+
for (JsonNode element : node) {
60+
arrayNode.add(canonicalize(element)); // recursion inside array
61+
}
62+
return arrayNode;
63+
}
64+
65+
return node; // primitives + null
66+
}
67+
68+
private String hash(String input) {
69+
try {
70+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
71+
byte[] hashed = digest.digest(input.getBytes(StandardCharsets.UTF_8));
72+
return Base64.getEncoder().encodeToString(hashed);
73+
} catch (Exception e) {
74+
throw new RuntimeException("Hashing failed", e);
75+
}
76+
}
77+
78+
private String currentTimeWindow() {
79+
Instant now = Instant.now();
80+
long window = now.getEpochSecond() / (5 * 60);
81+
return String.valueOf(window);
82+
}
83+
}

fineract-core/src/main/java/org/apache/fineract/commands/service/IdempotencyKeyResolver.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,23 @@ public class IdempotencyKeyResolver {
3030

3131
private final FineractRequestContextHolder fineractRequestContextHolder;
3232

33-
private final IdempotencyKeyGenerator idempotencyKeyGenerator;
33+
private final DeterministicIdempotencyKeyGenerator deterministicGenerator;
3434

3535
public String resolve(CommandWrapper wrapper) {
36-
return Optional.ofNullable(wrapper.getIdempotencyKey()).orElseGet(() -> getAttribute().orElseGet(idempotencyKeyGenerator::create));
36+
37+
// If wrapper already has key → use it
38+
if (wrapper.getIdempotencyKey() != null) {
39+
return wrapper.getIdempotencyKey();
40+
}
41+
42+
// If request attribute exists (internal retry)
43+
Optional<String> attributeKey = getAttribute();
44+
if (attributeKey.isPresent()) {
45+
return attributeKey.get();
46+
}
47+
48+
// hybrid logic (external retry)
49+
return deterministicGenerator.generate(wrapper.getJson());
3750
}
3851

3952
private Optional<String> getAttribute() {

0 commit comments

Comments
 (0)