Raxx · internal docs

internal · gated

ADR-0138 — Idempotency-Key Mechanism for Raptor Write Endpoints

Status: Accepted Date: 2026-07-10 UTC Deciders: Kristerpher (operator); software-architect (design) Scope: Raptor (backend_v2/); getraxx.com signup surface; all Raptor mutating routes that affect money, positions, or account state Refs: #4002 (this design card), #4001 (waitlist dedup, now closed),

4000 (parent epic), ADR-0127 (Stripe webhook idempotency, Stripe-side already solved)


Context

Raptor's write paths are called from Antlers (client retries on network failure), internal service-to-service calls, and operator tooling. Without a dedup layer, a transient 5xx followed by a client retry can double-apply state changes: a second order row, two billing checkout sessions, or two LCC roll legs submitted against the same position.

The deterministic-execution invariant makes silent double-fire uniquely dangerous here: every order the paper engine executes is intentional and audited. An accidental duplicate changes cash balance and position state in ways that are difficult to unwind after the fact.

Prior work — do not duplicate:

Path What it does Status
waitlist_signups Unique index on lower(email) + IntegrityError → 202 Done (#4001)
billing_mirror_sync ON CONFLICT DO UPDATE upsert for subscription mirror Done
freescout_audit_webhook ON CONFLICT (ticket_id) DO UPDATE Done
auth.register_verify_with_token Returns 200 idempotently on existing credential (#2771) Done
Stripe webhooks processed_stripe_events dedup table (Queue service) Done (ADR-0127)
Apple IAP notifications Dispatcher-level dedup on notification_uuid Done
cancel_order DELETE MbtFillEngine returns current status (safe to call twice) Done

This ADR designs the general mechanism covering the remaining mutating routes — primarily order submission and other money/position-affecting writes.


Invariants

These constraints from the project charter govern this design and override any implementation convenience:


Decision

Implement a client-supplied Idempotency-Key: <UUID> header pattern backed by a dedicated Postgres table (idempotency_keys). The concurrency serialization strategy is unique-constraint-first with in-flight status: the first request to INSERT a key wins and executes; concurrent duplicates read the status column and either replay the completed response or receive a 409 "in progress" response that instructs retry. For order-affecting endpoints, the middleware is fail-closed: if the idempotency store is unavailable, the request is rejected. For other endpoints (billing, account mutations), the middleware is fail-open: if the store is unavailable, the request proceeds without dedup protection but with a warning log.


Language choice rationale

This ADR does not introduce a new service. The idempotency middleware is a Python decorator and a Postgres table addition within the existing Raptor (Tier 2 Python) service. No tier reclassification is triggered.


Data model

idempotency_keys table

-- POSTGRES-ONLY
CREATE TABLE idempotency_keys (
    id              BIGSERIAL      PRIMARY KEY,
    idem_key        TEXT           NOT NULL,
    scope_user_id   INTEGER        REFERENCES users(id) ON DELETE CASCADE,
    scope_endpoint  TEXT           NOT NULL,
    request_hash    TEXT           NOT NULL,
    response_code   SMALLINT,
    response_body   JSONB,
    status          TEXT           NOT NULL DEFAULT 'in_flight'
                                   CHECK (status IN ('in_flight','completed','failed')),
    created_at      TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
    locked_at       TIMESTAMPTZ    NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMPTZ    NOT NULL,
    CONSTRAINT uq_idem_key_user_endpoint
        UNIQUE (idem_key, scope_user_id, scope_endpoint)
);

CREATE INDEX idx_idem_keys_expires
    ON idempotency_keys (expires_at)
    WHERE status IN ('completed','failed');

CREATE INDEX idx_idem_keys_user
    ON idempotency_keys (scope_user_id)
    WHERE scope_user_id IS NOT NULL;

Column notes:

Column Purpose
idem_key Client-supplied UUID string; opaque to the server
scope_user_id The authenticated user's users.id; NULL for unauthenticated or service calls
scope_endpoint Normalized "METHOD /path/pattern" e.g. "POST /api/trading/orders"
request_hash SHA-256 hex of the canonical request body; used to detect key reuse with different body (409 body-mismatch)
response_code HTTP status of the original response; NULL while in_flight
response_body JSON of the original response body; NULL while in_flight
status in_flight (executing) → completed or failed
locked_at Timestamp of INSERT; used for stale-in-flight detection (>30s = treat as failed)
expires_at Hard TTL: 24h for order endpoints, 7d for billing endpoints

Key scoping rule: The unique constraint is (idem_key, scope_user_id, scope_endpoint). User A's key abc and User B's key abc on the same endpoint are distinct records. A service call without a user scope has scope_user_id = NULL; two such calls with the same key and endpoint on the same endpoint DO conflict (Postgres UNIQUE treats NULL = NULL only with partial indexes; the constraint is written without NULLs-as- not-equal which means NULL-scoped keys don't conflict — for service endpoints this is acceptable as they use endpoint-specific natural dedup).

TTL values:

Endpoint family TTL
Order submit (paper orders, IC, LCC roll) 24 hours
Billing mutations (checkout, refund, subscription changes) 7 days
Account mutations (paper reset, preferences) 24 hours

API contracts

Request header

Idempotency-Key: <uuid-v4>

Response semantics

Scenario HTTP Body
First call, business logic succeeds per-handler (201/200) per-handler
Replay of completed key same code as original same body as original + "idempotency_replayed": true
Same key, different body 422 {"error": "idempotency_key_body_mismatch"}
Same key, request still in flight 409 {"error": "idempotency_in_flight", "retry_after_ms": 5000}
Same key, previous request failed 409 {"error": "idempotency_previous_failed"}
Idempotency store unavailable (fail-closed endpoint) 503 {"error": "idempotency_store_unavailable"}
Idempotency store unavailable (fail-open endpoint) per-handler per-handler (proceeds without dedup)

Endpoint coverage map

Tier 1 — Fail-closed (order/position writes)

These endpoints must REJECT the request if the idempotency store is unavailable. A retry on a 503 is safe; a duplicate fire is not.

Endpoint Method Risk Notes
/api/trading/orders POST HIGH market + limit orders; creates paper_orders row + audit
/api/trading/orders/ic POST HIGH 4-leg IC; calls submit_multi_leg; creates up to 5 rows
/api/lcc/roll/submit POST HIGH two-leg roll (close-first); partial completion is a known risk (V1 limitation; idempotency key prevents double-roll)
/api/trading/paper/reset POST MEDIUM destructive (wipes positions + orders); cooldown is partial dedup; idempotency key provides deterministic dedup
/api/trading/orders/<id> PATCH (replace) MEDIUM cancel + re-create; double-replace creates phantom order

Tier 2 — Fail-open (billing/account mutations)

These have upstream dedup (Stripe idempotency keys, Queue billing_action_log) or lower blast radius. Idempotency-Key is additive protection.

Endpoint Method Risk Notes
/api/billing/checkout-session POST MEDIUM Double-click creates two Stripe sessions; idempotency key collapses to one
/api/billing/refund POST MEDIUM Queue handles Stripe idempotency; Raptor-layer key prevents double Queue dispatch
/api/subscription/upgrade POST LOW-MED Stripe-side dedup; Raptor key adds defense-in-depth
/api/subscription/downgrade POST LOW-MED Same
/api/subscription/cancel POST LOW-MED Same
/api/account-merge/<id>/billing-choice POST LOW State machine; already guarded by merge status column

Already idempotent — do not instrument (would be redundant)


Concurrency serialization

This is the critical correctness property: two simultaneous requests with the same Idempotency-Key must not both execute the business logic.

Algorithm (fail-closed path):

1. Extract Idempotency-Key header. If absent, passthrough.
2. Compute request_hash = SHA256(sorted_json_body).
3. Attempt INSERT INTO idempotency_keys
       (idem_key, scope_user_id, scope_endpoint, request_hash, status, locked_at, expires_at)
   VALUES (:key, :uid, :endpoint, :hash, 'in_flight', NOW(), NOW() + :ttl)
   ON CONFLICT DO NOTHING
   RETURNING id, status, request_hash, response_code, response_body, locked_at;
4. If INSERT returned a row (we won the race):
   a. Execute the business logic handler.
   b. On success:
      UPDATE idempotency_keys
        SET status='completed', response_code=:code, response_body=:body
        WHERE id=:id;
      Return handler response.
   c. On failure:
      UPDATE idempotency_keys SET status='failed' WHERE id=:id;
      Return handler error response.
5. If INSERT returned no row (conflict — someone else holds or held this key):
   SELECT id, status, request_hash, response_code, response_body, locked_at
   FROM idempotency_keys
   WHERE idem_key=:key AND scope_user_id=:uid AND scope_endpoint=:endpoint;

   a. If status='completed': return stored response + "idempotency_replayed": true.
   b. If status='failed': return 409 idempotency_previous_failed.
   c. If status='in_flight':
      - If NOW() - locked_at > 30s (stale): treat as failed → 409 + log alert.
      - Else: return 409 idempotency_in_flight with retry_after_ms=5000.
   d. If request_hash != stored hash: return 422 idempotency_key_body_mismatch
      (checked before status).

Why not SELECT FOR UPDATE? The INSERT ON CONFLICT DO NOTHING RETURNING pattern achieves the same race-win determination in a single round-trip without holding a row-level lock across the business logic execution time (which could be 100-500ms for order submission). Holding a FOR UPDATE lock that long would serialize all concurrent requests for the same user behind one lock, causing timeout cascades under normal retry load.

Why INSERT-first rather than SELECT-then-INSERT? SELECT-then-INSERT has a check-then-act race: two requests both read "not found" and both INSERT, both winning. The unique constraint turns the INSERT into an atomic compare-and-swap.

Stale in-flight threshold (30s): Raptor's gunicorn worker timeout is configured at 30s. A request that has been in-flight for >30s has been killed by the worker timeout and will never complete — its row is an orphan. The 30s threshold therefore safely frees the key for a new attempt. Log a WARNING when a stale in-flight is detected (possible indicator of worker crashes or DB slowness).

Stale-heal audit note: If a roll's handler wrote to audit_log outside tx-2 (i.e. via a separate transaction rather than through caller_conn), those audit rows will have committed even though tx-2 was rolled back — leaving audit entries that describe an operation that never completed. Cross-check orphan audit_log entries against paper_orders when investigating stale-heal incidents on roll endpoints.


State machine

stateDiagram-v2
    [*] --> in_flight : INSERT wins race
    in_flight --> completed : handler succeeds
    in_flight --> failed : handler errors
    in_flight --> stale_in_flight : locked_at > 30s ago [read by loser]
    stale_in_flight --> [*] : 409 returned, new attempt may INSERT

    [*] --> replay : INSERT conflicts, status=completed
    replay --> [*] : stored response returned

    [*] --> conflict_failed : INSERT conflicts, status=failed
    conflict_failed --> [*] : 409 returned

    [*] --> in_progress : INSERT conflicts, status=in_flight (fresh)
    in_progress --> [*] : 409 retry_after returned

Middleware design

# Stub — feature-developer implements against this spec.
# Location: backend_v2/api/middleware/idempotency.py

def idempotency_key(*, ttl_hours: int = 24, fail_closed: bool = True):
    """
    Decorator for Raptor route handlers.

    @bp.route('/orders', methods=['POST'])
    @require_session
    @idempotency_key(ttl_hours=24, fail_closed=True)
    def place_order():
        ...

    fail_closed=True: 503 if idempotency store is unavailable.
    fail_closed=False: proceed without dedup, log warning.

    The decorator:
    1. Reads Idempotency-Key header; returns 400 if malformed UUID.
    2. Computes request_hash from request.get_json(silent=True).
    3. Runs the INSERT ON CONFLICT DO NOTHING algorithm above.
    4. On replay: returns stored response with "idempotency_replayed" injected.
    5. On winner path: calls the wrapped handler, then stores result.
    6. Records idempotency_hit / idempotency_miss / idempotency_replay counters
       for Prometheus (see [ADR-0121](https://internal-docs.raxx.app/architecture/adr/0121-status-surface-registry.html) observability surface).
    """

Flag gate: FLAG_IDEMPOTENCY_MIDDLEWARE. When off, the decorator is a passthrough. This enables the migration to land before any enforcement is active.

The decorator must be applied AFTER @require_session so that g.current_user_id is available for scope_user_id.


Migrations

Migration 0060 (0060_idempotency_keys.py):

Cleanup job:

A Woodpecker nightly cron step (or Raptor CLI command flask idempotency cleanup) runs:

DELETE FROM idempotency_keys WHERE expires_at < NOW();

This is safe to run while the table is live. Index idx_idem_keys_expires makes it an index scan. Target: run at 04:00 UTC daily. Cleanup job is a sub-card (SC-IK-1) responsibility.

B1 flag migration: The FLAG_IDEMPOTENCY_MIDDLEWARE flag must have a console_flag_promotions migration in the same PR as the flag YAML entry, per the project standard (feedback: new_flag_needs_b1_migration_same_pr).


Rollout plan

Wave Condition Endpoints
Dark (flag off) Migration merged; decorator present but passthrough All instrumented endpoints
Wave 1 (flag on, staging) FLAG_IDEMPOTENCY_MIDDLEWARE=1 on staging only Tier 1: POST /api/trading/orders, POST /api/trading/orders/ic
Wave 2 (flag on, prod) Wave 1 soak ≥ 5 trading sessions with no replay anomalies Tier 1: LCC roll + paper reset + order replace
Wave 3 (flag on, prod) Wave 2 soak ≥ 1 week Tier 2: billing + account mutations
GA Operator confirms no anomalies All endpoints; begin requiring the header for Tier 1

Kill-switch: set FLAG_IDEMPOTENCY_MIDDLEWARE=0 to revert the decorator to a passthrough on all endpoints simultaneously without a code deploy.


getraxx signup gap vs #4001

4001 shipped:

Remaining gap (covered by this ADR's general mechanism, not requiring new specialized work):

Net result: no sub-card is required for signup dedup beyond #4001.


Security considerations


Open questions

  1. Live trading path: This design covers the paper-trading (MBT) path. When live-trading against a broker is enabled, the idempotency key should be forwarded as the broker's own idempotency key (e.g. client_order_id for Alpaca). This requires a separate design pass. Flag for future ADR.

  2. response_body encryption: Responses stored in JSONB are visible to anyone with Postgres read access (support roles, sre-agent). Operator decision needed: encrypt at the column level (Postgres pgcrypto) or accept that order details in the idempotency store are readable with DB access.

  3. Idempotency-Key required vs optional: Should the header be required (return 400 if absent) for Tier 1 endpoints in GA? Required header enforces client correctness but breaks unmodified clients. Operator decision needed before Wave 3 rollout.


Alternatives considered

A. Natural-key dedup per endpoint

Use the domain semantics to deduplicate: e.g. reject a second order with identical symbol/side/qty within N seconds. Rejected because: - A user legitimately wants two identical orders (averaging in). - The dedup window is arbitrary and leaks state to the caller. - Does not generalize across endpoints; requires bespoke logic per route.

B. Server-derived hash key (no client header)

Hash the request body server-side; use that as the idempotency key. Rejected because: - Two orders for the same stock submitted seconds apart are distinct operations, not retries. A body hash would falsely deduplicate them. - The client knows when it is retrying; the server cannot know without a client signal.

C. Pessimistic locking (SELECT FOR UPDATE)

Take a row lock on an idempotency row, hold it across the business logic execution, release on commit. Rejected because: - Holding a row lock for 100-500ms under retry load causes lock-wait timeout cascades. - INSERT ON CONFLICT DO NOTHING RETURNING achieves the same race safety without a held lock.

D. Redis-backed idempotency store

Use Redis SETNX for atomic claim. Rejected because: - Raptor does not currently have a Redis dependency; adding one for idempotency alone is not justified. - Postgres is already present; its unique constraint provides equivalent atomicity. - Redis persistence is optional; a crash before response write would drop the key and allow re-execution — the failure mode is worse than Postgres.


Security / GDPR checklist


Revisit when