DEV Community

Cover image for Idempotency Keys in Go HTTP Handlers: Stop Double-Processing Requests
Gabriel Anhaia
Gabriel Anhaia

Posted on

Idempotency Keys in Go HTTP Handlers: Stop Double-Processing Requests


A customer taps "Pay" once. The button spins. Nothing happens for
two seconds, so they tap it again. Your Go service now has two
POST /charges requests in flight for the same cart. Both pass
validation. Both call the payment provider. The customer gets
charged twice and opens a support ticket.

This is not a rare edge case. Mobile networks retry. Load
balancers retry on timeout. Client SDKs retry on 5xx. A gateway
that returns 504 while your handler is still running will happily
send the same request again, and your handler has no idea the
first one is still working.

Stripe's API solved this with a header the client controls: the
Idempotency-Key.
The client generates a unique key per logical operation and sends
it on every retry of that operation. The server promises that a
given key runs at most once, and every retry gets the same
response the first attempt produced. This post builds that in Go,
from the store to the handler, and shows where the check belongs
relative to your use case.

The contract

An idempotent endpoint makes two promises:

  1. The side effect (the charge, the order, the email) happens once per key, no matter how many times the request arrives.
  2. Every request carrying that key gets the same response body and status the first one produced, even the retries that arrive while the first is still running.

The mechanism is a key store with three states per key:
in-flight, completed, and absent. The first request to claim
a key wins and does the work. Everyone else either waits or
replays the stored result. First-write-wins is the whole game.

The store interface

Start with a port, not a Redis client. The handler should not
know where keys live.

package idempotency

import (
    "context"
    "time"
)

// Record is the stored outcome for one key.
type Record struct {
    Status   int
    Body      []byte
    Completed bool
}

type Store interface {
    // Claim tries to reserve key. If it already exists,
    // ok is false and the existing Record comes back.
    Claim(
        ctx context.Context, key string, ttl time.Duration,
    ) (rec Record, ok bool, err error)

    // Save writes the final response for a claimed key.
    Save(
        ctx context.Context, key string, rec Record,
    ) error
}
Enter fullscreen mode Exit fullscreen mode

Claim is the atomic first-write-wins primitive. If it returns
ok == true, this caller reserved the key and owns the work. If
ok == false, someone got there first, and rec tells you
whether they finished.

A Postgres store with first-write-wins

Postgres gives you atomic claim through INSERT ... ON CONFLICT
DO NOTHING
. The row either inserts (you won) or does not (someone
else holds it).

CREATE TABLE idempotency_keys (
    key          text PRIMARY KEY,
    status       int NOT NULL DEFAULT 0,
    body         bytea NOT NULL DEFAULT '',
    completed    boolean NOT NULL DEFAULT false,
    created_at   timestamptz NOT NULL DEFAULT now(),
    expires_at   timestamptz NOT NULL
);
Enter fullscreen mode Exit fullscreen mode
func (s *PGStore) Claim(
    ctx context.Context, key string, ttl time.Duration,
) (Record, bool, error) {
    exp := time.Now().Add(ttl)
    tag, err := s.db.Exec(ctx, `
        INSERT INTO idempotency_keys (key, expires_at)
        VALUES ($1, $2)
        ON CONFLICT (key) DO NOTHING
    `, key, exp)
    if err != nil {
        return Record{}, false, err
    }
    if tag.RowsAffected() == 1 {
        return Record{}, true, nil // we claimed it
    }

    // Someone else holds it. Read their state.
    var rec Record
    err = s.db.QueryRow(ctx, `
        SELECT status, body, completed
        FROM idempotency_keys
        WHERE key = $1
    `, key).Scan(&rec.Status, &rec.Body, &rec.Completed)
    if err != nil {
        return Record{}, false, err
    }
    return rec, false, nil
}
Enter fullscreen mode Exit fullscreen mode

RowsAffected() == 1 is the win condition. It is atomic at the
database level, so two goroutines racing the same key can never
both see a win. One inserts, the other conflicts. No application
lock, no SELECT then INSERT gap for a race to slip through.

Save writes the outcome back once the work is done:

func (s *PGStore) Save(
    ctx context.Context, key string, rec Record,
) error {
    _, err := s.db.Exec(ctx, `
        UPDATE idempotency_keys
        SET status = $2, body = $3, completed = true
        WHERE key = $1
    `, key, rec.Status, rec.Body)
    return err
}
Enter fullscreen mode Exit fullscreen mode

The middleware

Wrap the handler once and every route it covers becomes
idempotent. The middleware reads the header, claims the key, and
either runs the inner handler or replays the stored response.

func Middleware(store Store, ttl time.Duration) func(
    http.Handler,
) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(
            w http.ResponseWriter, r *http.Request,
        ) {
            key := r.Header.Get("Idempotency-Key")
            if key == "" {
                next.ServeHTTP(w, r)
                return
            }

            rec, won, err := store.Claim(
                r.Context(), key, ttl,
            )
            if err != nil {
                http.Error(w, "store error", 500)
                return
            }

            if !won {
                replay(w, rec)
                return
            }

            // We own the key. Capture the response.
            rw := &capture{ResponseWriter: w, status: 200}
            next.ServeHTTP(rw, r)

            // Only persist terminal responses. A 5xx is
            // usually transient, so storing it would replay
            // the failure to every future retry with this key.
            if rw.status < 500 {
                _ = store.Save(r.Context(), key, Record{
                    Status: rw.status,
                    Body:   rw.buf.Bytes(),
                })
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

replay handles the two not-won cases: the first request
finished, or it is still running.

func replay(w http.ResponseWriter, rec Record) {
    if !rec.Completed {
        // First request still in flight.
        w.Header().Set("Retry-After", "1")
        http.Error(w, "request in progress", 409)
        return
    }
    w.WriteHeader(rec.Status)
    _, _ = w.Write(rec.Body)
}
Enter fullscreen mode Exit fullscreen mode

A retry that lands while the original is still working gets a
409 with Retry-After, not a duplicate charge. A retry that
lands after the original finished gets the exact stored response.
Both promises hold.

One caveat on the capture path: only persist terminal responses.
A 5xx is usually transient, and storing it would replay that
failure to every future retry carrying the key. Gating Save on
rw.status < 500 keeps a transient error out of the store. A
production version also releases the claim on failure (deletes
the in-flight row) so the next retry can re-run the work instead
of parking on 409 until the TTL expires.

The capture type is a small ResponseWriter wrapper that
records what the handler wrote so the middleware can store it:

type capture struct {
    http.ResponseWriter
    status int
    buf    bytes.Buffer
}

func (c *capture) WriteHeader(code int) {
    c.status = code
    c.ResponseWriter.WriteHeader(code)
}

func (c *capture) Write(b []byte) (int, error) {
    c.buf.Write(b)
    return c.ResponseWriter.Write(b)
}
Enter fullscreen mode Exit fullscreen mode

Where the check sits relative to the use case

This is the part most tutorials skip, and it is the part that
decides whether the pattern actually protects you.

The idempotency check has to sit outside the use case, in the
transport layer, before the handler touches the domain. Think of
it as the outermost ring in a hexagonal layout: HTTP concerns
(the header, the key store, the replay) live in an adapter. The
use case underneath (ChargeCart, PlaceOrder) knows nothing
about keys. It runs, or it does not run at all.

mux := http.NewServeMux()
mux.Handle("POST /charges", chargeHandler)

// Idempotency wraps the transport, not the domain.
handler := idempotency.Middleware(store, 24*time.Hour)(mux)
http.ListenAndServe(":8080", handler)
Enter fullscreen mode Exit fullscreen mode

Put the check inside the use case and you leak an HTTP concern
into your domain. ChargeCart should not take an
idempotencyKey string. It should not know that HTTP retries
exist. Keep the concern in the adapter and the use case stays
testable without a store.

There is a deeper reason the boundary matters: the atomic claim
and the side effect must not drift apart. If the claim lives in
the middleware but the charge commits in a database transaction
the middleware cannot see, a crash between Save and the
handler's own commit leaves you with a completed key and no
charge. For side effects that must be exactly-once with their
key, fold the claim into the same transaction as the work — claim
and side effect commit together, or neither does. The middleware
handles the general case; the money path deserves the tighter
coupling.

What a key must not do

An idempotency key is a promise about one logical operation, so
guard it:

  • Bind the key to the request. Store a hash of the request body alongside the key. If the same key arrives with a different body, reject with 422 — the client reused a key for a different operation, which is a bug you want surfaced, not silently served the wrong stored response.
  • Give it a TTL. Keys are not permanent. A day is a common window. expires_at plus a sweep job keeps the table from growing without bound.
  • Only key mutations. GET is already idempotent by HTTP's own definition. Wrap POST, PATCH, and DELETE where a double-run causes damage.

The full request path

Put together, one key flows like this. First POST with key
abc: Claim inserts, wins, the handler charges the card,
Save stores 200 and the body. Second POST with abc two
seconds later while the first still runs: Claim conflicts,
completed is false, the client gets 409 Retry-After: 1. Third
POST with abc after the first finished: Claim conflicts,
completed is true, the client gets the exact 200 from attempt
one. One charge, three requests, no double processing.

The customer can tap the button as many times as their patience
allows. Your Go handler charges them once.


If this was useful

The store, the middleware, and the use case underneath are a
clean hexagonal split: an adapter owns the HTTP-level idempotency
concern, the domain stays ignorant of it, and the two meet at a
port. Hexagonal Architecture in Go walks that boundary in
depth — where transport concerns stop and the use case begins,
and how to keep the two from bleeding into each other. The
Complete Guide to Go Programming
covers the runtime pieces this
leans on: ResponseWriter wrapping, context propagation, and
the database/sql transaction semantics the money path needs.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)