Appearance
IdempotencyManager
Handles request deduplication for user-facing operations using client-provided idempotency keys.
Purpose
Prevents duplicate execution of user actions when clients retry requests using a client-provided requestId. Keys expire after 24 hours.
High-Level Design
Check-Claim-Complete Pattern
Every idempotent RPC handler follows a three-phase lifecycle:
Client request
(with optional requestId)
│
▼
┌──────────────────────────────┐
│ 1. CHECK: Look up requestId │
│ in idempotency_keys │
└──────────────┬───────────────┘
│
┌─────────┼─────────┐
▼ ▼ ▼
NEW COMPLETED PROCESSING
│ │ │
▼ ▼ ▼
Claim Return Reject with
key cached DUPLICATE_REQUEST
│ result
▼
┌─────────────────────────────┐
│ 2. EXECUTE: Run business │
│ logic (order, reset, etc)│
└──────────────┬──────────────┘
│
┌─────────┴─────────┐
▼ ▼
Success Failure
│ │
▼ ▼
┌────────────┐ ┌──────────┐
│3a.COMPLETE │ │3b. FAIL │
│Cache result│ │Delete key│
│for retries │ │(retry OK)│
└────────────┘ └──────────┘withIdempotency() Helper
All handlers use the withIdempotency() utility (src/utils/idempotency-helper.ts) to eliminate boilerplate. It wraps the check-claim-complete lifecycle so handlers only contain business logic. On success, the result is cached. On failure (returned or thrown), the claim is deleted so the client can retry with the same key.
Key Scoping: Composite Primary Key
Keys are scoped by (account_id, request_id, operation). This means the same requestId can be reused across different operations or accounts without collision.
Account-Changing Operations
RESET and START_CHALLENGE create a new account, which changes the account_id mid-execution. For these operations:
- Lookup queries by
(request_id, operation)only — omittingaccount_id— since the original account no longer exists after the operation. - Claim records the pre-operation
account_idasclaimAccountId. - Complete/Fail uses
claimAccountId(not current account) to update the correct row.
Optional Idempotency
The requestId parameter is optional on all operations. When omitted, checkAndClaim returns NEW with an empty claimId and skips all DB operations — every request executes without deduplication.
Key Expiration
Keys have a 24-hour TTL. Expired keys are cleaned up on DO initialization via a DELETE WHERE created_at < cutoff query, indexed on created_at.
Edge Cases & Error Handling
- PROCESSING status: Handles rapid client retries before first request completes (DOs are single-threaded, so this is the only concurrency scenario)
- Failed operations: Claim is deleted immediately, allowing retry with the same key
- Null result_json: Void operations (e.g., reset) store no result;
checkAndClaimreturnsundefinedcast toT - INSERT OR REPLACE on claim: If a stale PROCESSING key exists from a crashed execution (e.g., DO eviction), re-claiming with the same key overwrites it
See Also
- UserPaperTradePortfolio - Parent DO
- OrderExecutionManager - Primary consumer for position open/close
- LimitOrderManager - Consumer for limit order operations