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:
- Cliente envia request de pagamento
- Servidor processa, debita o valor, mas a resposta de sucesso não chega ao cliente (timeout de rede)
- Cliente não sabe se o pagamento foi processado
- 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:
- Cliente gera um UUID único para a operação
- Envia no header:
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 - Servidor armazena o resultado associado à chave
- Se receber a mesma chave novamente, retorna o resultado armazenado sem reprocessar
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.