DEV Community

Pedro Parker
Pedro Parker

Posted on

Rate limiting com Redis para um SaaS freemium: a arquitetura por trás de 3 tiers

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}"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    }
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Redis INCR + EXPIRE para contadores atômicos sem race conditions
  2. Identificação em cascata (JWT → IP) para cobrir todos os cenários
  3. Cache do plano para evitar query ao banco em cada request
  4. Scoping correto — não contar requests que não geram valor
  5. Headers informativos para o frontend mostrar o estado
  6. Rate limits separados para auth (brute force) vs consulta (quota)

Top comments (0)