Rate Limiter em Zig — Tutorial Passo a Passo

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

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

Continue aprendendo Zig

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