Rate Limiter em Python Puro: O Guardião Que Impede Sua Automação de Se Tornar o Ataque DDoS Que Você Sempre Temeu

Semana passada, um script meu de web scraping — daqueles que roda “de boa” a cada 5 minutos — foi bloqueado permanentemente pela API que eu usava há dois anos. Motivo? Eu tinha esquecido de limitar a taxa de requisições numa nova thread que adicionei. O resultado: 847 requisições em 12 segundos. A API me respondeu com um 429 Too Many Requests e, na sequência, um 403 Forbidden eterno.

Perdi o acesso. Perdi os dados. Perdi a paciência.

Se você já escreveu qualquer automação que faz requisições HTTP — scraper, monitor, bot, integrador — você está a um while True mal pensado de se tornar o atacante da sua própria infraestrutura.

Hoje vou te mostrar como construir um Rate Limiter robusto em Python puro, sem pip install nada, com duas estratégias complementares: Token Bucket e Sliding Window Log. Código testado, pronto pra copiar, com decorador pra aplicar em qualquer função em 3 segundos.

Por que Rate Limiter local se já tem na API?

Ótima pergunta. Três razões:

1. A API pode não ter limiter configurado corretamente — e quando tem, o erro 429 vem acompanhado de delay arbitrário que quebra seu pipeline.

2. Você pode ter múltiplos scripts atacando o mesmo endpoint — o limiter da API vê “você”, não sabe que são 4 processos diferentes.

3. Proteger recursos locais também importa — banco SQLite, arquivos de log, threads de trabalho. Tudo tem limite.

A filosofia aqui é defesa em profundidade: você controla o fluxo na origem, não depende do servidor te bloquear.

Estratégia 1: Token Bucket — O Clássico Confiável

O Token Bucket funciona como uma caixa d’água com torneira: tokens entram em taxa constante, cada requisição consome um token, e se a caixa esvaziar, você espera.

import time
import threading


class TokenBucket:
    """
    Rate Limiter baseado em Token Bucket.
    
    - capacity: máximo de tokens acumuláveis
    - refill_rate: tokens adicionados por segundo
    - Exemplo: capacity=10, refill_rate=2 → até 10 req/s burst,
      sustentando 2 req/s continuamente
    """

    def __init__(self, capacity: float, refill_rate: float):
        if capacity <= 0 or refill_rate <= 0:
            raise ValueError("capacity e refill_rate devem ser positivos")
        
        self.capacity = capacity
        self.refill_rate = refill_rate
        self._tokens = capacity  # começa cheio
        self._last_refill = time.monotonic()
        self._lock = threading.Lock()

    def _refill(self):
        """Recarrega tokens baseado no tempo elapsed."""
        now = time.monotonic()
        elapsed = now - self._last_refill
        tokens_to_add = elapsed * self.refill_rate
        self._tokens = min(self.capacity, self._tokens + tokens_to_add)
        self._last_refill = now

    def acquire(self, tokens: float = 1.0, block: bool = True) -> bool:
        """Consome tokens. Bloqueia se block=True e não houver tokens."""
        with self._lock:
            self._refill()
            
            if self._tokens >= tokens:
                self._tokens -= tokens
                return True
            
            if not block:
                return False
            
            deficit = tokens - self._tokens
            wait_time = deficit / self.refill_rate
        
        time.sleep(wait_time)
        
        with self._lock:
            self._refill()
            self._tokens -= tokens
            return True

    @property
    def available_tokens(self) -> float:
        with self._lock:
            self._refill()
            return self._tokens

Testando na prática:

# Configuração: burst de 5 requisições, sustentando 2 por segundo
limiter = TokenBucket(capacity=5, refill_rate=2)

for i in range(8):
    start = time.monotonic()
    limiter.acquire()
    elapsed = time.monotonic() - start
    print(f"Req {i+1}: {'imediata' if elapsed < 0.01 else f'esperou {elapsed:.2f}s'}")

Saída:

Req 1: imediata
Req 2: imediata
Req 3: imediata
Req 4: imediata
Req 5: imediata
Req 6: esperou 0.50s
Req 7: esperou 0.50s
Req 8: esperou 0.50s

As 5 primeiras passaram de imediato (burst). A partir da sexta, o limiter segura o ritmo em 2/segundo — exatamente o refill_rate.

Estratégia 2: Sliding Window Log — Precisão Cirúrgica

O Token Bucket permite burst. Se você precisa de controle absoluto — “no máximo N requisições em qualquer janela de T segundos, sem exceção” — o Sliding Window Log é o caminho.

import time
import threading
from collections import deque


class SlidingWindowLog:
    """
    Rate Limiter baseado em Sliding Window Log.
    Garante no máximo max_requests em qualquer janela de window_seconds.
    """

    def __init__(self, max_requests: int, window_seconds: float):
        if max_requests <= 0 or window_seconds <= 0:
            raise ValueError("Parâmetros devem ser positivos")
        
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        self._log = deque()
        self._lock = threading.Lock()

    def _cleanup(self):
        cutoff = time.monotonic() - self.window_seconds
        while self._log and self._log[0] < cutoff:
            self._log.popleft()

    def acquire(self, block: bool = True) -> bool:
        while True:
            with self._lock:
                self._cleanup()
                
                if len(self._log) < self.max_requests:
                    self._log.append(time.monotonic())
                    return True
                
                if not block:
                    return False
                
                oldest = self._log[0]
                wait_time = (oldest + self.window_seconds) - time.monotonic()
            
            if wait_time > 0:
                time.sleep(wait_time)

    @property
    def current_count(self) -> int:
        with self._lock:
            self._cleanup()
            return len(self._log)

O Decorador Mágico: Proteja Qualquer Função em 3 Linhas

Aqui é onde a coisa fica elegante. Um decorador que aplica rate limiting transparente:

import functools


def rate_limit(limiter):
    """Decorador que aplica rate limiting a qualquer função."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            limiter.acquire()
            return func(*args, **kwargs)
        return wrapper
    return decorator


# === Exemplo de uso real ===
bucket = TokenBucket(capacity=3, refill_rate=1)

@rate_limit(bucket)
def fetch_page(url: str) -> str:
    import urllib.request
    with urllib.request.urlopen(url, timeout=10) as resp:
        return resp.read().decode()


# Múltiplos limiters empilhados (global + por-endpoint)
global_bucket = TokenBucket(capacity=10, refill_rate=5)
endpoint_sliding = SlidingWindowLog(max_requests=30, window_seconds=60)

@rate_limit(global_bucket)
@rate_limit(endpoint_sliding)
def search_api(query: str) -> dict:
    url = f"https://api.example.com/search?q={query}"
    import urllib.request, json
    with urllib.request.urlopen(url, timeout=10) as resp:
        return json.loads(resp.read())

Sim, você pode empilhar decoradores. O limite mais restritivo sempre vence. É exatamente assim que APIs reais como GitHub e Stripe funcionam internamente.

Rate Limiter para Recursos Locais (não só HTTP)

O mesmo pattern protege qualquer recurso finito:

# Proteger writes em SQLite (evita SQLITE_BUSY)
db_write_limiter = TokenBucket(capacity=1, refill_rate=10)

@rate_limit(db_write_limiter)
def save_record(record: dict):
    with sqlite3.connect("data.db", timeout=5) as conn:
        conn.execute(
            "INSERT INTO records (data) VALUES (?)",
            (json.dumps(record),)
        )

# Proteger CPU em processamento de imagens
cpu_limiter = SlidingWindowLog(max_requests=5, window_seconds=1)

@rate_limit(cpu_limiter)
def process_image(path: str):
    # processamento pesado aqui
    pass

# Proteger disco (evita I/O saturado)
io_limiter = TokenBucket(capacity=50 * 1024 * 1024, refill_rate=10 * 1024 * 1024)
# 50MB burst, 10MB/s sustentável

def write_large_file(data: bytes, path: str):
    chunk_size = 1024 * 1024  # 1MB chunks
    for i in range(0, len(data), chunk_size):
        io_limiter.acquire(tokens=chunk_size)
        with open(path, "ab") as f:
            f.write(data[i:i + chunk_size])

Rate Limiter Compartilhado entre Processos (via arquivo)

Para múltiplos processos Python atacando o mesmo recurso:

import os, json, time, fcntl


class SharedRateLimiter:
    """Rate Limiter compartilhado entre processos via arquivo lock."""

    def __init__(self, lock_path: str, max_requests: int, window_seconds: float):
        self.lock_path = lock_path
        self.max_requests = max_requests
        self.window_seconds = window_seconds
        
        if not os.path.exists(lock_path):
            with open(lock_path, "w") as f:
                json.dump([], f)

    def acquire(self):
        while True:
            with open(self.lock_path, "r+") as f:
                fcntl.flock(f.fileno(), fcntl.LOCK_EX)
                try:
                    timestamps = json.loads(f.read())
                    cutoff = time.monotonic() - self.window_seconds
                    timestamps = [t for t in timestamps if t >= cutoff]
                    
                    if len(timestamps) < self.max_requests:
                        timestamps.append(time.monotonic())
                        f.seek(0)
                        f.truncate()
                        json.dump(timestamps, f)
                        return
                    else:
                        oldest = min(timestamps)
                        wait = (oldest + self.window_seconds) - time.monotonic()
                finally:
                    fcntl.flock(f.fileno(), fcntl.LOCK_UN)
            
            if wait > 0:
                time.sleep(wait)

🔥 O Perrengue do Olivetto: 847 Requisições Que Mataram Minha Conta

Era uma sexta-feira à noite. Eu tinha um scraper rodando há meses, coletando dados de uma API pública de preços. Funcionava lindo: cron job a cada 5 minutos, 3 requisições por execução.

Aí resolvi “otimizar” adicionando threads paralelas para buscar mais endpoints. Quatro threads, cada uma fazendo 3 requisições. Total esperado: 12 requisições a cada 5 minutos.

O que eu esqueci: as threads não tinham rate limiter individual. E o threading.Thread do Python não garante ordem de execução. Resultado: as 4 threads dispararam quase simultaneamente, cada uma fez suas 3 requisições, e o retry automático (que eu também tinha adicionado sem pensar) multiplicou tudo quando a API começou a responder devagar.

847 requisições em 12 segundos.

A API me bloqueou. Não foi um 429 temporário — foi ban permanente. Perdi 8 meses de dados históricos porque o endpoint de exportação também foi bloqueado.

Desde esse dia, toda função que toca rede passa pelo decorador @rate_limit. Sem exceção. Sem “ah, é só uma requisição”. O custo de adicionar o decorador é zero. O custo de esquecer pode ser meses de trabalho.

Qual Estratégia Escolher?

API com rate limit conhecido → Sliding Window — precisão absoluta, respeita a janela da API.

Automação geral / scraping → Token Bucket — burst controlado + throughput estável.

Múltiplos endpoints, um servidor → Token Bucket global + Sliding por endpoint — empilhamento cobre ambos os limites.

Recursos locais (DB, disco, CPU) → Token Bucket — simples, eficiente, permite burst quando faz sentido.

Multi-processo → SharedRateLimiter (arquivo) — coordenação via lock de arquivo.

E agora?

Qual foi o pior ban que você já levou por esquecer rate limiter? Ou pior: qual automação sua está rodando agora sem nenhum controle de taxa e você só vai descobrir quando quebrar?

Cola nos comentários que eu quero saber — e se tiver dúvida sobre qual estratégia usar no seu caso, manda o cenário que eu respondo.

Código testado em Python 3.11+ | Zero dependências externas | Pronto pra produção (com os devidos testes, claro)

Posts Similares

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *