Resposta rápida: para criar um rate limiter em Zig, mantenha um token bucket por cliente. Cada requisição consome um token; o tempo repõe tokens até uma capacidade máxima; quando o saldo fica abaixo de 1, a operação é negada. Essa estratégia permite rajadas curtas sem perder o controle da taxa média. Neste projeto, você implementará a regra, o mapa de clientes, testes e uma CLI de simulação.
Este tutorial é voltado ao aprendizado e mantém tudo em memória. Para levar o mesmo padrão a uma API real, consulte também o guia de rate limiting em Zig para produção, que cobre HTTP 429, proxies, concorrência, limpeza de estado e múltiplas réplicas.
O que vamos construir
Nosso rate limiter vai:
- Implementar o algoritmo Token Bucket com reposição contínua
- Suportar múltiplos clientes com limites independentes
- Permitir configuração de taxa (tokens/segundo) e capacidade máxima
- Fornecer estatísticas de uso por cliente
- Incluir interface interativa para simulação
Como o token bucket se comporta
Considere um bucket com capacidade 10 e recarga de 2 tokens por segundo. O cliente pode fazer 10 operações de uma vez. Depois de esvaziá-lo, sustenta aproximadamente 2 operações por segundo; se ficar ocioso, o saldo volta até 10, mas nunca ultrapassa esse teto.
| Parâmetro | Exemplo | Efeito |
|---|---|---|
| Capacidade | 10 tokens | Rajada máxima aceita |
| Recarga | 2 tokens/s | Taxa sustentada após a rajada |
| Custo | 1 token | Consumo de cada requisição |
| Chave | usuário, token ou IP | Quem possui um bucket independente |
Token bucket é uma boa escolha quando pequenas rajadas são aceitáveis. A tabela resume as trocas entre os algoritmos mais comuns de rate limiting:
| Aspecto | Token Bucket | Leaky Bucket | Janela Fixa | Janela Deslizante |
|---|---|---|---|---|
| Aceita rajadas (burst)? | Sim, até a capacidade | Não, ritmo constante | Sim, até 2× o limite na virada | Parcial |
| Complexidade | Baixa | Média (fila) | Muito baixa | Média/alta |
| Memória por cliente | Contador + timestamp | Fila + timestamp | 2 contadores | Vários contadores ou log |
| Efeito sobre a média | Suaviza com pico inicial | Força ritmo constante | Degraus na virada | Suaviza bem |
| Típico em | AWS, Stripe, GitHub | NGINX, filas de saída | Sistemas simples | APIs premium |
Por que este projeto?
Implementar o algoritmo ensina controle de taxa, gerenciamento de estado por cliente, ownership de strings e aritmética de tempo. Em Zig, as alocações e a vida útil das chaves ficam explícitas, o que torna o projeto um bom exercício intermediário de programação de sistemas.
Pré-requisitos
- Zig instalado e disponível como
zigno terminal - Familiaridade com structs, métodos e tratamento de erros
- Conhecimento básico de
std.StringHashMap
Compatibilidade: os exemplos usam a API de I/O da série 0.13 (
std.io.getStdOut,std.io.getStdIn). A partir do Zig 0.15 a interface de I/O mudou (std.fs.File.stdout()e o novoIo), e oGeneralPurposeAllocatorfoi renomeado paraDebugAllocator. Executezig versione consulte a documentação do release instalado antes de misturar snippets de versões diferentes.
Passo 1: estrutura do projeto
mkdir rate-limiter
cd rate-limiter
zig init
Passo 2: o algoritmo token bucket
O balde começa cheio. Cada requisição consome um token, e o tempo repõe tokens a uma taxa fixa. Se o saldo estiver abaixo do custo da operação, a requisição é negada. A fórmula de recarga é:
tokens = min(capacidade, tokens + segundos_decorridos × taxa_por_segundo)
Receber o relógio por parâmetro deixaria o núcleo ainda mais fácil de testar; aqui usamos std.time.nanoTimestamp() diretamente para manter a CLI curta. O guia de produção mostra a alternativa com relógio injetado.
const std = @import("std");
const time = std.time;
const io = std.io;
const mem = std.mem;
const fmt = std.fmt;
/// Representa um bucket (balde) de tokens para um cliente.
/// O bucket reabastece tokens continuamente a uma taxa fixa.
const TokenBucket = struct {
tokens: f64, // quantidade atual de tokens (fracionário para precisão)
capacidade: f64, // máximo de tokens que o bucket comporta
taxa_por_segundo: f64, // tokens adicionados por segundo
ultimo_reabastecimento: i64, // timestamp em nanosegundos
total_permitido: u64, // contador de requisições permitidas
total_negado: u64, // contador de requisições negadas
const Self = @This();
/// Cria um novo bucket cheio.
pub fn init(capacidade: f64, taxa_por_segundo: f64) Self {
return .{
.tokens = capacidade, // começa cheio
.capacidade = capacidade,
.taxa_por_segundo = taxa_por_segundo,
.ultimo_reabastecimento = time.nanoTimestamp(),
.total_permitido = 0,
.total_negado = 0,
};
}
/// Reabastece tokens baseado no tempo decorrido.
/// Esta é a parte "contínua" — tokens são adicionados
/// proporcionalmente ao tempo que passou.
fn reabastecer(self: *Self) void {
const agora = time.nanoTimestamp();
const delta_ns = agora - self.ultimo_reabastecimento;
if (delta_ns <= 0) return;
const delta_seg: f64 = @as(f64, @floatFromInt(delta_ns)) / 1_000_000_000.0;
const novos_tokens = delta_seg * self.taxa_por_segundo;
self.tokens = @min(self.capacidade, self.tokens + novos_tokens);
self.ultimo_reabastecimento = agora;
}
/// Tenta consumir um token. Retorna true se permitido.
pub fn permitir(self: *Self) bool {
self.reabastecer();
if (self.tokens >= 1.0) {
self.tokens -= 1.0;
self.total_permitido += 1;
return true;
}
self.total_negado += 1;
return false;
}
/// Tenta consumir N tokens de uma vez (para operações em batch).
pub fn permitirN(self: *Self, n: f64) bool {
self.reabastecer();
if (self.tokens >= n) {
self.tokens -= n;
self.total_permitido += @intFromFloat(n);
return true;
}
self.total_negado += 1;
return false;
}
/// Retorna quantos tokens estão disponíveis agora.
pub fn tokensDisponiveis(self: *Self) f64 {
self.reabastecer();
return self.tokens;
}
/// Retorna o tempo estimado (em ms) até o próximo token disponível.
pub fn tempoAteProximoToken(self: *Self) u64 {
self.reabastecer();
if (self.tokens >= 1.0) return 0;
const deficit = 1.0 - self.tokens;
const tempo_seg = deficit / self.taxa_por_segundo;
return @intFromFloat(tempo_seg * 1000.0);
}
};
Decisão de design: Usamos f64 para os tokens porque o reabastecimento é contínuo e produz frações. Isso dá precisão muito maior do que incrementar tokens inteiros em intervalos fixos.
Passo 3: Gerenciador Multi-Cliente
/// Gerencia rate limiters para múltiplos clientes.
/// Cada cliente é identificado por um ID string (IP, API key, etc).
const RateLimiterManager = struct {
buckets: std.StringHashMap(TokenBucket),
capacidade_padrao: f64,
taxa_padrao: f64,
allocator: mem.Allocator,
const Self = @This();
pub fn init(
allocator: mem.Allocator,
capacidade_padrao: f64,
taxa_padrao: f64,
) Self {
return .{
.buckets = std.StringHashMap(TokenBucket).init(allocator),
.capacidade_padrao = capacidade_padrao,
.taxa_padrao = taxa_padrao,
.allocator = allocator,
};
}
pub fn deinit(self: *Self) void {
var it = self.buckets.keyIterator();
while (it.next()) |key| {
self.allocator.free(key.*);
}
self.buckets.deinit();
}
/// Verifica se um cliente pode fazer uma requisição.
/// Cria o bucket automaticamente na primeira requisição.
pub fn verificar(self: *Self, cliente_id: []const u8) !bool {
if (self.buckets.getPtr(cliente_id)) |bucket| {
return bucket.permitir();
}
// Novo cliente: criar bucket
const id_copia = try self.allocator.dupe(u8, cliente_id);
errdefer self.allocator.free(id_copia);
var bucket = TokenBucket.init(self.capacidade_padrao, self.taxa_padrao);
_ = bucket.permitir(); // consome o primeiro token
try self.buckets.put(id_copia, bucket);
return true;
}
/// Retorna estatísticas de um cliente.
pub fn estatisticas(self: *Self, cliente_id: []const u8) ?struct { permitido: u64, negado: u64, tokens: f64 } {
if (self.buckets.getPtr(cliente_id)) |bucket| {
return .{
.permitido = bucket.total_permitido,
.negado = bucket.total_negado,
.tokens = bucket.tokensDisponiveis(),
};
}
return null;
}
/// Retorna o número de clientes rastreados.
pub fn totalClientes(self: *const Self) usize {
return self.buckets.count();
}
/// Remove explicitamente um cliente e libera a cópia da chave.
pub fn removerCliente(self: *Self, cliente_id: []const u8) bool {
const entry = self.buckets.fetchRemove(cliente_id) orelse return false;
self.allocator.free(entry.key);
return true;
}
};
Passo 4: Interface CLI Interativa
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const stdout = io.getStdOut().writer();
const stdin = io.getStdIn().reader();
// Configuração: 10 tokens de capacidade, 2 tokens/segundo de recarga
var manager = RateLimiterManager.init(allocator, 10.0, 2.0);
defer manager.deinit();
try stdout.print(
\\
\\ ==========================================
\\ RATE LIMITER - Token Bucket - Zig
\\ ==========================================
\\ Config: 10 tokens max, 2 tokens/seg
\\
\\ Comandos:
\\ req <cliente> - Simula requisicao
\\ burst <cliente> N - Simula N requisicoes
\\ stats <cliente> - Ver estatisticas
\\ info - Info geral
\\ sair - Encerrar
\\ ==========================================
\\
\\
, .{});
var buf: [256]u8 = undefined;
while (true) {
try stdout.print("limiter> ", .{});
const linha = stdin.readUntilDelimiter(&buf, '\n') catch |err| {
if (err == error.EndOfStream) break;
return err;
};
const trimmed = mem.trim(u8, linha, " \t\r\n");
if (trimmed.len == 0) continue;
var it = mem.splitScalar(u8, trimmed, ' ');
const cmd = it.first();
if (mem.eql(u8, cmd, "req")) {
const cliente = it.next() orelse {
try stdout.print("Uso: req <cliente_id>\n", .{});
continue;
};
const permitido = try manager.verificar(cliente);
if (permitido) {
try stdout.print(" PERMITIDO - Requisicao de '{s}' aceita\n", .{cliente});
} else {
if (manager.buckets.getPtr(cliente)) |bucket| {
const espera = bucket.tempoAteProximoToken();
try stdout.print(" NEGADO - '{s}' excedeu limite. Tente em {d}ms\n", .{ cliente, espera });
}
}
} else if (mem.eql(u8, cmd, "burst")) {
const cliente = it.next() orelse {
try stdout.print("Uso: burst <cliente_id> <quantidade>\n", .{});
continue;
};
const n_str = it.next() orelse "5";
const n = fmt.parseInt(u32, n_str, 10) catch 5;
var aceitas: u32 = 0;
var negadas: u32 = 0;
for (0..n) |_| {
const permitido = try manager.verificar(cliente);
if (permitido) aceitas += 1 else negadas += 1;
}
try stdout.print(" Burst de {d} para '{s}': {d} aceitas, {d} negadas\n", .{
n, cliente, aceitas, negadas,
});
} else if (mem.eql(u8, cmd, "stats")) {
const cliente = it.next() orelse {
try stdout.print("Uso: stats <cliente_id>\n", .{});
continue;
};
if (manager.estatisticas(cliente)) |stats| {
try stdout.print(
\\ --- {s} ---
\\ Permitidas: {d}
\\ Negadas: {d}
\\ Tokens: {d:.1}
\\
, .{ cliente, stats.permitido, stats.negado, stats.tokens });
} else {
try stdout.print(" Cliente '{s}' nao encontrado\n", .{cliente});
}
} else if (mem.eql(u8, cmd, "info")) {
try stdout.print(
\\ --- Info Geral ---
\\ Clientes rastreados: {d}
\\ Capacidade padrao: {d:.0} tokens
\\ Taxa de recarga: {d:.1} tokens/seg
\\
, .{ manager.totalClientes(), manager.capacidade_padrao, manager.taxa_padrao });
} else if (mem.eql(u8, cmd, "sair")) {
break;
} else {
try stdout.print("Comando desconhecido: {s}\n", .{cmd});
}
}
}
Testes
test "token bucket - permitir dentro do limite" {
var bucket = TokenBucket.init(5.0, 1.0);
// Deve permitir 5 requisições (bucket começa cheio)
for (0..5) |_| {
try std.testing.expect(bucket.permitir());
}
}
test "token bucket - negar quando vazio" {
var bucket = TokenBucket.init(2.0, 0.1);
_ = bucket.permitir();
_ = bucket.permitir();
// Bucket vazio, deve negar
try std.testing.expect(!bucket.permitir());
}
test "token bucket - contadores" {
var bucket = TokenBucket.init(3.0, 1.0);
_ = bucket.permitir(); // aceita
_ = bucket.permitir(); // aceita
_ = bucket.permitir(); // aceita
_ = bucket.permitir(); // nega
try std.testing.expectEqual(@as(u64, 3), bucket.total_permitido);
try std.testing.expectEqual(@as(u64, 1), bucket.total_negado);
}
test "token bucket - tempo ate proximo" {
var bucket = TokenBucket.init(1.0, 10.0);
_ = bucket.permitir();
// Após consumir, deve haver algum tempo de espera
const espera = bucket.tempoAteProximoToken();
try std.testing.expect(espera <= 100); // no máximo 100ms para 10 tokens/seg
}
Compilando e executando
# Compilar e executar
zig build run
# Sessão de exemplo:
# limiter> req usuario1
# PERMITIDO - Requisicao de 'usuario1' aceita
# limiter> burst usuario1 15
# Burst de 15 para 'usuario1': 9 aceitas, 6 negadas
# limiter> stats usuario1
# --- usuario1 ---
# Permitidas: 10
# Negadas: 6
# Tokens: 0.0
# Rodar testes
zig build test
Como integrar a decisão em uma API
A função verificar deve ficar separada do framework HTTP. O handler transforma a decisão em protocolo: continua o processamento quando recebe true e responde 429 Too Many Requests quando recebe false. Se você conhece o tempo até o próximo token, pode enviá-lo no header Retry-After em segundos.
Não confie automaticamente em X-Forwarded-For para escolher o bucket por IP. Atrás de Nginx, Caddy, Cloudflare ou outro proxy, aceite o header somente de uma origem confiável e normalize a cadeia de endereços. Caso contrário, um cliente pode forjar chaves e contornar o limite. Veja o tutorial de IP real e proxies confiáveis em Zig.
Este exemplo também é single-threaded. Em um servidor concorrente, proteja o mapa com mutex ou sharding. Em múltiplas réplicas, cada processo terá seu próprio saldo; use um gateway ou armazenamento compartilhado quando a cota precisar ser global.
Erros comuns
- Não limitar o tamanho do mapa: chaves novas podem consumir memória indefinidamente.
- Guardar slices temporários: copie a chave antes de mantê-la no
StringHashMap. - Usar relógio de parede sem cuidado: saltos de horário não devem criar tokens extras.
- Tratar autenticação como rate limiting: identidade e volume são controles diferentes.
- Aplicar um limite global a todas as rotas: login, busca e health check têm custos distintos.
- Testar com
sleep: prefira um relógio injetado para testes rápidos e determinísticos.
Conceitos aprendidos
- Algoritmo Token Bucket para rate limiting
- Aritmética de tempo com
std.time.nanoTimestamp - HashMap para gerenciamento de estado por cliente
- Números de ponto flutuante para precisão na taxa de reposição
- Gerenciamento de memória com ownership de strings
Próximos passos
- Evolua o exemplo com relógio injetado e testes determinísticos.
- Adicione
last_seene remova buckets ociosos periodicamente. - Proteja o mapa para acesso concorrente com
std.Thread.Mutex. - Integre a resposta
429a um servidor HTTP em Zig. - Compare o limite local com a abordagem distribuída usando Redis em Zig.
- Consulte o checklist completo de rate limiting em Zig para produção.