1. Por que um rate limit e' necessario
Falando de forma simples, rate limit e' um bloqueio na entrada da sua API. Nao para “torturar o usuario honesto”, mas para que um cliente muito ativo demais (ou um bot) nao transforme seu servidor em uma abobora triste, que so sabe cair, registrar erros e se arrepender da escolha de carreira.
No mundo HTTP, rate limit normalmente responde a perguntas como: “Quantas requisições por minuto estamos dispostos a atender de um cliente?” e “O que fazemos se o cliente exagerar?”. A resposta para a segunda pergunta e' padronizada: HTTP 429 Too Many Requests. O importante e' que 429 nao e' um “erro interno do servidor”, mas uma politica esperada. E' como uma placa de “entrada apenas com ingresso” — ninguem se surpreende por nao entrar sem ingresso.
Outro motivo: rate limit ajuda a tornar o sistema mais previsivel. Mesmo que tudo esteja rapido, um pico repentino de requisicoes (por exemplo, alguem rodou um script em loop) pode estragar a latencia para todos. rate limit reduz a probabilidade desses “feriados da vida”.
Fixed window: modelo simples e parametros
A expressao “rate limiting” soa como se agora viesse uma dissertação de 150 paginas, graficos, formulas e um professor que ama suspeitamente a palavra “token”. Mas aqui vamos usar o modelo mais simples e didatico-pratico: fixed window (janela fixa).
A ideia e' simples. Voce escolhe:
window — duracao da janela, por exemplo 1 minute
limit — quantas requisições sao permitidas nessa janela, por exemplo 30
Depois, para cada cliente, voce guarda dois numeros: quantas requisicoes ele ja fez na janela atual e quando essa janela termina. Assim que a janela expira, o contador e' zerado e uma nova janela comeca.
Uma mini tabela para nao se confundir:
| Parametro | Exemplo | Significado |
|---|---|---|
|
|
“periodo de medicao” |
|
|
“quantas requisicoes cabem nesse periodo” |
|
|
“quanto ja foi usado” |
|
|
“quando a janela termina” |
Sim, fixed window nao e' perfeito (na borda da janela da para “enfiar” muitas requisicoes), mas ele e' otimo para entender o principio, testar a implementacao e nao afundar em detalhes.
Quem e' o “cliente” e como escolher a chave do limite
Rate limit quase sempre e' feito “per client”. Isso significa que precisamos entender: o que considerar como “um cliente”.
No mundo ideal, teriamos um identificador estavel do cliente: user id da autenticação, API key, token — algo consistente. Mas, no contexto de um servidor didatico, vamos fazer algo simples e claro: usar r.RemoteAddr.
RemoteAddr e' uma string no formato IP:port, por exemplo 203.0.113.10:54321. Isso nao e' perfeito (a porta muda, ha proxies e balanceadores), mas mostra bem a mecanica: existe uma requisicao de entrada, ela tem um “endereco”, e e' por ele que contamos.
Para deixar mais correto, vamos separar o IP da porta. Isso e' util para que o mesmo cliente nao pareca “cem clientes diferentes” so por causa de uma porta nova.
import (
"net"
"net/http"
)
func clientKey(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
Se SplitHostPort nao conseguir analisar o endereco, mantemos como esta. Em codigo didatico, isso e' uma estrategia normal: nao complicar a vida.
2. Requisicoes paralelas e por que precisamos de sync.Mutex
Um ponto que muitas vezes quebra a cabeca de iniciantes (e isso e' normal): voce pode imaginar uma logica ideal de rate limit no papel, mas, em um servidor HTTP real, ela comeca a se comportar de forma estranha se voce esquecer um detalhe — o servidor processa requisicoes em paralelo.
Em net/http, cada requisicao pode ser processada em sua propria goroutine. Isso significa que duas requisicoes podem entrar ao mesmo tempo no seu limiter e tentar atualizar a mesma estrutura de dados simultaneamente (normalmente um map).
E um map em Go nao gosta de escrita concorrente: isso nao e' um “resultado incorreto de vez em quando”, e sim um cenario real de runtime panic. Por isso precisamos proteger o estado compartilhado.
Teremos um map global, onde cada chave de cliente guarda o estado da janela. Para que as operacoes “leu → atualizou → gravou” sejam atomicas do ponto de vista logico, vamos protege-las com sync.Mutex.
Sim, em Go existe sync.Map, que e' seguro para acesso concorrente. Mas, para aprendizado e para um limiter simples, sync.Mutex + map e' mais simples e claro, e ainda e' uma habilidade basica que sera util o tempo todo. Vale a pena lembrar de sync.Map como uma ferramenta especializada para cenarios especificos.
Estruturas de dados
Vamos criar duas estruturas: uma para a “janela” e outra para o limiter em si.
windowHits guarda o limite da janela e o contador, e fixedWindowLimiter guarda as configuracoes + map + Mutex.
import "time"
type windowHits struct {
until time.Time
count int
}
import (
"sync"
"time"
)
type fixedWindowLimiter struct {
window time.Duration
limit int
mu sync.Mutex
hits map[string]windowHits
}
Observe: hits pode ser criado logo com make, ou pode ser criado de forma preguiçosa (na primeira requisicao). Criar de forma preguiçosa e' conveniente se o limiter for criado como &fixedWindowLimiter{...}.
Metodo Allow: o coracao do limiter
Precisamos de uma funcao que decida “deixa entrar ou nao”. Normalmente ela e' chamada de Allow.
A ideia principal e' que Allow seja o mais previsivel possivel. Voce passa a chave do cliente e o instante atual now, e ela devolve true/false.
Por que passamos now em vez de chamar time.Now() dentro? Para que o codigo fique mais controlavel e potencialmente mais facil de testar. Mesmo que voce ainda nao escreva testes, o habito de “tirar time.Now() de dentro” deixa o codigo mais organizado.
import "time"
func (l *fixedWindowLimiter) Allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.hits == nil {
l.hits = make(map[string]windowHits)
}
h := l.hits[key]
if now.After(h.until) {
h = windowHits{until: now.Add(l.window), count: 0}
}
if h.count >= l.limit {
l.hits[key] = h
return false
}
h.count++
l.hits[key] = h
return true
}
O que importa aqui:
- Pegamos Lock() imediatamente e colocamos defer Unlock() logo depois. Esse e' o seu “cinto de seguranca”: mesmo que depois apareca um return antecipado, Unlock() ainda vai acontecer.
- Inicializamos o map se ele estiver nil.
- Lemos o estado atual h := l.hits[key]. Se a chave nao existir, teremos o zero value (until = time.Time{}, count = 0) — e isso e' normal para nos.
- Se o tempo atual estiver “depois do fim da janela”, zeramos a janela: definimos um novo until e colocamos count = 0.
- Se count >= limit, negamos.
- Caso contrario, incrementamos count e permitimos.
4. Middleware: transformando o limiter em comportamento HTTP
Middleware e' uma funcao que recebe next http.Handler e retorna um novo http.Handler. Dentro dela, podemos fazer algo “antes”, “depois”, ou ate nem chamar next e encerrar a requisicao sozinhos (short-circuit).
Rate limit e' um short-circuit classico: se o limite foi excedido, respondemos imediatamente com 429 e encerramos.
Suponha que ja tenhamos uma funcao writeAPIError, que escreve um error envelope em JSON (um formato unico para erros).
import (
"net/http"
"time"
)
func rateLimitMW(l *fixedWindowLimiter) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := clientKey(r)
if !l.Allow(key, time.Now()) {
writeAPIError(w, http.StatusTooManyRequests,
"rate_limited", "rate limit exceeded")
return
}
next.ServeHTTP(w, r)
})
}
}
Aqui pegamos a chave do cliente, chamamos Allow, e se nao puder — respondemos com 429.
O importante e' que respondemos no mesmo contrato de error envelope que os demais erros. Isso torna a API previsivel: o cliente nao precisa adivinhar se o erro virara texto, HTML ou JSON.
Bônus: Retry-After sem matematicas desnecessarias
Podemos adicionar um pequeno detalhe que melhora a UX do cliente: o cabecalho Retry-After. Ele indica em quantos segundos vale a pena tentar novamente.
Para fixed window, isso e' especialmente simples: se sabemos until, podemos dizer aproximadamente ceil(until-now).
Para nao complicar o Allow, vamos criar um metodo separado RetryAfterSeconds, que le a janela atual sob o mesmo mutex.
import (
"math"
"time"
)
func (l *fixedWindowLimiter) RetryAfterSeconds(key string, now time.Time) int {
l.mu.Lock()
defer l.mu.Unlock()
h := l.hits[key]
if now.After(h.until) {
return 0
}
sec := h.until.Sub(now).Seconds()
return int(math.Ceil(sec))
}
E usar no middleware:
ra := l.RetryAfterSeconds(key, time.Now())
w.Header().Set("Retry-After", fmt.Sprintf("%d", ra))
writeAPIError(w, http.StatusTooManyRequests, "rate_limited", "rate limit exceeded")
Se quiser manter tudo bem minimalista, esse bonus pode ser omitido. Mas, como conceito, e' util: o servidor fica mais “educado”.
5. Integracao na aplicacao e ordem dos middlewares
Suponha que ja tenhamos:
- mux com rotas /api/v1/tasks...
- chain(...) para montar middlewares
- requestIDMW, logMW, authMW — escritos antes
Vamos criar o limiter na inicializacao e coloca-lo na cadeia.
import (
"net/http"
"time"
)
func buildHandler(mux *http.ServeMux, auth Middleware) http.Handler {
limiter := &fixedWindowLimiter{
window: time.Minute,
limit: 30,
}
return chain(mux,
requestIDMW,
logMW,
auth,
rateLimitMW(limiter),
)
}
A ordem e' parte da logica, nao apenas “beleza”.
Se colocarmos rate limit antes da auth, o limiter vai limitar ate mesmo quem nao tem chave nenhuma. As vezes isso e' normal (por exemplo, para nao deixarem bater em 401 milhares de requisicoes), mas muitas vezes e' mais razoavel limitar clientes ja autenticados.
Se o seu rate limit estiver amarrado a uma API key (o que normalmente faz bastante sentido), entao a auth deve vir antes, para que voce possa pegar a chave do cliente de X-API-Key (e nao do IP). Aqui estamos usando RemoteAddr, entao isso nao e' critico — apenas registramos uma estrutura saudavel.
Um pequeno esquema para sentir a ordem fisicamente:
flowchart TD
A[Requisicao chegou] --> B[request_idMW]
B --> C[logMW - inicio do timer]
C --> D[authMW]
D --> E[rateLimitMW]
E --> F[handler / mux]
F --> C2[logMW - log apos next]
6. Limitacoes da abordagem em memory
In-memory rate limiter e' um limiter que vive na memoria de um unico processo.
Isso significa que, se voce subir duas instancias do servidor atras de um balanceador, o limite sera aplicado separadamente em cada uma. O cliente podera “driblar” o limite se suas requisicoes se espalharem por diferentes instancias. Isso nao e' bug de codigo — e' uma caracteristica do design escolhido.
Outro detalhe: hits pode crescer com o tempo. Por exemplo, se chegarem muitos enderecos IP unicos (ou se voce escolher uma chave que muda com frequencia), o map vai armazenar entradas. Nesta versao didatica, nao fazemos uma limpeza complexa de “clientes antigos”. O maximo que fazemos e' reutilizar a entrada e zerar a janela quando ela expira.
Se quisermos fazer uma limpeza minima sem complicar, poderiamos apagar a entrada quando a janela expirar e o cliente voltar. Ou seja, em vez de “reset”, fazer “delete e criar de novo”. Mas isso nao da uma limpeza completa: o cliente que veio uma vez e nao voltou continuara no map. Para uma implementacao didatica, isso e' aceitavel.
E mais um detalhe: sim, podemos tentar sync/atomic para contadores, mas isso rapidamente vira uma mecanica mais complexa. Um “int atomico” nao resolve, porque ainda precisamos atualizar de forma coerente varios campos (o tempo da janela e o contador). Por isso, aqui ficamos com um Mutex simples e confiavel.
7. Erros tipicos ao implementar rate limit
Erro #1: alterar o map sem Mutex (“mas parece que funciona”).
Essa e' a historia mais comum e mais traiçoeira. Localmente, voce pode ter poucas requisicoes e tudo “parecer normal”. Depois, sob carga (ou simplesmente por coincidencia de tempo), voce recebe uma race condition e um potencial runtime panic. Em um servidor HTTP, as requisicoes sao paralelas por definicao, entao o map compartilhado precisa ser protegido.
Erro #2: usar Lock() e esquecer o Unlock() em um caminho.
Assim nascem os “deadlocks de iniciante”: o servidor para de responder porque alguem segurou o lock para sempre. A forma mais simples de quase eliminar isso e' colocar defer l.mu.Unlock() logo depois de Lock().
Erro #3: nao fazer short-circuit e chamar next depois do 429.
As vezes o codigo escreve 429, mas depois, por inercia, chama next.ServeHTTP(w, r). Como resultado, o handler de baixo tambem tenta escrever resposta, e voce recebe efeitos estranhos: “headers ja enviados”, “corpo duplicado da resposta” e outras coisas divertidas. Se o limite foi excedido, responda e return.
Erro #4: devolver 500 em vez de 429.
Rate limit nao e' “ah, quebramos”, e sim “voce esta fazendo isso com muita frequencia”. 500 significa “problema do lado do servidor”, enquanto 429 significa “politica de acesso/carga”. Com 429, o cliente pode desacelerar corretamente e tentar mais tarde; com 500, ele normalmente passa a pensar que o servidor morreu.
Erro #5: escolher uma chave de cliente instavel e se surpreender que o limite “nao funciona”.
Se a chave muda o tempo todo (por exemplo, voce usa RemoteAddr junto com a porta), o limiter vai achar que cada requisicao vem de “um cliente novo” e nunca havera excesso. Portanto, ou estabilize a chave (por exemplo, pelo IP), ou use um identificador mais significativo (API key / user id), quando voce tiver um.
GO TO FULL VERSION