Rate Limiting em Zig: Token Bucket para APIs e CLIs

Rate limiting é uma daquelas peças pequenas que separam um serviço experimental de um serviço operável. Sem limite, um cliente com bug, um crawler agressivo ou uma tentativa simples de abuso consegue consumir CPU, memória, conexões, filas e cota de APIs externas. Com limite demais, você derruba usuários legítimos e transforma produção em loteria.

Em Zig, a boa notícia é que um rate limiter básico não precisa de framework pesado. Você pode modelar a regra como uma struct explícita, testar com relógio controlado e integrar o resultado ao seu servidor HTTP, worker, cliente de API ou ferramenta CLI. Este guia mostra uma implementação prática de token bucket em Zig, quando usar por IP ou por chave, como limpar estado antigo e quais cuidados entram antes de colocar isso em produção.

O texto complementa os guias de servidor HTTP em Zig para produção, webhooks com HMAC e idempotência, configuração segura com segredos e observabilidade em Zig. A mentalidade é a mesma: limite pequeno, comportamento previsível e falha fácil de investigar.

O que rate limiting deve proteger

Antes do algoritmo, defina o recurso protegido. Nem todo limite tem o mesmo objetivo.

CenárioRecurso protegidoChave comumResposta esperada
API públicaCPU, DB, fila, cota externaIP, usuário, token429 Too Many Requests
Webhookprocessamento e idempotênciaprovedor, conta, evento429 ou fila com backoff
CLI que chama APIcota do provedortoken localesperar ou abortar
Worker internobanco, fila, upstreamjob type, tenantrequeue com atraso
Loginconta e senhausuário + IPbloquear agressivamente

O erro comum é usar uma única regra global para tudo. Uma rota de busca pode aceitar mais requisições que uma rota de login. Um endpoint interno com autenticação forte pode ter limite diferente de uma rota pública. Um webhook assinado ainda pode precisar de limite porque assinatura prova origem, não garante volume saudável.

Por que token bucket

O algoritmo token bucket é simples: cada chave tem um balde com capacidade máxima. O tempo adiciona tokens ao balde até o máximo. Cada operação consome um token. Se não houver token suficiente, a chamada é limitada.

Esse modelo é bom para APIs porque permite rajadas pequenas sem perder controle. Por exemplo: capacidade 20 e recarga de 5 tokens por segundo. Um cliente pode fazer 20 chamadas rápidas, mas depois só sustenta 5 por segundo.

Comparando:

  • janela fixa é simples, mas permite rajada dupla na virada do minuto;
  • janela deslizante é mais justa, mas exige guardar mais histórico;
  • leaky bucket suaviza saída, bom para filas;
  • token bucket é um bom meio-termo para HTTP, CLIs e workers.

Modelo básico em Zig

Comece com uma struct que não sabe nada sobre HTTP. Ela só recebe o tempo atual e decide se uma ação pode passar.

const std = @import("std");

const TokenBucket = struct {
    capacity: f64,
    tokens: f64,
    refill_per_second: f64,
    last_refill_ns: u64,

    pub fn init(capacity: f64, refill_per_second: f64, now_ns: u64) TokenBucket {
        return .{
            .capacity = capacity,
            .tokens = capacity,
            .refill_per_second = refill_per_second,
            .last_refill_ns = now_ns,
        };
    }

    pub fn allow(self: *TokenBucket, now_ns: u64, cost: f64) bool {
        self.refill(now_ns);
        if (self.tokens < cost) return false;
        self.tokens -= cost;
        return true;
    }

    fn refill(self: *TokenBucket, now_ns: u64) void {
        if (now_ns <= self.last_refill_ns) return;

        const elapsed_ns = now_ns - self.last_refill_ns;
        const elapsed_s = @as(f64, @floatFromInt(elapsed_ns)) / std.time.ns_per_s;
        const added = elapsed_s * self.refill_per_second;

        self.tokens = @min(self.capacity, self.tokens + added);
        self.last_refill_ns = now_ns;
    }
};

Usar f64 deixa a recarga fracionária fácil de entender. Para sistemas extremamente sensíveis a determinismo, dá para trocar por inteiros escalados, como tokens_micros, mas comece simples. O ponto importante é receber now_ns por parâmetro. Isso facilita testes e evita espalhar chamadas de relógio pela regra de negócio.

Teste sem dormir

Rate limiter que depende de sleep nos testes vira lento e instável. Injete o tempo.

test "token bucket permite rajada e recarrega com o tempo" {
    var now: u64 = 0;
    var bucket = TokenBucket.init(3, 1, now);

    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(!bucket.allow(now, 1));

    now += std.time.ns_per_s;
    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(!bucket.allow(now, 1));

    now += 10 * std.time.ns_per_s;
    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(bucket.allow(now, 1));
    try std.testing.expect(!bucket.allow(now, 1));
}

Esse teste cobre três propriedades: rajada inicial, recarga proporcional ao tempo e teto de capacidade. Sem o teto, um cliente parado por uma hora acumularia uma rajada absurda.

Limite por chave

Em produção, o balde raramente é global. Você precisa limitar por IP, usuário, tenant, token de API, fila ou rota. Para isso, mantenha um mapa de chaves para baldes.

const RateLimiter = struct {
    allocator: std.mem.Allocator,
    buckets: std.StringHashMap(TokenBucket),
    capacity: f64,
    refill_per_second: f64,

    pub fn init(allocator: std.mem.Allocator, capacity: f64, refill_per_second: f64) RateLimiter {
        return .{
            .allocator = allocator,
            .buckets = std.StringHashMap(TokenBucket).init(allocator),
            .capacity = capacity,
            .refill_per_second = refill_per_second,
        };
    }

    pub fn deinit(self: *RateLimiter) void {
        var it = self.buckets.iterator();
        while (it.next()) |entry| self.allocator.free(entry.key_ptr.*);
        self.buckets.deinit();
    }

    pub fn allow(self: *RateLimiter, key: []const u8, now_ns: u64) !bool {
        const result = try self.buckets.getOrPut(key);
        if (!result.found_existing) {
            result.key_ptr.* = try self.allocator.dupe(u8, key);
            result.value_ptr.* = TokenBucket.init(self.capacity, self.refill_per_second, now_ns);
        }
        return result.value_ptr.allow(now_ns, 1);
    }
};

Esse exemplo duplica a chave para que o mapa não dependa da vida útil do buffer vindo da requisição. É uma decisão pequena, mas importante em Zig: ownership precisa estar claro. Se a chave vem de header, socket ou parser temporário, não guarde o slice sem copiar.

Resposta HTTP correta

Para HTTP, o limite deve falhar de forma explícita. O padrão é 429 Too Many Requests, idealmente com Retry-After quando você consegue estimar o tempo de espera.

fn handleRequest(limiter: *RateLimiter, client_ip: []const u8) !void {
    const now = @as(u64, @intCast(std.time.nanoTimestamp()));

    if (!try limiter.allow(client_ip, now)) {
        // No seu servidor real, escreva status 429 e headers.
        std.log.warn("rate_limited key={s}", .{client_ip});
        return error.RateLimited;
    }

    // Continue validação, autenticação e regra de negócio.
}

Não confunda rate limiting com autenticação. Faça autenticação quando a rota exige identidade. O rate limiter decide volume; a autenticação decide permissão. Em rotas públicas, IP pode ser suficiente para uma primeira defesa. Em rotas autenticadas, prefira chave por usuário ou token, e use IP como camada adicional contra abuso grosseiro.

Cuidado com proxies e IP real

Se o serviço Zig roda atrás de Nginx, Caddy, Traefik, Cloudflare ou load balancer, o IP direto da conexão pode ser o proxy. Usar esse IP como chave global limita todos os usuários juntos.

A solução é aceitar X-Forwarded-For ou CF-Connecting-IP somente de proxies confiáveis. Nunca confie cegamente em header enviado por qualquer cliente. Um atacante pode escolher o próprio X-Forwarded-For e contornar o limite se o servidor não validar a origem do proxy.

Um desenho seguro:

  1. o proxy remove headers de IP vindos do cliente;
  2. o proxy adiciona um header canônico;
  3. o serviço Zig só usa esse header quando a conexão vem do proxy esperado;
  4. logs registram a chave usada, sem incluir payload sensível.

Esse ponto encaixa com o guia de HTTP em produção: TLS, compressão e parte do rate limiting podem morar no proxy, mas a aplicação ainda precisa de limites específicos de negócio.

Limpeza de estado antigo

Um StringHashMap por IP cresce para sempre se você nunca limpar. Em serviços públicos, isso vira vetor de memória: basta enviar requisições de muitas chaves diferentes.

Adicione uma política de limpeza. A forma mais simples é varrer periodicamente e remover baldes cheios que não foram tocados há algum tempo. Para isso, guarde last_seen_ns junto do bucket.

const BucketState = struct {
    bucket: TokenBucket,
    last_seen_ns: u64,
};

Depois, em um timer ou a cada N requisições, remova entradas antigas. Não precisa varrer em toda chamada. Para um processo pequeno, uma varredura a cada minuto já costuma bastar. Para tráfego alto, limite o número de entradas visitadas por ciclo ou use shard por rota/tenant.

Também defina um teto de chaves. Se o mapa passar de, por exemplo, 100 mil entradas em um serviço pequeno, é melhor ativar um fallback conservador do que deixar o processo morrer por falta de memória.

Concorrência e threads

O exemplo acima assume acesso single-threaded. Se o servidor atende requisições em várias threads, proteja o mapa com std.Thread.Mutex, use sharding por hash ou mantenha rate limit no proxy e use a aplicação só para limites autenticados de baixo volume.

const SharedRateLimiter = struct {
    mutex: std.Thread.Mutex = .{},
    limiter: RateLimiter,

    pub fn allow(self: *SharedRateLimiter, key: []const u8, now_ns: u64) !bool {
        self.mutex.lock();
        defer self.mutex.unlock();
        return self.limiter.allow(key, now_ns);
    }
};

Um mutex global é aceitável para APIs pequenas e ferramentas internas. Se ele aparecer em perfil de performance, só então complique: shards, buckets por rota, algoritmo lock-free ou delegar limite volumétrico para a borda. Otimize a partir de métrica, não de ansiedade.

Configuração segura

Não compile limites mágicos no binário sem documentação. Exponha configuração clara:

  • capacidade do balde;
  • tokens por segundo;
  • teto de chaves;
  • janela de limpeza;
  • modo de falha quando o limitador não consegue alocar memória;
  • rotas isentas, se existirem.

Para um serviço, esses valores podem vir de variáveis de ambiente validadas no boot. Para uma CLI, podem vir de arquivo local ou XDG, como no guia de configuração para CLI em Zig. O importante é não aceitar limite inválido silenciosamente. RATE_LIMIT_PER_SECOND=abc deve derrubar o boot ou usar default explícito com log claro, nunca virar zero por acidente.

Observabilidade mínima

Rate limiter sem observabilidade vira superstição. Registre métricas simples:

  • requisições permitidas por rota;
  • requisições limitadas por rota;
  • número de chaves ativas;
  • tamanho máximo do mapa;
  • erros de alocação;
  • latência de decisão, se o limiter ficar no caminho quente.

Em logs, não grave payload de request, token, cookie ou email. Para chaves sensíveis, registre hash curto ou tipo lógico, como tenant_id ou api_key_fingerprint. O guia de OpenTelemetry em Zig ajuda quando você precisa conectar limite, rota, usuário e upstream sem vazar segredo.

Quando usar Redis ou proxy

O limitador em memória é simples e rápido, mas tem limites claros. Ele não compartilha estado entre réplicas. Se você roda três pods, cada um permite sua própria cota. Isso pode ser aceitável para defesa leve, mas não para billing, login sensível ou cota contratual.

Use proxy, Redis ou gateway quando:

  • há múltiplas réplicas e o limite precisa ser global;
  • o custo de abuso é alto;
  • o limite participa de plano comercial;
  • você precisa de regras por região, rota, usuário e método;
  • a equipe já opera uma camada de API gateway.

Mesmo nesses casos, o código Zig ainda pode manter limites locais para proteger recursos internos. Camadas não se excluem: borda segura volume bruto, aplicação segura regra de negócio.

Checklist de produção

Antes de publicar, confira:

  • algoritmo e parâmetros estão documentados;
  • testes cobrem rajada, recarga, teto e negação;
  • chaves copiadas têm ownership claro;
  • existe limpeza de estado antigo;
  • há teto de chaves ativas;
  • acesso concorrente é protegido;
  • 429 tem resposta previsível;
  • IP real só vem de proxy confiável;
  • logs não vazam token, cookie, payload ou email;
  • métricas mostram permitido, limitado e tamanho do mapa;
  • limites críticos não dependem apenas de memória local se há múltiplas réplicas.

Rate limiting bom não tenta resolver todos os problemas de segurança. Ele reduz blast radius, deixa abuso visível e compra tempo para o restante do sistema responder. Em Zig, essa peça combina com a filosofia da linguagem: estado pequeno, contratos explícitos e custo operacional que aparece no código.

Próximos passos

Se você está colocando uma API Zig no ar, leia também servidor HTTP em produção, webhooks com HMAC e idempotência e filas e workers em background. Para comparar com outro ecossistema do portfólio, veja como o tema aparece em rate limiting em Go: o algoritmo é parecido, mas Zig deixa ownership, alocação e concorrência mais explícitos.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.