Voltar para o blog
APIsBackendNestJSConfiabilidade

Idempotência em APIs REST: o que é, por que importa e como implementar

16 de maio de 20268 min de leiturapor Sávio Araújo

O que é idempotência?

Uma operação é idempotente quando executá-la múltiplas vezes produz o mesmo resultado que executá-la uma única vez.

Na prática de APIs: o cliente pode repetir a mesma request quantas vezes precisar sem gerar efeitos colaterais indesejados.

Por que isso importa em produção

Redes falham. Timeouts acontecem. Clientes fazem retry. Considere:

  1. Cliente envia request de pagamento
  2. Servidor processa, debita o valor, mas a resposta de sucesso não chega ao cliente (timeout de rede)
  3. Cliente não sabe se o pagamento foi processado
  4. Cliente faz retry

Sem idempotência: o pagamento é debitado duas vezes. Com idempotência: o segundo processamento detecta a duplicata e retorna o mesmo resultado do primeiro, sem debitar novamente.

Esse problema não é teórico. É o tipo de coisa que acorda engenheiros às 3h da manhã.

Métodos HTTP e idempotência

O HTTP define expectativas de idempotência para seus métodos:

| Método | Idempotente? | Safe? | |--------|-------------|-------| | GET | ✅ Sim | ✅ Sim | | PUT | ✅ Sim | ❌ Não | | DELETE | ✅ Sim | ❌ Não | | HEAD | ✅ Sim | ✅ Sim | | POST | ❌ Não | ❌ Não | | PATCH | ❌ Não | ❌ Não |

"Safe" significa que não modifica estado. "Idempotente" significa que repetir não tem efeito adicional.

GET, PUT e DELETE deveriam ser idempotentes por design. O desafio está em POST e PATCH.

O padrão Idempotency-Key

A solução padrão da indústria (usada por Stripe, Adyen, e outros) é o header Idempotency-Key:

  1. Cliente gera um UUID único para a operação
  2. Envia no header: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  3. Servidor armazena o resultado associado à chave
  4. Se receber a mesma chave novamente, retorna o resultado armazenado sem reprocessar

Fluxo do Idempotency-Key: cache hit retorna direto, cache miss processa e salva

Implementação no NestJS

// idempotency.middleware.ts
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { Redis } from "ioredis";
 
@Injectable()
export class IdempotencyMiddleware implements NestMiddleware {
  constructor(private readonly redis: Redis) {}
 
  async use(req: Request, res: Response, next: NextFunction) {
    const key = req.headers["idempotency-key"] as string;
    if (!key) return next();
 
    const cacheKey = `idempotency:${key}`;
 
    const cached = await this.redis.get(cacheKey);
    if (cached) {
      const { status, body } = JSON.parse(cached);
      return res.status(status).json(body);
    }
 
    // Intercepta a resposta para armazenar no cache
    const originalJson = res.json.bind(res);
    res.json = (body: unknown) => {
      if (res.statusCode >= 200 && res.statusCode < 300) {
        this.redis.setex(
          cacheKey,
          86400, // 24 horas
          JSON.stringify({ status: res.statusCode, body })
        );
      }
      return originalJson(body);
    };
 
    next();
  }
}

Registrando o middleware

// app.module.ts
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(IdempotencyMiddleware)
      .forRoutes(
        { path: "payments", method: RequestMethod.POST },
        { path: "transfers", method: RequestMethod.POST }
      );
  }
}

Lidando com concorrência na chave

E se o mesmo Idempotency-Key chegar duas vezes simultaneamente, antes do primeiro processar? É preciso um lock:

async use(req: Request, res: Response, next: NextFunction) {
  const key = req.headers["idempotency-key"] as string;
  if (!key) return next();
 
  const cacheKey = `idempotency:${key}`;
  const lockKey = `idempotency:lock:${key}`;
 
  const cached = await this.redis.get(cacheKey);
  if (cached) {
    const { status, body } = JSON.parse(cached);
    return res.status(status).json(body);
  }
 
  // Tenta adquirir lock (NX = só se não existir, EX = TTL em segundos)
  const locked = await this.redis.set(lockKey, "1", "EX", 30, "NX");
  if (!locked) {
    // Outro processo está processando: aguarda brevemente e tenta o cache
    await new Promise((resolve) => setTimeout(resolve, 150));
    const retry = await this.redis.get(cacheKey);
    if (retry) {
      const { status, body } = JSON.parse(retry);
      return res.status(status).json(body);
    }
    return res.status(409).json({ error: "Request em processamento" });
  }
 
  res.on("finish", () => this.redis.del(lockKey));
 
  const originalJson = res.json.bind(res);
  res.json = (body: unknown) => {
    if (res.statusCode >= 200 && res.statusCode < 300) {
      this.redis.setex(cacheKey, 86400, JSON.stringify({ status: res.statusCode, body }));
    }
    return originalJson(body);
  };
 
  next();
}

Idempotência no banco com constraints

Para operações de criação, a estratégia complementar é usar constraints de unicidade:

ALTER TABLE payments
ADD COLUMN idempotency_key UUID UNIQUE;
async createPayment(dto: CreatePaymentDto, idempotencyKey: string) {
  try {
    return await this.repo.save({ ...dto, idempotencyKey });
  } catch (error) {
    if (isUniqueConstraintError(error)) {
      // Já existe: retorna o registro existente
      return this.repo.findOne({ where: { idempotencyKey } });
    }
    throw error;
  }
}

Essa abordagem funciona mesmo sem Redis: o banco garante unicidade.

Design idempotente para PATCH

PATCH não é idempotente por padrão, mas pode ser projetado para ser. A diferença:

// Não idempotente: incrementa toda vez
PATCH /users/123
{ "increment_score": 10 }
 
// Idempotente: define o valor absoluto
PATCH /users/123
{ "score": 150 }

Quando precisar de operações incrementais idempotentes, use eventos com ID único:

// O evento tem ID único: processado exatamente uma vez
POST /score-events
{
  "eventId": "evt_abc123",
  "userId": "123",
  "delta": 10,
  "reason": "purchase_completed"
}

O backend verifica se eventId já foi processado antes de aplicar o delta.

Tempo de retenção das chaves

Por quanto tempo guardar a chave de idempotência?

  • Stripe retém por 24 horas
  • Adyen retém por 7 dias
  • Para a maioria dos casos, 24 horas é suficiente

Use TTL no Redis ou uma coluna expires_at no banco: nunca guarde indefinidamente.

Conclusão

Idempotência é o que separa uma API que funciona em condições ideais de uma API que funciona em produção. O padrão Idempotency-Key resolve isso de forma elegante: o cliente gera um ID único por tentativa, o servidor garante que esse ID seja processado exatamente uma vez.

Implemente primeiro nas operações financeiras ou que criam recursos. São as de maior impacto e onde duplicações causam mais dano.

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech