Build APIs That Survive Retries: Idempotency Keys in Go

Network requests fail. Timeouts happen. Retries are inevitable. If your API charges a customer’s credit card twice because a TCP connection dropped at the wrong moment, that’s not a network problem — it’s a design problem. Idempotency is the property that makes your API safe to retry, and building it correctly is one of the most impactful things you can do for reliability.

This post walks through what idempotency actually means in practice, how HTTP methods relate to it, and how to implement the idempotency key pattern in Go — the same approach used by Stripe, AWS, and most payment processors.

What Idempotency Really Means

An operation is idempotent when executing it once has the same effect as executing it multiple times. In HTTP terms, GET, PUT, and DELETE are idempotent by specification — requesting the same resource twice doesn’t change server state beyond the first request. POST and PATCH are not. Send a POST /orders request twice, and you’ll likely create two orders.

The problem is that real-world callers can’t always tell whether their request succeeded. The server processes it, sends a response, but the connection drops before the client receives it. From the client’s perspective, it’s a timeout. From the server’s perspective, the operation completed. The safe thing to do is retry — but only if the endpoint is idempotent.

Three Levels of Idempotency

1. Natural Idempotency (HTTP Spec)

PUT replaces a resource entirely. Calling it twice with the same payload produces the same state. DELETE removes a resource — deleting something that’s already deleted is a no-op (or returns 404, which is still safe). GET reads data and doesn’t change state at all. These methods are naturally idempotent.

2. Designed Idempotency (Client-Supplied Keys)

For non-idempotent methods like POST, you need the Idempotency Key pattern. The client generates a unique key (typically a UUID v4) and sends it in a header with every request. The server stores the mapping between the key and the response. If it sees the same key again within a time window, it returns the stored response without re-executing the operation.

3. Conditional Idempotency (Dedup Logic)

Sometimes you can make endpoints idempotent through business logic. An order creation endpoint that uses the client’s order_reference as a unique constraint will reject duplicates at the database level. This is simpler than a full idempotency key system but less flexible — it ties your dedup strategy to your domain model.

Implementing the Idempotency Key Pattern in Go

Let’s build a working middleware that handles idempotency keys. The approach: intercept incoming requests, check if the key has been seen before, and either return the cached response or process the request and store the result.

The Response Cache

First, define a store interface. In production you’d use Redis or a database — here’s an in-memory version for clarity:

package idempotency

import (
    "bytes"
    "encoding/gob"
    "net/http"
    "sync"
    "time"
)

// CachedResponse stores the result of a previously processed request.
type CachedResponse struct {
    StatusCode int
    Headers    http.Header
    Body       []byte
    CreatedAt  time.Time
}

// Store abstracts the persistence layer for idempotency records.
type Store interface {
    Get(key string) (*CachedResponse, bool)
    Set(key string, resp *CachedResponse, ttl time.Duration)
}

// InMemoryStore is suitable for single-instance development.
type InMemoryStore struct {
    mu   sync.RWMutex
    data map[string]*cachedEntry
}

type cachedEntry struct {
    response  *CachedResponse
    expiresAt time.Time
}

func NewInMemoryStore() *InMemoryStore {
    return &InMemoryStore{data: make(map[string]*cachedEntry)}
}

func (s *InMemoryStore) Get(key string) (*CachedResponse, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    entry, ok := s.data[key]
    if !ok || time.Now().After(entry.expiresAt) {
        return nil, false
    }
    return entry.response, true
}

func (s *InMemoryStore) Set(key string, resp *CachedResponse, ttl time.Duration) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.data[key] = &cachedEntry{
        response:  resp,
        expiresAt: time.Now().Add(ttl),
    }
}

The Middleware

The middleware intercepts the response writer, captures the result, and caches it under the idempotency key. If the key is seen again, it replays the cached response:

const IdempotencyKeyHeader = "Idempotency-Key"
const DefaultTTL = 24 * time.Hour

// Middleware returns an HTTP middleware that handles idempotency keys.
// It only activates for requests that include the Idempotency-Key header.
func Middleware(store Store, ttl time.Duration) func(http.Handler) http.Handler {
    if ttl == 0 {
        ttl = DefaultTTL
    }

    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            key := r.Header.Get(IdempotencyKeyHeader)
            if key == "" {
                // No key — pass through without idempotency protection.
                next.ServeHTTP(w, r)
                return
            }

            // Check for a cached response.
            if cached, ok := store.Get(key); ok {
                for k, vals := range cached.Headers {
                    for _, v := range vals {
                        w.Header().Add(k, v)
                    }
                }
                w.WriteHeader(cached.StatusCode)
                w.Write(cached.Body)
                return
            }

            // Capture the response using a recording writer.
            rec := &responseRecorder{
                ResponseWriter: w,
                statusCode:     http.StatusOK,
                buf:            &bytes.Buffer{},
            }

            next.ServeHTTP(rec, r)

            // Store the result, including error responses.
            store.Set(key, &CachedResponse{
                StatusCode: rec.statusCode,
                Headers:    rec.Header().Clone(),
                Body:       rec.buf.Bytes(),
                CreatedAt:  time.Now(),
            }, ttl)
        })
    }
}

// responseRecorder wraps http.ResponseWriter to capture the response.
type responseRecorder struct {
    http.ResponseWriter
    statusCode int
    buf        *bytes.Buffer
}

func (r *responseRecorder) WriteHeader(code int) {
    r.statusCode = code
    r.ResponseWriter.WriteHeader(code)
}

func (r *responseRecorder) Write(b []byte) (int, error) {
    r.buf.Write(b) // capture for caching
    return r.ResponseWriter.Write(b)
}

Redis-Backed Store for Production

The in-memory store doesn’t survive restarts or work across multiple instances. Here’s a Redis-backed implementation using go-redis:

import (
    "context"
    "encoding/json"
    "time"

    "github.com/redis/go-redis/v9"
)

type RedisStore struct {
    client *redis.Client
    prefix string
}

func NewRedisStore(client *redis.Client, prefix string) *RedisStore {
    return &RedisStore{client: client, prefix: prefix}
}

func (s *RedisStore) Get(key string) (*CachedResponse, bool) {
    data, err := s.client.Get(context.Background(), s.prefix+key).Bytes()
    if err != nil {
        return nil, false
    }
    var resp CachedResponse
    if err := json.Unmarshal(data, &resp); err != nil {
        return nil, false
    }
    return &resp, true
}

func (s *RedisStore) Set(key string, resp *CachedResponse, ttl time.Duration) {
    data, err := json.Marshal(resp)
    if err != nil {
        return
    }
    s.client.Set(context.Background(), s.prefix+key, data, ttl)
}

Wiring It Together

func main() {
    rdb := redis.NewClient(&redis.Options{
        Addr: "localhost:6379",
    })

    store := NewRedisStore(rdb, "idempotency:")
    mux := http.NewServeMux()

    // POST /payments — needs idempotency protection.
    mux.HandleFunc("POST /payments", func(w http.ResponseWriter, r *http.Request) {
        // Your payment logic here. This handler runs ONCE per
        // idempotency key, even if the client retries.
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusCreated)
        fmt.Fprint(w, `{"status":"created","transaction_id":"tx_123"}`)
    })

    // Wrap with idempotency middleware.
    handler := Middleware(store, 24*time.Hour)(mux)
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Common Pitfalls

Caching Error Responses

Stripe’s implementation caches everything — including 500 errors. This means a transient server failure gets cached and returned for 24 hours. For most APIs, you should only cache successful responses (2xx status codes) and let errors retry naturally. The middleware above caches all responses to match Stripe’s behavior; adjust the store.Set call with a status check if you want different semantics.

Key Expiry and Replay Windows

Set a TTL that matches your retry window. 24 hours is the Stripe standard. If your clients might retry after longer periods (e.g., offline mobile apps), consider a longer window — but balance it against storage cost and stale data risk.

In-Flight Request Deduplication

The basic pattern above has a race condition: if two requests with the same key arrive simultaneously, both might pass the Get check before either calls Set. The fix is to use an atomic set-if-not-exists operation. In Redis, this is SET key value NX:

func (s *RedisStore) TryAcquire(key string) (bool, *CachedResponse) {
    fullKey := s.prefix + key
    // Try to set a placeholder to claim this key.
    ok, err := s.client.SetNX(context.Background(),
        fullKey, "processing", 30*time.Second).Result()
    if err != nil {
        return false, nil
    }
    if !ok {
        // Key exists — check if it's a completed response or still processing.
        data, err := s.client.Get(context.Background(), fullKey).Bytes()
        if err != nil {
            return false, nil // still processing
        }
        var resp CachedResponse
        if json.Unmarshal(data, &resp) == nil {
            return false, &resp // return cached result
        }
        return false, nil
    }
    return true, nil // we acquired the lock
}

This gives you a two-phase approach: first claim the key atomically, then process the request, then update with the real response. Any concurrent request with the same key sees “processing” and can either wait or return a 409 Conflict with a Retry-After header.

Parameter Mismatch Detection

If a client reuses an idempotency key with a different request body, something is wrong. Stripe returns an idempotency_error in this case. You should hash the request body and compare it when a cached key is found:

import "crypto/sha256"

func bodyHash(r *http.Request) string {
    body, _ := io.ReadAll(r.Body)
    r.Body = io.NopCloser(bytes.NewReader(body)) // restore for handler
    h := sha256.Sum256(body)
    return hex.EncodeToString(h[:])
}

// In the middleware, after checking for cached response:
cached, ok := store.Get(key)
if ok {
    if cached.BodyHash != "" && cached.BodyHash != bodyHash(r) {
        w.WriteHeader(http.StatusConflict)
        fmt.Fprint(w, "idempotency key reused with different parameters")
        return
    }
    // replay cached response...
}

When You Actually Need This

Not every endpoint needs idempotency keys. Here’s a practical guide:

  • Payment endpoints: Always. Double-charging is the canonical example.
  • Order creation: Yes, if clients might retry on timeout.
  • Email/notification dispatch: Yes, to prevent duplicate sends.
  • Resource updates (PUT): Already idempotent by HTTP semantics.
  • Search/filter (GET): Already safe and idempotent.
  • Analytics/event tracking: Often handled via dedup by event ID at the storage layer instead.

Wrapping Up

Idempotency transforms your API from “hopefully the network cooperates” to “safe to retry under any conditions.” The idempotency key pattern is straightforward to implement — a middleware layer, a key-value store with TTL, and atomic lock acquisition for concurrent request handling. The key decisions are what to cache (all responses vs. successes only), how long to keep keys, and how to handle parameter mismatches.

Start with the middleware approach above, backed by Redis for multi-instance deployments. It handles the vast majority of real-world retry scenarios with minimal code. The Stripe API docs on idempotent requests remain one of the best references for production-grade implementation details, and the HTTP Semantics RFC (§9.2.2) defines the formal idempotency contract your API should follow.

Leave a Reply

Your email address will not be published. Required fields are marked *