Idempotência em APIs distribuídas

1. Fundamentos da Idempotência em Sistemas Distribuídos

1.1. Definição formal

Uma operação é idempotente quando executá-la uma ou múltiplas vezes produz o mesmo resultado final, sem efeitos colaterais adicionais. No contexto HTTP, os métodos possuem comportamentos distintos:

  • GET, HEAD, OPTIONS: naturalmente idempotentes (leitura pura)
  • PUT, DELETE: idempotentes por definição (substituição/exclusão total)
  • PATCH: não idempotente por padrão (depende da implementação)
  • POST: nunca idempotente (criação de recursos)

1.2. Por que a idempotência é crítica em APIs distribuídas

Em sistemas distribuídos, falhas de rede, timeouts e retentativas automáticas são inevitáveis. Sem idempotência, uma requisição duplicada pode:

  • Criar múltiplos registros de pagamento
  • Processar a mesma transação financeira duas vezes
  • Enviar notificações duplicadas

1.3. Desafios específicos em Go

Go oferece excelentes primitivas de concorrência, mas a ausência de garantias atômicas nativas no pacote net/http exige cuidado:

// Problema: race condition em cache de idempotência
var cache = make(map[string][]byte) // sem proteção concorrente

func handler(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if data, ok := cache[key]; ok { // leitura não segura
        w.Write(data)
        return
    }
    // processamento...
    cache[key] = result // escrita não segura
}

2. Estratégias de Chaveamento de Idempotência

2.1. Geração e validação de Idempotency-Key

A chave deve ser única e previsível apenas pelo cliente. UUID v7 (ordenado por timestamp) é recomendado:

import "github.com/google/uuid"

func generateIdempotencyKey() string {
    return uuid.Must(uuid.NewV7()).String()
}

2.2. Armazenamento de chaves

Três abordagens comuns:

Redis (recomendado para sistemas distribuídos):

func storeKey(redis *redis.Client, key string, response []byte, ttl time.Duration) error {
    return redis.Set(ctx, "idem:"+key, response, ttl).Err()
}

PostgreSQL (unique constraint):

CREATE TABLE idempotency_keys (
    key TEXT PRIMARY KEY,
    response BYTEA,
    created_at TIMESTAMP DEFAULT NOW()
);

sync.Map (apenas single-instance):

var idempotencyCache sync.Map

func getCachedResponse(key string) ([]byte, bool) {
    data, ok := idempotencyCache.Load(key)
    if !ok {
        return nil, false
    }
    return data.([]byte), true
}

2.3. Middleware HTTP para extrair e validar a chave

func IdempotencyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Method == http.MethodPost || r.Method == http.MethodPatch {
            key := r.Header.Get("Idempotency-Key")
            if key == "" {
                http.Error(w, "Idempotency-Key header required", http.StatusBadRequest)
                return
            }
            if !isValidUUID(key) {
                http.Error(w, "Invalid Idempotency-Key format", http.StatusBadRequest)
                return
            }
            ctx := context.WithValue(r.Context(), "idempotency_key", key)
            next.ServeHTTP(w, r.WithContext(ctx))
        } else {
            next.ServeHTTP(w, r)
        }
    })
}

3. Implementação de Middleware de Idempotência em Go

3.1. Estrutura básica

type IdempotencyMiddleware struct {
    store    *redis.Client
    ttl      time.Duration
    mu       sync.Mutex
    inflight sync.Map
}

func NewIdempotencyMiddleware(store *redis.Client, ttl time.Duration) *IdempotencyMiddleware {
    return &IdempotencyMiddleware{
        store: store,
        ttl:   ttl,
    }
}

3.2. Lock otimista vs. lock pessimista

Lock pessimista (garantia forte, menor throughput):

func (m *IdempotencyMiddleware) acquireLock(key string) bool {
    m.mu.Lock()
    defer m.mu.Unlock()
    _, loaded := m.inflight.LoadOrStore(key, struct{}{})
    return !loaded
}

Lock otimista (maior throughput, possível duplicação):

func (m *IdempotencyMiddleware) checkAndStore(key string, response []byte) (bool, error) {
    // Usando SET NX do Redis (apenas se chave não existir)
    ok, err := m.store.SetNX(ctx, "idem:"+key, response, m.ttl).Result()
    return ok, err
}

3.3. Cache de respostas

func (m *IdempotencyMiddleware) Handle(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        key := r.Context().Value("idempotency_key").(string)

        // Verificar cache
        if cached, err := m.store.Get(ctx, "idem:"+key).Bytes(); err == nil {
            w.Header().Set("X-Idempotent-Replay", "true")
            w.Write(cached)
            return
        }

        // Lock para evitar execução concorrente
        if !m.acquireLock(key) {
            http.Error(w, "Request already in progress", http.StatusConflict)
            return
        }
        defer m.inflight.Delete(key)

        // Capturar resposta
        rec := httptest.NewRecorder()
        next.ServeHTTP(rec, r)

        // Armazenar em cache
        m.store.Set(ctx, "idem:"+key, rec.Body.Bytes(), m.ttl)

        // Copiar resposta original
        for k, v := range rec.Header() {
            w.Header()[k] = v
        }
        w.WriteHeader(rec.Code)
        w.Write(rec.Body.Bytes())
    })
}

4. Garantias de Consistência com Singleflight e Transações

4.1. Uso de singleflight.Group

var sf singleflight.Group

func (m *IdempotencyMiddleware) HandleWithSingleflight(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        key := r.Context().Value("idempotency_key").(string)

        // Deduplicação de chamadas concorrentes
        result, err, shared := sf.Do(key, func() (interface{}, error) {
            rec := httptest.NewRecorder()
            next.ServeHTTP(rec, r)
            return rec, nil
        })

        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        rec := result.(*httptest.ResponseRecorder)
        if shared {
            w.Header().Set("X-Idempotent-Replay", "true")
        }

        for k, v := range rec.Header() {
            w.Header()[k] = v
        }
        w.WriteHeader(rec.Code)
        w.Write(rec.Body.Bytes())
    })
}

4.2. Integração com transações de banco de dados

func createOrderHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        key := r.Context().Value("idempotency_key").(string)

        tx, err := db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelSerializable})
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        defer tx.Rollback()

        // Verificar se já processamos esta chave
        var exists bool
        err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM orders WHERE idempotency_key = $1)", key).Scan(&exists)
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        if exists {
            // Retornar resposta anterior
            var response []byte
            tx.QueryRow("SELECT response FROM orders WHERE idempotency_key = $1", key).Scan(&response)
            w.Write(response)
            return
        }

        // Processar pedido
        result := processOrder(r)

        // Armazenar com chave de idempotência
        _, err = tx.Exec(
            "INSERT INTO orders (id, idempotency_key, response) VALUES ($1, $2, $3)",
            generateID(), key, result,
        )
        if err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        tx.Commit()
        w.Write(result)
    }
}

4.3. Edge cases: timeouts parciais

func handlePartialFailure(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    // Se o cliente recebeu timeout mas o servidor processou
    // a chave de idempotência protege contra duplicação
    result, err := processWithTimeout(ctx)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            // Não retornar erro - cliente deve reenviar com mesma chave
            w.WriteHeader(http.StatusAccepted)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Write(result)
}

5. Idempotência em Operações com Efeitos Colaterais

5.1. Operações de criação (POST)

type PaymentRequest struct {
    Amount    float64 `json:"amount"`
    Currency  string  `json:"currency"`
}

func processPayment(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        key := r.Context().Value("idempotency_key").(string)

        tx, _ := db.BeginTx(r.Context(), nil)
        defer tx.Rollback()

        // Unique constraint na chave de idempotência
        _, err := tx.Exec(
            "INSERT INTO payments (id, idempotency_key, status) VALUES ($1, $2, 'pending')",
            generateID(), key,
        )
        if err != nil {
            if strings.Contains(err.Error(), "unique") {
                w.WriteHeader(http.StatusConflict)
                json.NewEncoder(w).Encode(map[string]string{
                    "error": "Payment already processed",
                })
                return
            }
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }

        // Processar pagamento...
        tx.Commit()
        w.WriteHeader(http.StatusCreated)
    }
}

5.2. Operações de atualização (PUT/PATCH)

func updateResourceWithETag(w http.ResponseWriter, r *http.Request) {
    resourceID := r.URL.Path
    etag := r.Header.Get("If-Match")

    // Verificar versão atual
    currentETag := getResourceETag(resourceID)
    if etag != "" && etag != currentETag {
        http.Error(w, "Resource modified", http.StatusPreconditionFailed)
        return
    }

    // Atualizar recurso (PUT é naturalmente idempotente)
    // ...

    w.Header().Set("ETag", generateNewETag())
    w.WriteHeader(http.StatusOK)
}

5.3. Operações de exclusão (DELETE)

func deleteResource(w http.ResponseWriter, r *http.Request) {
    resourceID := r.URL.Path

    // DELETE é idempotente: excluir recurso já excluído retorna 204
    err := deleteFromDatabase(resourceID)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            w.WriteHeader(http.StatusNoContent) // Já excluído
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    // Notificação assíncrona - usar chave de idempotência
    go notifyDeletion(resourceID, r.Context().Value("idempotency_key").(string))

    w.WriteHeader(http.StatusNoContent)
}

6. Testes e Verificação de Robustez

6.1. Testes unitários com httptest

func TestIdempotencyMiddleware(t *testing.T) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusCreated)
        w.Write([]byte(`{"id": "123"}`))
    })

    middleware := NewIdempotencyMiddleware(redisClient, 24*time.Hour)
    wrapped := middleware.Handle(handler)

    server := httptest.NewServer(wrapped)
    defer server.Close()

    client := &http.Client{}
    key := "test-key-123"

    // Primeira requisição
    req1, _ := http.NewRequest("POST", server.URL, nil)
    req1.Header.Set("Idempotency-Key", key)
    resp1, _ := client.Do(req1)

    // Segunda requisição com mesma chave
    req2, _ := http.NewRequest("POST", server.URL, nil)
    req2.Header.Set("Idempotency-Key", key)
    resp2, _ := client.Do(req2)

    assert.Equal(t, http.StatusCreated, resp1.StatusCode)
    assert.Equal(t, http.StatusCreated, resp2.StatusCode)
    assert.Equal(t, "true", resp2.Header.Get("X-Idempotent-Replay"))
}

6.2. Testes de concorrência

func TestConcurrentIdempotency(t *testing.T) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(100 * time.Millisecond) // Simular processamento
        w.WriteHeader(http.StatusOK)
    })

    middleware := NewIdempotencyMiddleware(redisClient, 24*time.Hour)
    wrapped := middleware.Handle(handler)

    server := httptest.NewServer(wrapped)
    defer server.Close()

    var wg sync.WaitGroup
    results := make(chan int, 10)

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            req, _ := http.NewRequest("POST", server.URL, nil)
            req.Header.Set("Idempotency-Key", "concurrent-key")
            resp, _ := http.DefaultClient.Do(req)
            results <- resp.StatusCode
        }()
    }

    wg.Wait()
    close(results)

    // Apenas uma requisição deve ser processada (ou 201 ou 409)
    statusCodes := make(map[int]int)
    for code := range results {
        statusCodes[code]++
    }

    assert.Equal(t, 1, statusCodes[200]) // Uma requisição bem-sucedida
    // As outras podem ser 409 (conflito) ou 200 (cache hit)
}

6.3. Simulação de falhas de rede

func TestTimeoutAndRetry(t *testing.T) {
    handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second) // Simular lentidão
        w.WriteHeader(http.StatusOK)
    })

    middleware := NewIdempotencyMiddleware(redisClient, 24*time.Hour)
    wrapped := middleware.Handle(handler)

    server := httptest.NewServer(wrapped)
    defer server.Close()

    // Cliente com timeout curto
    client := &http.Client{
        Timeout: 1 * time.Second,
    }

    key := "timeout-test-key"

    // Primeira requisição - deve dar timeout
    req1, _ := http.NewRequest("POST", server.URL, nil)
    req1.Header.Set("Idempotency-Key", key)
    _, err := client.Do(req1)
    assert.Error(t, err) // Timeout

    // Retentativa com mesma chave - servidor pode ter processado
    req2, _ := http.NewRequest("POST", server.URL, nil)
    req2.Header.Set("Idempotency-Key", key)
    resp, err := http.DefaultClient.Do(req2)
    assert.NoError(t, err)

    // Se o servidor processou a primeira, retorna cache
    // Se não, processa agora - em ambos os casos, sem duplicação
    assert.Equal(t, http.StatusOK, resp.StatusCode)
}

7. Considerações de Performance e Boas Práticas

7.1. TTL para chaves de idempotência

const (
    DefaultTTL = 24 * time.Hour
    MaxTTL     = 7 * 24 * time.Hour
)

func calculateTTL(operation string) time.Duration {
    switch operation {
    case "payment":
        return 7 * 24 * time.Hour // Pagamentos: janela maior
    case "email":
        return 1 * time.Hour // Emails: janela curta
    default:
        return DefaultTT

L

```go
    default:
        return DefaultTTL
    }
}

7.2. Monitoramento e Métricas

type IdempotencyMetrics struct {
    mu                sync.RWMutex
    totalRequests     int64
    cacheHits         int64
    duplicateKeys     int64
    middlewareLatency time.Duration
}

func (m *IdempotencyMetrics) RecordRequest(isCacheHit bool) {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.totalRequests++
    if isCacheHit {
        m.cacheHits++
    }
}

func (m *IdempotencyMetrics) RecordDuplicate() {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.duplicateKeys++
}

// Middleware com métricas integradas
func (im *IdempotencyMiddleware) HandleWithMetrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        key := r.Header.Get("Idempotency-Key")

        if key == "" {
            next.ServeHTTP(w, r)
            return
        }

        // Verificar cache
        if cached, ok := im.cache.Get(key); ok {
            im.metrics.RecordRequest(true)
            w.Header().Set("X-Idempotent-Replay", "true")
            w.WriteHeader(cached.StatusCode)
            w.Write(cached.Body)
            return
        }

        // Processar requisição
        im.metrics.RecordRequest(false)
        im.middlewareLatency.Observe(time.Since(start).Seconds())

        next.ServeHTTP(w, r)
    })
}

7.3. Combinando com Retry Policies e Circuit Breakers

type IdempotentClient struct {
    client          *http.Client
    retryPolicy     RetryPolicy
    circuitBreaker  *CircuitBreaker
    idempotencyKey string
}

func (c *IdempotentClient) Do(req *http.Request) (*http.Response, error) {
    // Gerar chave de idempotência se não existir
    if req.Header.Get("Idempotency-Key") == "" {
        req.Header.Set("Idempotency-Key", uuid.New().String())
    }

    var resp *http.Response
    var err error

    for attempt := 0; attempt <= c.retryPolicy.MaxRetries; attempt++ {
        // Verificar circuit breaker
        if !c.circuitBreaker.IsAvailable() {
            return nil, ErrCircuitOpen
        }

        resp, err = c.client.Do(req)
        if err != nil {
            c.circuitBreaker.RecordFailure()

            // Se houve timeout, retentar com mesma chave
            if isTimeoutError(err) {
                time.Sleep(c.retryPolicy.Backoff(attempt))
                continue
            }
            break
        }

        // Sucesso ou erro não recuperável
        c.circuitBreaker.RecordSuccess()
        break
    }

    return resp, err
}

Conclusão

A idempotência é um pilar fundamental para a construção de APIs distribuídas confiáveis em Go. Implementada corretamente, ela protege contra os efeitos colaterais de retentativas, duplicações de mensagens e falhas de rede que são inevitáveis em sistemas distribuídos.

Vimos que:

  1. Chaves de idempotência (UUIDs, tokens criptográficos) fornecem a base para identificar requisições únicas
  2. Armazenamento em Redis ou PostgreSQL garante a persistência e atomicidade necessárias
  3. Middlewares HTTP em Go encapsulam a lógica de forma reutilizável
  4. Singleflight e transações previnem execuções concorrentes problemáticas
  5. Testes rigorosos com httptest e simulação de falhas validam a robustez
  6. Métricas e monitoramento permitem ajustar TTLs e detectar problemas

A combinação de idempotência com retry policies e circuit breakers forma uma base sólida para sistemas resilientes. Em Go, a concorrência e as primitivas de sincronização (sync.Mutex, atomic.Value, singleflight.Group) oferecem ferramentas poderosas para implementar essas garantias de forma eficiente.

Lembre-se: a idempotência não é apenas sobre evitar duplicatas, mas sobre fornecer garantias previsíveis para consumidores da API, mesmo em cenários de falha. Invista no design cuidadoso das chaves, no armazenamento adequado e nos testes de concorrência para construir APIs verdadeiramente confiáveis.