DEV Community

Borino88
Borino88

Posted on

Designing an Idempotent Transaction API: Preventing Duplicate Financial Operations

In financial backend systems, network retries represent a classic race-condition vector. If a client attempts to post a transaction, suffers a TCP timeout, and blindly retries, the backend risks posting the transaction twice.

To prevent duplicate financial operations, we must design an Idempotent API using Idempotency Keys.

The Architecture of Idempotency

sequenceDiagram
    participant Client
    participant API Gateway
    participant Redis Cache
    participant PostgreSQL

    Client->>API Gateway: POST /journal (Idempotency-Key: X)
    API Gateway->>Redis Cache: SetNX(Key: X, Status: IN_PROGRESS)
    alt Key exists
        Redis Cache-->>Client: 409 Conflict (Request In Progress) or Cached Response
    else Key is new
        API Gateway->>PostgreSQL: Execute Double-Entry Transaction
        PostgreSQL-->>API Gateway: Transaction Committed successfully
        API Gateway->>Redis Cache: Set(Key: X, Status: DONE, Response: Y)
        API Gateway-->>Client: 201 Created (Transaction Details)
    end
Enter fullscreen mode Exit fullscreen mode

Core Database Schema & Unique Constraints

We enforce idempotency at the database level by binding the idempotency key directly to our journal entries table:

CREATE TABLE journal_entries (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,
    amount DECIMAL(18, 4) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
Enter fullscreen mode Exit fullscreen mode

Python Implementation (FastAPI & Redis)

Here is a tested pattern for validating and caching requests in Redis:

from fastapi import FastAPI, Header, HTTPException, status
import redis

app = FastAPI()
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

@app.post("/api/v1/journal", status_code=201)
def post_transaction(payload: dict, idempotency_key: str = Header(...)):
    # 1. Atomic Check-and-Set in Redis
    lock_acquired = r.set(f"idemp:{idempotency_key}", "IN_PROGRESS", nx=True, ex=3600)

    if not lock_acquired:
        status_val = r.get(f"idemp:{idempotency_key}")
        if status_val == "IN_PROGRESS":
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail="Transaction in progress. Please wait."
            )
        # Return cached response if done
        return {"status": "success", "cached": True, "data": status_val}

    try:
        # 2. Process transaction inside a database transaction block
        # (DB commit logic goes here)
        result_data = {"transaction_id": "tx_abc123", "processed": True}

        # 3. Update status to completed and store response
        r.set(f"idemp:{idempotency_key}", str(result_data), ex=86400)
        return {"status": "success", "cached": False, "data": result_data}
    except Exception as e:
        r.delete(f"idemp:{idempotency_key}")
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail="Transaction failed."
        )
Enter fullscreen mode Exit fullscreen mode

Testing for Concurrency

To ensure that concurrent requests with the exact same idempotency key are rejected, we can run multithreaded HTTP request tests using pytest and httpx.

You can view the full repository and test suites at secure-fintech-ledger.


Disclosure: This article was prepared with AI-assisted editing and research support. I reviewed the technical content, tested the code and take responsibility for the final article. #ABotWroteThis

Top comments (0)