DEV Community

晖莫
晖莫

Posted on

The Unique Index That Makes Your Endpoint Retry-Safe

The payment form double-submitted on a flaky connection. Two POSTs hit /payments with the same idempotency key. One row landed in the table. The client got 200 OK both times. I asked why the second insert did not fail, and got three different answers from three engineers.

The real answer was in a migration from before any of us joined.

CREATE UNIQUE INDEX payments_idempotency_key_idx
    ON payments (idempotency_key);
Enter fullscreen mode Exit fullscreen mode

That index, and nothing in the Python, is what made the endpoint safe to retry.

The constraint is the retry policy

The handler looked like this.

def create_payment(req):
    try:
        cur.execute(
            """INSERT INTO payments (idempotency_key, account_id, amount_cents)
               VALUES (%s, %s, %s)
               ON CONFLICT DO NOTHING""",
            (req.key, req.account_id, req.amount_cents),
        )
        db.commit()
    except Exception:
        db.rollback()
    return {"status": "accepted"}
Enter fullscreen mode Exit fullscreen mode

Note the bare ON CONFLICT DO NOTHING. It names no target and no index. That clause compiles whether or not a unique constraint exists. When one exists, the duplicate is absorbed. When none exists, the same code inserts a second row and returns success. The endpoint's retry safety lives entirely in the schema, not in the code.

The except Exception makes it worse. It catches the genuine constraint violation just as quietly as it catches a dropped connection, so an absorbed duplicate and a rolled-back write produce the same response.

Why "it happens to work" is the problem

Nothing in that function announces an idempotency contract. The index looks like ordinary data hygiene, the kind someone adds during a cleanup ticket. So it gets treated as removable.

Two changes break it. Someone drops the index to cut write amplification on a hot table, and the bare ON CONFLICT DO NOTHING starts silently inserting duplicates. Or someone rewrites the branch to DO UPDATE so the response can carry the stored row, and a retry now overwrites an amount that already settled.

Neither change fails a test, because the test sends one request and asserts one row. That assertion passes with the index, without it, and with DO UPDATE. The suite was measuring the wrong thing the whole time.

Absorbed is not the same as correct

An absorbed duplicate means the database kept one row. It says nothing about what the client received. If the handler builds a fresh UUID per call, the retry gets a different resource id for the same charge, and any client that reconciles by id now holds two. If the response is just {"status": "accepted"}, the client learns nothing about which write won.

A correct replay returns the same answer twice: same status, same body, same id. That is the property clients actually depend on, and it is the property you should assert.

def test_retry_returns_same_body(client, db):
    body = {"idempotency_key": "k-1", "account_id": 7, "amount_cents": 500}
    first = client.post("/payments", json=body)
    second = client.post("/payments", json=body)
    assert first.status_code == second.status_code == 200
    assert first.json() == second.json()
    assert db.scalar(
        "SELECT count(*) FROM payments WHERE idempotency_key = 'k-1'"
    ) == 1
Enter fullscreen mode Exit fullscreen mode

Run that against the handler above and the row assertion passes while the body assertion fails if you generate the id per call. That failure is the point. It tells you the guarantee was only ever half there.

Finding the accidental ones

Grep the migrations for UNIQUE and CREATE UNIQUE INDEX. Grep the handlers for ON CONFLICT and for IntegrityError. Every place that catches a unique violation is declaring a retry contract it never wrote down.

Then ask one question per handler: does any test send the same request twice? If not, the guarantee is unverified and undefended. Write the two-request test first. If it passes, you have pinned the behaviour to the schema and to a test at the same time. If it fails, you just found a latent double-write before a customer did.

Name the constraint in a comment next to the conflict clause. Not for documentation's sake, but so the next person weighing write throughput against dropping that index can see what it is holding up. A unique index that is load-bearing should read like one.


I write about production failures in Postgres, queues, and distributed systems.

Subscribe by email · RSS · Bluesky

Top comments (2)

Collapse
 
axiru profile image
Axiru •

Putting retry safety in a unique index on the idempotency key, not in a bare ON CONFLICT that compiles whether the constraint exists, is the right payment-endpoint shape. An absorbed duplicate that returns a fresh response id is not the same as a correct replay of the stored settle.

When a flaky client retries /payments with the same idempotency key after the first settle already committed, do you return the stored result for that key and refuse to overwrite amount, currency, or destination on a DO UPDATE path?

Collapse
 
_66d02d0cc1ece7d1137c5f profile image
晖莫 •

Short answer: the handler in the post does not, and that is exactly the half the article leaves open — it returns a constant {"status": "accepted"}, so it cannot return a stored result even in principle. The version that holds up looks like this:

def create_payment(req, cur):
    # key + account + amount + currency + destination, canonicalized
    fingerprint = sha256(canonical(req))

    cur.execute(
        """INSERT INTO payments
               (idempotency_key, fingerprint, account_id, amount_cents, currency, destination)
           VALUES (%s, %s, %s, %s, %s, %s)
           ON CONFLICT (idempotency_key) DO NOTHING
           RETURNING *""",
        (...),
    )
    row = cur.fetchone()

    if row is None:
        row = cur.execute(
            "SELECT * FROM payments WHERE idempotency_key = %s", (req.key,)
        ).fetchone()

        if row is None:
            # A concurrent winner may not be visible yet — DO NOTHING does not
            # promise to wait for that transaction to commit — so this branch is
            # real and must not be answered with a fresh id.
            raise RetryLater()          # 409/503, never 200

        if row["fingerprint"] != fingerprint:
            raise Conflict()            # same key, different body: do not absorb

    return serialize(row)
Enter fullscreen mode Exit fullscreen mode

Three things matter more than the SQL:

Never DO UPDATE the money columns. SET amount_cents = EXCLUDED.amount_cents on a retry is precisely the overwrite you are asking about, and it is silent. The only reason to reach for DO UPDATE at all is that DO NOTHING ... RETURNING yields no row when it absorbs; DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key RETURNING * does return the existing row, but it takes a row lock and writes a new tuple version on every duplicate. Since the indexed column is being rewritten, HOT cannot save you, so a hot endpoint pays for bloat on exactly the traffic it is trying to absorb. The extra SELECT is cheaper.

The key has to be bound to the request, not just to the client. Same key with a different amount, currency or destination is not a retry, it is a client bug, and the fingerprint is the only thing that can tell those apart. Canonicalize before hashing or a reordered JSON body produces a different fingerprint for the same request.

Return the response, not a recomputation of it. If the serializer or the fee rules change between the original call and the replay, a recomputed body is not the same answer. Storing the status code and body next to the row is what makes "same answer twice" survive a deploy.

Your two-request test is what pins all three: same body twice, one row, same JSON — plus a third request with the same key and a different amount that must be rejected rather than absorbed.