No CNPJ Aberto, oferecemos consultas gratuitas de empresas brasileiras com um modelo freemium:
| Tier | Limite diário | Identificação |
|---|---|---|
| Anônimo | 50/dia | IP |
| Free (conta gratuita) | 200/dia | User ID |
| Pro (R$39/mês) | 5.000/dia | User ID |
Implementar isso parece simples, mas os detalhes fazem a diferença entre um sistema robusto e um cheio de edge cases. Neste post, vou mostrar a arquitetura completa usando Redis e FastAPI middleware.
Por que Redis?
Rate limiting precisa de:
- Contadores atômicos — múltiplas requests simultâneas não podem criar race conditions
- Expiração automática — o contador deve resetar a cada período
- Baixa latência — não pode adicionar overhead perceptível à request
- Memória eficiente — milhões de chaves com TTL
Redis resolve tudo isso com dois comandos: INCR e EXPIRE.
A chave do rate limit
def get_rate_limit_key(identifier: str) -> str:
today = datetime.utcnow().strftime("%Y-%m-%d")
return f"rl:{identifier}:{today}"
Formato: rl:{identifier}:{YYYY-MM-DD}
Exemplos:
- Anônimo:
rl:189.44.52.100:2026-04-16 - Logado:
rl:user:42:2026-04-16
A data na chave garante que o contador reseta automaticamente à meia-noite UTC. Não precisamos de cron jobs para limpar contadores — o EXPIRE cuida disso.
O check: INCR + EXPIRE atômico
async def check_rate_limit(redis, identifier: str, limit: int):
key = get_rate_limit_key(identifier)
current = await redis.incr(key)
if current == 1:
# Primeira request do dia — definir TTL de 24h
await redis.expire(key, 86400)
if current > limit:
return False, current # Rate limited
return True, current
Por que INCR primeiro? Porque INCR no Redis é atômico — se dois requests chegarem ao mesmo tempo, um vai receber 1 e o outro 2. Nunca teremos race condition.
Por que checar current == 1? Na primeira request do dia, a chave ainda não existe. O INCR cria a chave com valor 1. Nesse momento, definimos o TTL de 86400 segundos (24h). Se não fizermos isso, a chave ficaria para sempre.
Edge case: e se o EXPIRE falhar?
Se o EXPIRE falhar (Redis reiniciou entre o INCR e o EXPIRE, por exemplo), teríamos uma chave sem TTL que nunca expira. Solução de segurança:
if current == 1:
await redis.expire(key, 86400)
elif current == 2:
# Safeguard: verificar se o TTL foi setado
ttl = await redis.ttl(key)
if ttl == -1: # Sem TTL
await redis.expire(key, 86400)
Identificando o usuário: IP vs JWT
O rate limit precisa saber quem está fazendo a request. A ordem de identificação:
def get_identifier(request: Request) -> tuple[str, str]:
# 1. Tentar extrair user do JWT (cookie ou header)
token = extract_token(request)
if token:
user_id = decode_jwt(token).get("sub")
if user_id:
return f"user:{user_id}", "authenticated"
# 2. Fallback: IP do cliente
ip = (
request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
or request.client.host
)
return ip, "anonymous"
Cuidado com X-Forwarded-For: Sempre pegar o primeiro IP da lista (o IP real do cliente). Os subsequentes são proxies intermediários. Se você não está atrás de um reverse proxy, use request.client.host.
Determinando o plano: cache de 5 minutos
Buscar o plano do usuário no banco a cada request seria caro. Usamos Redis como cache:
async def get_effective_plan(redis, db, user_id: int) -> str:
cache_key = f"uplan:{user_id}"
cached = await redis.get(cache_key)
if cached:
return cached.decode()
user = db.query(User).filter(User.id == user_id).first()
plan = user.plan if user else "free"
# Cache por 5 minutos
await redis.setex(cache_key, 300, plan)
return plan
Por que 5 minutos? Se o usuário fizer upgrade para Pro, o novo limite entra em vigor em no máximo 5 minutos. É um tradeoff aceitável entre performance e experiência.
Invalidação: Quando o webhook de pagamento processa um upgrade, fazemos redis.delete(f"uplan:{user_id}") para invalidar o cache imediatamente.
O Middleware FastAPI
Tudo se junta no middleware que intercepta cada request:
COUNTED_PREFIXES = [
"/api/cnpj/",
"/api/search",
"/api/busca-avancada",
"/api/leads",
"/api/socios/busca",
"/api/pessoa/",
]
RATE_LIMITS = {
"anon": 50,
"free": 200,
"pro": 5000,
}
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
path = request.url.path
# Só conta requests que consomem dados
if not any(path.startswith(p) for p in COUNTED_PREFIXES):
return await call_next(request)
identifier, auth_type = get_identifier(request)
if auth_type == "authenticated":
plan = await get_effective_plan(redis, db, user_id)
else:
plan = "anon"
limit = RATE_LIMITS[plan]
allowed, current = await check_rate_limit(redis, identifier, limit)
if not allowed:
return JSONResponse(
status_code=429,
content={"detail": "Limite de consultas atingido"},
headers=rate_limit_headers(limit, 0, plan),
)
response = await call_next(request)
# Adicionar headers informativos
response.headers.update(
rate_limit_headers(limit, limit - current, plan)
)
return response
Headers X-RateLimit
def rate_limit_headers(limit, remaining, plan):
return {
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": str(max(0, remaining)),
"X-RateLimit-Plan": plan,
}
O frontend lê esses headers para mostrar um banner quando o usuário está chegando no limite:
// Frontend: lê os headers e avisa o usuário
function trackRateLimit(res: Response) {
const remaining = res.headers.get("x-ratelimit-remaining");
const limit = res.headers.get("x-ratelimit-limit");
if (remaining && parseInt(remaining) <= 5) {
// Mostra banner: "Você tem apenas X consultas restantes hoje"
showRateLimitBanner(parseInt(limit), parseInt(remaining));
}
}
Rate limit separado para autenticação
Login e registro têm um rate limit próprio para prevenir brute force:
async def check_auth_rate_limit(redis, ip: str):
minute = datetime.utcnow().strftime("%Y%m%d%H%M")
key = f"rl:auth:{ip}:{minute}"
current = await redis.incr(key)
if current == 1:
await redis.expire(key, 120) # 2 minutos de janela
return current <= 10 # Max 10 tentativas por minuto
10 tentativas por minuto por IP. Janela de 2 minutos (não 1) para cobrir o caso de requests que chegam no segundo 59 e 00.
O que não contar
É importante não consumir quota em requests que não geram valor para o usuário:
# Não conta:
# - Assets estáticos (/_next/*, /fonts/*)
# - Health checks (/api/health)
# - Auth endpoints (/api/auth/*) ← tem rate limit próprio
# - Municipios/CNAEs search (autocomplete de filtros)
# Conta:
# - Consulta de CNPJ (/api/cnpj/{cnpj})
# - Busca textual (/api/search)
# - Busca avançada (/api/busca-avancada)
# - Leads (/api/leads)
Se contássemos autocomplete de filtros, um usuário gastaria metade do limite apenas navegando pela busca avançada.
Redirecionamento no frontend
Quando o backend retorna 429, o frontend redireciona para uma página explicativa:
async function apiFetch(url: string, init?: RequestInit) {
const res = await fetch(url, init);
if (res.status === 429 && typeof window !== "undefined") {
window.location.href = "/limite";
}
return res;
}
A página /limite explica o que aconteceu, mostra os planos, e incentiva o upgrade. É a principal conversão do freemium.
Monitoramento
No painel admin, mostramos:
- Requests totais por tier (anon/free/pro)
- Quantos 429 foram servidos hoje
- Top IPs por consumo
- Distribuição de uso (quantos usam <10%, 10-50%, 50-100% do limite)
Isso ajuda a calibrar os limites. Se muitos anônimos estão batendo 50/dia, talvez devêssemos subir para 75 para reduzir frustração sem impactar conversão.
Conclusão
Rate limiting parece simples, mas um sistema robusto precisa:
- Redis INCR + EXPIRE para contadores atômicos sem race conditions
- Identificação em cascata (JWT → IP) para cobrir todos os cenários
- Cache do plano para evitar query ao banco em cada request
- Scoping correto — não contar requests que não geram valor
- Headers informativos para o frontend mostrar o estado
- Rate limits separados para auth (brute force) vs consulta (quota)
Top comments (0)