FastAPI: endpoints idempotentes com Idempotency-Key e Redis (retries seguros)
Por John Pires · 29 de março de 2026

FastAPI: endpoints idempotentes com Idempotency-Key e Redis (retries seguros)
Contexto e motivacao
Falhas de rede, timeouts e retries automáticos (cliente ou infra) fazem com que operações não-idempotentes sejam executadas várias vezes. Isso gera cobranças duplicadas, criação de recursos em duplicidade e inconsistência de estado. Usar um cabeçalho Idempotency-Key junto com um armazenamento rápido como Redis permite detectar retries do mesmo pedido e garantir que a ação seja executada apenas uma vez ou que devolvamos uma resposta consistente.
Desenvolvedores no nível intermediário normalmente conhecem FastAPI e Redis, mas sofrem com a implementação correta do fluxo de idempotência em ambiente concorrente e com múltiplos workers. O objetivo aqui é entregar um padrão robusto que funciona em produção: bloqueio idempotente, armazenamento de resposta, expiração e proteção contra race conditions.
O padrão se aplica a endpoints que criam recursos ou disparam operações latentes (pagamentos, filas, geração de relatórios). Não é necessário para GETs, mas é crítico em POSTs/PUTs que não podem ser duplicados.
Este artigo explica o fluxo, detalha a implementação usando FastAPI + redis.asyncio (redis-py 4.x), mostra scripts Lua para atomicidade e oferece padrões operacionais e armadilhas a evitar.
Como funciona por baixo dos panos
Idempotência via chave consiste em vários passos: identificar a solicitação com a chave fornecida, verificar se já existe resultado, reservar um marcador de processamento (lock) se for a primeira vez, executar a operação e persistir o resultado para chamadas subsequentes. Tudo isso precisa ser coordenado de forma atômica para evitar duas execuções concorrentes para a mesma chave.
Estados e transições
Usamos três estados principais armazenados no Redis:
- in-progress: existe um lock indicando que um worker está processando a requisição.
- completed: a operação terminou com resultado persistido.
- absent: não existe registro para a chave.
Transições importantes:
- absent -> in-progress: realizado via SET NX com TTL (claim).
- in-progress -> completed: substituição atômica que grava resposta e remove o lock.
- in-progress -> absent: se o worker morreu e o TTL expirar, outra tentativa pode re-entrar.
Atomicidade e segurança
Operações simultâneas são o maior risco. Usamos SET NX (ou script Lua) para garantir que apenas um worker faça o trabalho inicial. Para gravar a resposta final, verificamos se a operação ainda pertence ao mesmo owner (uuid) antes de substituir o estado. Isso evita que um worker sobrescreva o resultado de outro caso o lock tenha expirado e outra tentativa tenha assumido.
Use sempre um identificador de owner por lock (UUID) e valide-o ao persistir o resultado. Scripts Lua ajudam a garantir essas checagens atomicas.
Implementacao passo a passo
A implementação que vamos usar combina um helper de Redis, um decorator para endpoints e um padrão de serialização de resposta. O código depende de redis.asyncio e FastAPI; para serializar use orjson para desempenho.
Dependências e configurações
Instale as dependências recomendadas:
pip install fastapi uvicorn redis[asyncio] orjson python-dotenv
Configuração mínima (exemplo .env):
REDIS_URL=redis://localhost:6379/0
IDEMPOTENCY_TTL=3600 # segundos
O TTL evita que chaves ocupem memória indefinidamente e serve como proteção contra keys abandonadas.
Helper Redis e script Lua (atomicidade)
O próximo bloco cria helpers para claim, salvar resposta e recuperar. Ele usa um script Lua para setar o estado final somente se o owner bater — isso é atômico no Redis.
"""redis_helpers.py
Helpers async para gerenciar locks idempotentes e armazenamento de resposta.
"""
import asyncio
import uuid
import json
from typing import Optional
import redis.asyncio as aioredis
IDEMPOTENCY_PREFIX = "idem:" # chave no redis
# Script Lua: grava resposta somente se owner bate
LUA_SET_COMPLETED = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])
redis.call('DEL', KEYS[1])
return 1
else
return 0
end
"""
class RedisIdempotency:
def __init__(self, url: str, ttl: int = 3600):
self.redis = aioredis.from_url(url, decode_responses=True)
self.ttl = ttl * 1000 # ms for SET px
self._script = None
async def _load_script(self):
if self._script is None:
self._script = await self.redis.script_load(LUA_SET_COMPLETED)
return self._script
async def claim(self, key: str, owner: Optional[str] = None, claim_ttl: int = 30000) -> str:
"""Tenta criar um lock in-progress. Retorna owner usado (novo ou existente).
claim_ttl em ms.
"""
if owner is None:
owner = str(uuid.uuid4())
lock_key = IDEMPOTENCY_PREFIX + key + ":lock"
# SET lock value NX PX claim_ttl
ok = await self.redis.set(lock_key, owner, nx=True, px=claim_ttl)
if ok:
return owner
# se não conseguiu, retorna o owner existente
existing = await self.redis.get(lock_key)
return existing
async def set_completed(self, key: str, owner: str, response_obj: dict, store_ttl: Optional[int] = None) -> bool:
"""Tenta gravar o resultado final se owner bater. Retorna True se bem sucedido."""
if store_ttl is None:
store_ttl = self.ttl
completed_key = IDEMPOTENCY_PREFIX + key + ":resp"
lock_key = IDEMPOTENCY_PREFIX + key + ":lock"
response_json = json.dumps(response_obj, separators=(",",":"))
script = await self._load_script()
res = await self.redis.evalsha(script, 2, lock_key, completed_key, owner, response_json, str(store_ttl))
return res == 1
async def get_completed(self, key: str) -> Optional[dict]:
completed_key = IDEMPOTENCY_PREFIX + key + ":resp"
raw = await self.redis.get(completed_key)
if not raw:
return None
return json.loads(raw)
async def wait_for_completion(self, key: str, timeout: float = 5.0, poll_interval: float = 0.1) -> Optional[dict]:
"""Se existir in-progress, espera até que completed apareça ou timeout."""
elapsed = 0.0
while elapsed < timeout:
res = await self.get_completed(key)
if res is not None:
return res
await asyncio.sleep(poll_interval)
elapsed += poll_interval
return None
O que aconteceu: o helper expõe claim (SET NX), set_completed rodando um script Lua que grava resposta apenas se o lock pertencer ao owner, e um get/espera para recuperar a resposta existente. Isso reduz races.
Decorator idempotente para endpoints
Agora um decorator que adiciona o comportamento idempotente ao handler. Ele:
- lê cabeçalho Idempotency-Key
- verifica se já existe resposta completa e a retorna
- tenta claim do lock; se não conseguir espera por resultado
- executa handler, captura resposta (status, headers, body) e persiste
"""decorators.py
Decorator idempotente para FastAPI endpoints.
"""
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse
import orjson
from functools import wraps
from typing import Callable
from redis_helpers import RedisIdempotency
redis_client = RedisIdempotency(url="redis://localhost:6379/0", ttl=3600)
def idempotent_endpoint(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
# procura Request em args/kwargs
request: Request = kwargs.get('request') or next((a for a in args if isinstance(a, Request)), None)
if request is None:
raise RuntimeError('idempotent_endpoint requires a Request parameter')
key = request.headers.get('Idempotency-Key')
if not key:
raise HTTPException(status_code=400, detail='Idempotency-Key header required')
# 1) se já existe completed -> retorna
existing = await redis_client.get_completed(key)
if existing is not None:
return JSONResponse(content=existing['body'], status_code=existing['status'], headers=existing.get('headers'))
# 2) tenta claim
owner = await redis_client.claim(key)
# se não somos owner, espera que outro complete
if owner is not None:
# se owner não é nosso uuid, significa que outro está processando
# espera curto
waited = await redis_client.wait_for_completion(key, timeout=5.0)
if waited is not None:
return JSONResponse(content=waited['body'], status_code=waited['status'], headers=waited.get('headers'))
# se timeout -> conflito
raise HTTPException(status_code=409, detail='Processing in progress; try again later')
# 3) somos owner: executa handler
result = await func(*args, **kwargs)
# normaliza resposta (assume JSON serializavel)
if isinstance(result, JSONResponse):
body = result.body.decode() if isinstance(result.body, (bytes, bytearray)) else result.body
status = result.status_code
headers = dict(result.headers)
elif isinstance(result, dict):
body = result
status = 200
headers = {}
else:
# tenta serializar genericamente
body = result
status = 200
headers = {}
# 4) grava completed (owner validation feita no script)
payload = {'status': status, 'headers': headers, 'body': body}
ok = await redis_client.set_completed(key, owner, payload)
if not ok:
# alguém tomou o lock antes do set (edge case)
# tenta recuperar o resultado do novo owner
final = await redis_client.get_completed(key)
if final:
return JSONResponse(content=final['body'], status_code=final['status'], headers=final.get('headers'))
raise HTTPException(status_code=500, detail='Idempotency conflict')
return JSONResponse(content=body, status_code=status, headers=headers)
return wrapper
Depois desse bloco, endpoints decorados com @idempotent_endpoint terão comportamento consistente. O decorator assume que handler retorna dict ou JSONResponse. Você pode adaptar para Response completa ou streams se necessário.
Exemplo de endpoint real
Exemplo de um endpoint de cobrança que usa o decorator. Simula operação que demora e que não deve rodar duas vezes.
from fastapi import FastAPI, Request
from decorators import idempotent_endpoint
import asyncio
app = FastAPI()
@app.post('/charge')
@idempotent_endpoint
async def create_charge(request: Request):
# simula chamada a gateway de pagamentos que demora
await asyncio.sleep(1.5)
# normalmente aqui você chamaria um serviço externo e gravaria no banco
result = {'transaction_id': 'tx-' + request.headers.get('Idempotency-Key'), 'status': 'success'}
return result
O que acontece: na primeira chamada com a chave X o decorator grava lock e executa create_charge. Chamadas repetidas com a mesma chave X retornam imediatamente a resposta persistida sem chamar o handler novamente.
Patterns e boas praticas
Algumas decisões de implementação impactam diretamente a robustez do sistema. Abaixo, patterns empíricos usados em produção.
Chave: escolha e validade
Recomende aos consumidores que gerem Idempotency-Key como UUID v4. Evite usar dados sensíveis ou previsíveis. Documente o TTL máximo suportado (ex: 24h) para que clientes saibam por quanto tempo podem reenviar a mesma chave com segurança.
TTL e expiracao
Configurar TTL para lock e para objeto completado é crítico:
- Lock TTL: deve cobrir o tempo máximo esperado da operação + margem.
- Resultado TTL: tempo que você quer permitir retries idempotentes. Cobrir alguns dias para operações financeiras pode ser necessário.
Regra prática: lock_ttl << result_ttl. Lock curto evita bloqueios permanentes por worker morto; resultado longo permite retries sem reprocessamento.
Garantir não repetição no banco externo
Mesmo com resposta persistida, a sua operação pode ter side-effects externos (banco, gateway). Combine persistência do resultado com um único ponto de escrita no banco:
- Trate a gravação do recurso como idempotente no DB (unique constraints, idempotency_key column).
- Se gravar no DB, escreva o resultado junto da idempotency_key numa mesma transação para garantir consistência.
Armadilhas comuns e como evitar
Aqui estão erros frequentes e como mitigá-los com implementações práticas.
1) Não validar owner antes de gravar
Problema: um worker perde o lock por timeout e outro assume; o primeiro grava resultado depois e sobrescreve o correto. Solução: sempre gravar final somente se owner bater (script Lua acima) ou usando WATCH/MULTI com checagens atômicas.
2) Tratar idempotency-key como segredo
Problema: chaves expostas podem permitir replay por terceiros. Solução: trate como token não-sensível, monitore anomalias e aplique rate-limits por client-id/IP. Para operações sensíveis, combine Idempotency-Key com autenticação forte.
3) Bloqueio indefinido por lock muito longo
Problema: se um worker trava e lock TTL é enorme, as requisições subsequentes ficarão bloqueadas ou falharão. Solução: use TTLs moderados, implemente wait_with_timeout e retorne 409/202 apropriados para quem tenta enquanto ainda está em processamento.
4) Serializacao de resposta inconsistente
Problema: serializar objetos complexos (bytes, datetimes) pode causar diferenças entre primeira execução e recuperações. Solução: normalize a resposta ao gravar — converta datetimes para ISO, remova campos transitórios e defina contrato claro de resposta armazenada.
Dica: logue o Idempotency-Key + owner + request-id para rastrear execuções e diagnosticar condicionais de corrida.
Armadilhas operacionais e testes
Teste com concorrência: scripts que disparam 50-200 requests simultâneos com a mesma chave são essenciais para validar o comportamento. Simule falhas de worker desligando processos durante execução.
Monitore métricas chave: número de keys expiradas, hits no completed, número de conflitos (409), tempo médio de espera para completed. Configure alertas para spikes incomuns.
Conclusao pratica
Idempotency-Key + Redis é uma solução robusta se implementada corretamente: claim com SET NX, validação de owner e gravação atômica do resultado. Use TTLs bem dimensionados, unique constraints no banco para compatibilidade dupla e scripts Lua quando precisar garantir atomicidade entre checagens e gravações.
Implemente o decorator/ middleware mostrado como base e adapte para o contrato de resposta real da sua aplicação. Teste com concorrência e falhas de infraestrutura. Documente o comportamento esperado para consumidores do API — TTL, formato da chave e semântica de retries.
Pontos finais: sempre trate idempotency como uma característica de API, não apenas como um hack de infra; combine armazenamento de resultado com garantias no backend e logue para auditoria.
Com esse padrão você reduz significativamente riscos de duplicação, melhora a experiência do cliente em redes instáveis e habilita retries seguros no frontend e infra.
Assuntos: Python, NoSQL, idempotency, best-practices
Leia também
FastAPI: inferência de modelos em streaming com asyncio, chunking e SSE
Padrões práticos para inferência em streaming com FastAPI: use asyncio.Queue, chunking e SSE para reduzir latência percebida, controlar concorrência e…
Feature flags dinâmicos em FastAPI: toggles, rollout e cache com Redis
Padrao prático para feature flags em FastAPI usando Redis: evite clientes sincronos, use cache local com TTL, rollout deterministico por hashing e invalidacao…
Instrumentando FastAPI com OpenTelemetry: traces, métricas e logs correlacionados
Guia prático para instrumentar FastAPI com OpenTelemetry: configure Tracer/Meter, exporte OTLP, adicione spans manuais, métricas e logs correlacionados…
Servidor de embeddings com FastAPI, Transformers e FAISS: batching e quantização
Implementação prática de um servidor de embeddings com FastAPI, transformers e FAISS: mostro anti-patterns e correções, batching assíncrono, execução em…
Configuração segura em Python: Pydantic v2 + HashiCorp Vault na prática
Pydantic v2 + HashiCorp Vault: técnicas práticas para evitar vazamento de segredos, integrar Vault como fonte de configuração, e patterns corretos para…
Criar uma CLI em Python com Typer: comandos, autocompletar e distribuir com pipx
Aprenda a criar uma CLI em Python com Typer: comandos tipados, autocompletar via click-completion e distribuição isolada com pipx. Guia prático passo a passo…