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)
United States
NORTH AMERICA
Related News
What Does "Building in Public" Actually Mean in 2026?
19h ago
The Agentic Headless Backend: What Vibe Coders Still Need After the UI Is Done
19h ago
Why I’m Still Learning to Code Even With AI
21h ago
I gave Claude a persistent memory for $0/month using Cloudflare
1d ago
NYT: 'Meta's Embrace of AI Is Making Its Employees Miserable'
1d ago