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:
- Chaves de idempotência (UUIDs, tokens criptográficos) fornecem a base para identificar requisições únicas
- Armazenamento em Redis ou PostgreSQL garante a persistência e atomicidade necessárias
- Middlewares HTTP em Go encapsulam a lógica de forma reutilizável
- Singleflight e transações previnem execuções concorrentes problemáticas
- Testes rigorosos com
httpteste simulação de falhas validam a robustez - 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.