Rate Limiter em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir um rate limiter usando o algoritmo Token Bucket em Zig. Rate limiters são componentes essenciais em APIs e serviços web para prevenir abuso e garantir distribuição justa de recursos.
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
Por Que Este Projeto?
O Token Bucket é o algoritmo de rate limiting mais usado em produção (utilizado pela AWS, Stripe, GitHub). Implementá-lo nos ensina sobre controle de taxa, gerenciamento de estado por cliente e aritmética de tempo. Em Zig, podemos fazer isso sem overhead de runtime.
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com structs e métodos
- Conhecimento básico de HashMap
Passo 1: Estrutura do Projeto
mkdir rate-limiter
cd rate-limiter
zig init
Passo 2: O Algoritmo Token Bucket
O Token Bucket funciona assim: um “balde” começa cheio de tokens. Cada requisição consome um token. Tokens são repostos a uma taxa fixa. Se o balde está vazio, a requisição é negada.
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,
};
}
/// Reabestece 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 += 1;
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 clientes inativos (com bucket cheio há muito tempo).
pub fn limparInativos(self: *Self) void {
var it = self.buckets.iterator();
var chaves_remover = std.ArrayList([]const u8).init(self.allocator);
defer chaves_remover.deinit();
while (it.next()) |entry| {
if (entry.value_ptr.total_permitido == 0 and entry.value_ptr.total_negado == 0) {
chaves_remover.append(entry.key_ptr.*) catch continue;
}
}
for (chaves_remover.items) |chave| {
_ = self.buckets.remove(chave);
self.allocator.free(chave);
}
}
};
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
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
- Explore HashMap na stdlib
- Aprenda sobre threads para rate limiting concorrente
- Veja o projeto Load Balancer que usa rate limiting
- Construa o URL Shortener que precisa de rate limiting