Skip to content

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:

  1. Lookup queries by (request_id, operation) only — omitting account_id — since the original account no longer exists after the operation.
  2. Claim records the pre-operation account_id as claimAccountId.
  3. 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; checkAndClaim returns undefined cast to T
  • 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