Go

How do you design an idempotent HTTP API handler in Go (idempotency keys, storage, concurrency)?

Question 562HardGo 1.22 to 1.25

The client sends an Idempotency-Key header (a UUID) with non-idempotent requests such as POST. The server atomically reserves the key, runs the operation once, stores the response, and replays it on retries. Three issues need handling: concurrent duplicates, retries that reuse a key with a different payload, and crashes partway through.

type Record struct {
	Hash   [32]byte
	Done   bool
	Status int
	Body   []byte
}

type IdemStore interface {
	// Reserve inserts an in-progress record atomically (e.g. INSERT ... ON CONFLICT DO NOTHING).
	// If the key exists, it returns the existing record and created=false.
	Reserve(ctx context.Context, key string, hash [32]byte, ttl time.Duration) (rec Record, created bool, err error)
	Complete(ctx context.Context, key string, status int, body []byte) error
	Release(ctx context.Context, key string) error
}

func Idempotent(store IdemStore, next func(*http.Request) (int, []byte, error)) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		if key == "" {
			http.Error(w, "missing Idempotency-Key", http.StatusBadRequest)
			return
		}
		body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
		if err != nil {
			http.Error(w, "bad body", http.StatusBadRequest)
			return
		}
		r.Body = io.NopCloser(bytes.NewReader(body))
		hash := sha256.Sum256(append([]byte(r.Method+" "+r.URL.Path+"\n"), body...))

		rec, created, err := store.Reserve(r.Context(), key, hash, 24*time.Hour)
		switch {
		case err != nil:
			http.Error(w, "store unavailable", http.StatusServiceUnavailable)
			return
		case !created && rec.Hash != hash:
			http.Error(w, "key reused with different payload", http.StatusUnprocessableEntity)
			return
		case !created && !rec.Done:
			http.Error(w, "request in progress", http.StatusConflict) // client retries later
			return
		case !created:
			w.WriteHeader(rec.Status) // replay the stored response
			w.Write(rec.Body)
			return
		}

		status, out, err := next(r)
		if err != nil { // failed before side effects: allow retry
			_ = store.Release(context.WithoutCancel(r.Context()), key)
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		_ = store.Complete(context.WithoutCancel(r.Context()), key, status, out)
		w.WriteHeader(status)
		w.Write(out)
	}
}

What the interviewer is looking for: atomicity comes from the store (a unique constraint or Redis SET NX), not from an in-process mutex, which fails with more than one replica. Scope keys per tenant or user. Give in-progress records a TTL or lease so a crash doesn't block the key forever. Ideally write the business change and the idempotency record in the same DB transaction. Otherwise a crash between the two steps breaks exactly-once. Use context.WithoutCancel (1.21) so a client disconnect doesn't skip bookkeeping.

More on Go Idioms, Design Patterns & Language Design

All 16 Go Idioms, Design Patterns & Language Design questions