How do you design an idempotent HTTP API handler in Go (idempotency keys, storage, concurrency)?
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
- Q560What are the uses of the blank identifier (_)? Explain import _, var _ = , _ = x and blank struct fields.
- Q561What are anonymous structs and struct tags? When would you use an anonymous struct in tests or JSON handling?
- Q563Compare Go with Java, Rust, and Python for backend services: concurrency model, memory management, error handling and deployment. When would you not choose Go?