Rate limiting é uma daquelas decisões de backend que parecem detalhe até o primeiro pico de tráfego, abuso de API ou integração mal comportada. Em uma aplicação Kotlin exposta na internet, limitar requisições protege infraestrutura, banco de dados, serviços externos, filas, custos em cloud e, principalmente, a experiência dos usuários legítimos. Sem limite, um único cliente com bug pode consumir a mesma capacidade que centenas de usuários reais.

Para quem trabalha com Kotlin para backend, rate limiting deve entrar no mesmo grupo de autenticação, autorização, timeout, retry, observabilidade e cache: não é enfeite de produção, é parte do contrato operacional da API. Este guia mostra como pensar limites em Spring Boot, Ktor e Redis, com exemplos práticos e trade-offs que aparecem em times brasileiros usando Kotlin em APIs REST, microsserviços e produtos SaaS.

O que rate limiting resolve

Rate limiting controla quantas ações uma identidade pode executar em uma janela de tempo. Essa identidade pode ser um IP, usuário autenticado, tenant, chave de API, rota, token interno ou combinação desses elementos. A regra mais simples é: “no máximo 100 requisições por minuto por usuário”. Em sistemas reais, a regra costuma variar por endpoint, plano contratado e criticidade da operação.

Ele ajuda em vários cenários:

  • evitar força bruta em login, recuperação de senha e endpoints sensíveis;
  • impedir scraping agressivo de catálogos, vagas ou dados públicos;
  • proteger chamadas caras que acessam banco, IA, pagamento ou parceiro externo;
  • preservar qualidade de serviço durante picos;
  • transformar abuso em resposta previsível com HTTP 429 Too Many Requests.

Rate limiting não substitui Spring Security com JWT e OAuth2, WAF, validação de entrada ou observabilidade. Ele complementa essas camadas. Um endpoint autenticado ainda pode ser abusado por um token legítimo. Uma chamada pública ainda precisa diferenciar tráfego normal de automação agressiva.

Escolha a chave correta

O erro mais comum é limitar apenas por IP. Isso funciona para uma primeira defesa, mas tem problemas: muitos usuários podem compartilhar o mesmo IP corporativo, usuários móveis trocam de IP com frequência, proxies escondem origem e atacantes podem distribuir chamadas. Sempre que possível, combine sinais.

Para endpoints autenticados, uma chave melhor costuma ser usuario:{id}:rota:{nome} ou tenant:{id}:rota:{nome}. Para APIs B2B, use a chave de API ou client id. Para endpoints públicos, use IP normalizado, user agent apenas como sinal auxiliar e limites mais conservadores em rotas críticas.

Também pense em granularidade. Um limite global por usuário protege a plataforma inteira, mas pode bloquear ações legítimas se uma tela faz muitas chamadas leves. Um limite por rota protege operações caras, mas não impede abuso distribuído entre muitos endpoints. Em produção, uma combinação costuma funcionar melhor: limite global, limite por endpoint sensível e limite especial para operações com efeito colateral.

Algoritmos comuns

Existem várias formas de aplicar limites. As mais usadas em APIs Kotlin são janela fixa, janela deslizante e token bucket.

Janela fixa é simples: contar chamadas entre 10:00:00 e 10:00:59, depois reiniciar. É fácil de implementar com Redis, mas pode permitir rajadas na virada da janela. Se o limite é 100 por minuto, um cliente pode fazer 100 chamadas às 10:00:59 e mais 100 às 10:01:00.

Janela deslizante reduz esse problema ao considerar os últimos 60 segundos reais. É mais justa, mas exige estrutura de dados e limpeza mais cuidadosas. Token bucket permite acumular uma quantidade limitada de “fichas” e recarregar ao longo do tempo. Ele lida bem com pequenas rajadas sem liberar abuso sustentado.

Para começar, janela fixa com limites prudentes e métricas já é melhor que nada. Para produtos com tráfego alto, planos pagos ou risco financeiro, token bucket ou janela deslizante trazem mais controle.

Implementação simples com Redis

Redis em Kotlin é uma escolha comum porque INCR e expiração são rápidos e atômicos. Um serviço básico em Spring Boot pode ficar assim:

@Service
class RateLimitService(
    private val redis: StringRedisTemplate,
) {
    fun permitir(chave: String, limite: Long, janela: Duration): Boolean {
        val contador = redis.opsForValue().increment(chave) ?: return false

        if (contador == 1L) {
            redis.expire(chave, janela)
        }

        return contador <= limite
    }
}

Esse exemplo é intencionalmente pequeno. Em produção, prefira executar incremento e TTL em script Lua ou biblioteca pronta para evitar inconsistência se o processo cair entre INCR e EXPIRE. Ainda assim, ele mostra a mecânica: cada chave recebe uma contagem e uma expiração curta.

Um filtro Spring pode aplicar a regra antes do controller:

@Component
class ApiRateLimitFilter(
    private val rateLimit: RateLimitService,
) : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
    ) {
        val user = request.userPrincipal?.name ?: request.remoteAddr
        val key = "rate:v1:$user:${request.method}:${request.requestURI}"

        if (!rateLimit.permitir(key, limite = 120, janela = Duration.ofMinutes(1))) {
            response.status = 429
            response.setHeader("Retry-After", "60")
            response.writer.write("Limite de requisições excedido")
            return
        }

        chain.doFilter(request, response)
    }
}

Não use o caminho completo sem cuidado se ele contém IDs, slugs ou parâmetros muito variados. Isso pode criar milhões de chaves. Normalmente é melhor mapear rotas para nomes estáveis, como login, criar-pedido, buscar-catalogo ou webhook-parceiro.

Rate limiting no Ktor

Em Ktor, a ideia pode ser implementada como plugin, interceptor de rota ou função reutilizável. Para começar de forma explícita:

suspend fun PipelineContext<Unit, ApplicationCall>.aplicarRateLimit(
    redis: RedisRateLimiter,
    limite: Long,
    janela: Duration,
) {
    val principal = call.principal<UserIdPrincipal>()?.name
    val origem = principal ?: call.request.origin.remoteHost
    val rota = call.request.httpMethod.value + ":" + call.request.path()
    val chave = "rate:v1:$origem:$rota"

    if (!redis.permitir(chave, limite, janela)) {
        call.response.header(HttpHeaders.RetryAfter, janela.seconds.toString())
        call.respond(HttpStatusCode.TooManyRequests, "Limite de requisições excedido")
        finish()
    }
}

Depois, chame a função nas rotas mais sensíveis ou encapsule em um plugin interno. Para APIs com coroutines, evite bloqueios longos dentro do limitador. Use cliente Redis compatível com o modelo assíncrono da aplicação ou isole chamadas bloqueantes em dispatcher adequado.

Limites diferentes por operação

Nem toda rota merece o mesmo número. GET /produtos pode aceitar volume maior, especialmente se há cache. POST /login, POST /checkout, POST /webhooks/parceiro e POST /relatorios/exportar precisam de regras próprias. Operações idempotentes e baratas toleram mais tráfego; operações com efeito colateral, custo externo ou risco de segurança pedem limites menores.

Um bom ponto de partida:

  • login: poucas tentativas por minuto por IP e por conta;
  • recuperação de senha: limite baixo por e-mail e por IP;
  • APIs autenticadas comuns: limite por usuário e por tenant;
  • endpoints de escrita: limite menor que leitura;
  • integrações externas: limite alinhado ao contrato do parceiro;
  • jobs manuais ou exportações: limite por usuário e fila assíncrona.

Se o backend chama serviços externos, combine rate limiting de entrada com cliente HTTP resiliente. O guia de Ktor Client com timeout, retry e circuit breaker mostra por que retry sem limite pode amplificar incidentes. Um 429 bem aplicado é melhor que deixar todas as chamadas entrarem, repetirem e derrubarem a dependência.

Observabilidade e resposta HTTP

Rate limiting sem métrica vira chute. Registre contadores por regra, rota e resultado: permitido, bloqueado, erro no Redis e fallback aplicado. Não grave dados sensíveis na chave de log; use IDs internos, hash ou rótulos controlados. Em dashboards, acompanhe taxa de 429, top rotas bloqueadas, tenants mais limitados e latência do limitador.

Também devolva uma resposta útil. O status correto é 429 Too Many Requests. O header Retry-After ajuda clientes bem comportados a esperar. Em APIs públicas, documente limites e mensagens. Em APIs internas, trate limite como contrato: consumidor que excede limite precisa ajustar batch, cache, paginação ou backoff.

Para investigar incidentes, integre esses eventos à sua camada de observabilidade em Kotlin. Um aumento súbito de bloqueios pode ser ataque, bug de frontend, app mobile antigo, parceiro sem backoff ou campanha de marketing bem-sucedida. A resposta operacional muda conforme a causa.

Cuidados de produção

Evite falhar fechado sem reflexão. Se Redis fica indisponível, bloquear todo tráfego pode transformar falha pequena em outage total. Permitir tudo também pode expor o sistema no pior momento. A política depende da rota: login pode falhar fechado com mensagem temporária; leitura pública pode falhar aberto com limite local; checkout talvez precise fallback conservador e alerta imediato.

Use namespaces versionados nas chaves, como rate:v1, para mudar regras sem misturar contadores antigos. Defina TTL em todas as chaves. Evite cardinalidade explosiva. Faça testes de carga com cenários de rajada. Documente exceções para health checks, webhooks confiáveis e tarefas internas. E revise limites com base em dados reais, não apenas opinião.

Carreira e próximos passos

Rate limiting é um tema pequeno no código e grande na maturidade operacional. Em entrevistas para backend Kotlin, ele ajuda a demonstrar que você entende segurança, performance, resiliência, Redis, HTTP e experiência de usuário. Para portfólio, implemente uma API com Spring Boot ou Ktor, autenticação, Redis, testes e métricas, depois documente os limites escolhidos.

Quem quer comparar stacks pode estudar como Go costuma resolver middlewares HTTP de alta concorrência em Go para backend. A lógica de produto é parecida; o que muda são bibliotecas, ergonomia e modelo de execução. Em Kotlin, a vantagem está em combinar ecossistema JVM maduro, Spring Boot quando há necessidade corporativa e Ktor quando simplicidade e coroutines são prioridades.

O próximo passo prático é escolher três endpoints do seu projeto e escrever uma tabela: identidade usada no limite, janela, quantidade, resposta HTTP, métrica e fallback. Se essa tabela não existe, o rate limiting ainda está implícito. Torná-lo explícito é uma das formas mais baratas de deixar uma API Kotlin mais preparada para produção.