Production-grade idempotency handling for repeat transaction requests in the Stellarcade frontend.
This module provides client-side idempotency support that works in conjunction with the backend's idempotency middleware. It prevents duplicate transaction submissions, tracks request lifecycle state, and enables safe retry logic for failed or unknown-outcome requests.
- Types (
types/idempotency.ts) - Type definitions for idempotency keys, request states, and service interfaces - Service (
services/idempotency-transaction-handling.ts) - Core idempotency service implementation
┌─────────┐ ┌────────────┐ ┌────────────┐ ┌───────────┐
│ PENDING │ -> │ IN_FLIGHT │ -> │ COMPLETED │ │ │
└─────────┘ └────────────┘ └───────────┘ │ │
| | │ SUCCESS │
| | │ │
| └──────────┐ │ │
| │ │ │
v v v v
┌─────────┐ ┌────────────┐ ┌───────────┐ ┌───────────┐
│ FAILED │ <- │ UNKNOWN │ <- │ IN_FLIGHT │ │ FAILURE │
└─────────┘ └────────────┘ └───────────┘ │ │
(recoverable) │ │
└───────────┘
| State | Description | Mutable |
|---|---|---|
PENDING |
Request created, not yet submitted | Yes |
IN_FLIGHT |
Submitted to wallet/RPC, awaiting confirmation | Yes |
COMPLETED |
Transaction confirmed on-chain | No (terminal) |
FAILED |
Terminal failure (will not retry) | No (terminal) |
UNKNOWN |
Outcome unknown (timeout, wallet closed) | Yes (recoverable) |
import { getIdempotencyService } from "@/services/idempotency-transaction-handling";
import { StorageStrategy } from "@/types/idempotency";
// Initialize service with session storage (persists across page reloads)
const service = getIdempotencyService({
strategy: StorageStrategy.SESSION,
ttl: 30 * 60 * 1000, // 30 minutes
});
// Generate a unique key for this operation
const key = service.generateKey({
operation: "coinFlip",
userContext: "wallet:GABC...",
});
// Check for duplicate before proceeding
const duplicate = service.checkDuplicate(key);
if (!duplicate.isDuplicate) {
// Register the request
service.registerRequest(key, "coinFlip", { gameId: "game_123" });
// Update state as transaction progresses
service.updateState(key, "IN_FLIGHT");
try {
// Execute transaction
const result = await submitTransaction();
// Mark as completed
service.updateState(key, "COMPLETED", {
txHash: result.hash,
ledger: result.ledger,
});
} catch (error) {
// Mark as failed
service.updateState(key, "FAILED", { error });
}
} else {
// Handle duplicate - return cached result or show error
console.log("Duplicate request detected:", duplicate.reason);
}When making API calls that require idempotency (wallet operations, game plays):
import {
getIdempotencyService,
StorageStrategy,
} from "@/services/idempotency-transaction-handling";
const idempotencyService = getIdempotencyService({
strategy: StorageStrategy.SESSION,
});
async function makeWithdrawal(amount: number, address: string) {
// Generate idempotency key
const idempotencyKey = idempotencyService.generateKey({
operation: "withdraw",
userContext: address,
});
// Register request
idempotencyService.registerRequest(idempotencyKey, "withdraw", {
amount,
address,
});
try {
// Make API call with idempotency key
const response = await fetch("/api/wallet/withdraw", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"Idempotency-Key": idempotencyKey, // Critical for backend idempotency
},
body: JSON.stringify({ amount, address }),
});
if (response.ok) {
const result = await response.json();
idempotencyService.updateState(idempotencyKey, "COMPLETED");
return result;
} else if (response.status === 409) {
// Idempotency conflict - key reused with different body
idempotencyService.updateState(idempotencyKey, "FAILED", {
error: { message: "Idempotency key conflict" },
});
throw new Error(
"Idempotency conflict: key already used with different data",
);
} else {
throw new Error(`Request failed: ${response.status}`);
}
} catch (error) {
// Network error or timeout - safe to retry with SAME key
idempotencyService.updateState(idempotencyKey, "UNKNOWN");
throw error; // Caller can decide to retry
}
}async function retryWithIdempotency<T>(
operation: string,
apiCall: () => Promise<T>,
maxRetries = 3,
): Promise<T> {
const idempotencyKey = idempotencyService.generateKey({ operation });
idempotencyService.registerRequest(idempotencyKey, operation);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await apiCall();
idempotencyService.updateState(idempotencyKey, "COMPLETED");
return result;
} catch (error) {
if (attempt === maxRetries) {
idempotencyService.updateState(idempotencyKey, "FAILED", { error });
throw error;
}
// Update state to UNKNOWN for recovery
idempotencyService.updateState(idempotencyKey, "UNKNOWN");
// Exponential backoff
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
}
}
}async function recoverUnknownTransaction(idempotencyKey: string) {
const request = idempotencyService.getRequest(idempotencyKey);
if (request?.state !== "UNKNOWN") {
return; // Nothing to recover
}
if (!request.txHash) {
// Cannot recover without transaction hash
idempotencyService.updateState(idempotencyKey, "FAILED", {
error: { message: "Cannot recover: no transaction hash" },
});
return;
}
// Poll Stellar RPC to check transaction status
const txStatus = await stellarRpc.getTransaction(request.txHash);
if (txStatus.success) {
idempotencyService.updateState(idempotencyKey, "COMPLETED", {
txHash: request.txHash,
ledger: txStatus.ledger,
});
} else if (txStatus.failed) {
idempotencyService.updateState(idempotencyKey, "FAILED", {
error: { message: "Transaction failed on-chain" },
});
}
}| Strategy | Persistence | Use Case |
|---|---|---|
MEMORY |
Lost on page reload | Short-lived operations, testing |
SESSION |
Lost on tab/window close | Recommended for most transactions |
LOCAL |
Persists across sessions | Long-running operations, critical transactions |
// Session storage (default, recommended)
const sessionService = getIdempotencyService({
strategy: StorageStrategy.SESSION,
ttl: 30 * 60 * 1000, // 30 minutes
});
// Local storage for critical operations
const localStorageService = getIdempotencyService({
strategy: StorageStrategy.LOCAL,
ttl: 60 * 60 * 1000, // 1 hour
keyPrefix: "stellarcade_critical",
});Generates a unique idempotency key for an operation.
Parameters:
operation(string): Operation identifier (e.g., 'coinFlip', 'withdraw')userContext(string, optional): User-provided context (e.g., wallet address)timestamp(number, optional): Override timestamp (defaults toDate.now())
Returns: string - Format: {operation}_{timestamp}_{randomId}
Checks if a key represents an active duplicate submission.
Returns:
isDuplicate: boolean- True if active duplicate foundexistingRequest?: IdempotencyRequest- The existing request if foundreason?: string- Reason for duplicate detection
Registers a new idempotent request in PENDING state.
Updates request state with optional metadata (txHash, ledger, error).
Valid State Transitions:
PENDING→IN_FLIGHTorFAILEDIN_FLIGHT→COMPLETED,FAILED, orUNKNOWNUNKNOWN→COMPLETEDorFAILEDCOMPLETED→ (terminal, no transitions)FAILED→ (terminal, no transitions)
Attempts to recover a transaction with UNKNOWN outcome.
- Generate keys client-side using the service's
generateKey()method - Use session storage for most transactions (survives page reload, cleared on close)
- Check for duplicates before submitting new requests
- Update state progressively as the transaction lifecycle advances
- Handle 409 errors from backend as programming errors (key reuse with different data)
- Retry with the same key when network errors occur
- Don't reuse keys across different operations
- Don't generate keys server-side (loses client-side tracking benefits)
- Don't skip duplicate checks before submission
- Don't use local storage unless the operation is critical and long-running
- Don't ignore UNKNOWN states - implement recovery logic
if (response.status === 409) {
// This indicates a bug: key was reused with different request body
// Log the error and generate a new key for the operation
console.error("Idempotency conflict - key reuse detected");
idempotencyService.updateState(key, "FAILED", {
error: { message: "Idempotency key conflict" },
});
}try {
const result = await apiCall();
// Success
} catch (error) {
if (error.name === "TimeoutError") {
// Mark as UNKNOWN - outcome is uncertain
idempotencyService.updateState(key, "UNKNOWN");
// Can safely retry with same key
}
}import {
IdempotencyTransactionHandler,
StorageStrategy,
} from "@/services/idempotency-transaction-handling";
describe("IdempotencyService", () => {
let service;
beforeEach(() => {
service = new IdempotencyTransactionHandler({
strategy: StorageStrategy.MEMORY,
});
});
it("generates unique keys", () => {
const key1 = service.generateKey({ operation: "test" });
const key2 = service.generateKey({ operation: "test" });
expect(key1).not.toBe(key2);
});
it("detects duplicates", () => {
const key = service.generateKey({ operation: "test" });
service.registerRequest(key, "test");
const result = service.checkDuplicate(key);
expect(result.isDuplicate).toBe(true);
});
it("validates state transitions", () => {
const key = service.generateKey({ operation: "test" });
service.registerRequest(key, "test");
// Valid: PENDING -> IN_FLIGHT
service.updateState(key, "IN_FLIGHT");
// Invalid: IN_FLIGHT -> PENDING
expect(() => service.updateState(key, "PENDING")).toThrow();
});
});