Rate Limiter em Zig: Token Bucket Passo a Passo

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âmetroExemploEfeito
Capacidade10 tokensRajada máxima aceita
Recarga2 tokens/sTaxa sustentada após a rajada
Custo1 tokenConsumo de cada requisição
Chaveusuário, token ou IPQuem 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:

AspectoToken BucketLeaky BucketJanela FixaJanela Deslizante
Aceita rajadas (burst)?Sim, até a capacidadeNão, ritmo constanteSim, até 2× o limite na viradaParcial
ComplexidadeBaixaMédia (fila)Muito baixaMédia/alta
Memória por clienteContador + timestampFila + timestamp2 contadoresVários contadores ou log
Efeito sobre a médiaSuaviza com pico inicialForça ritmo constanteDegraus na viradaSuaviza bem
Típico emAWS, Stripe, GitHubNGINX, filas de saídaSistemas simplesAPIs 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

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 novo Io), e o GeneralPurposeAllocator foi renomeado para DebugAllocator. Execute zig version e 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_seen e remova buckets ociosos periodicamente.
  • Proteja o mapa para acesso concorrente com std.Thread.Mutex.
  • Integre a resposta 429 a 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.

Curso planejado · Zig na prática

Já programa? Construa a sua primeira CLI em Zig.

Conheça o curso planejado: fundamentos, erros, memória, testes e build no mesmo projeto. R$ 299 na pré-venda, pagamento único.

R$ 299Pagamento único na pré-venda Conhecer o curso

Continue aprendendo Zig

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