Rate Limiter Local com Python e SQLite: O Freio de Mão Que Sua Automação Precisa (Antes do 429)
Tem uma automação que consome API externa — weather, CRM, gateway de pagamento, sei lá — e de repente ela para de funcionar. Sem explicação. Você verifica os logs e lá está: 429 Too Many Requests.
A primeira reação é sempre a mesma: “deve ser problema deles.” Spoiler: não é. O problema é que sua automação dispara requisições como se o endpoint fosse infinito. E não é. Toda API séria tem limite, e ignorar isso é como acelerar em fila de pedágio — funciona até a multa chegar.
O que eu fiz — e o que vou te mostrar aqui — é construir um rate limiter local com Python e SQLite. Nada de Redis. Nada de serviço gerenciado. Uma classe de 180 linhas que roda no mesmo processo da sua automação, conta requisições em uma tabela SQLite, e bloqueia antes que o 429 apareça.
Se você tem automações que consomem API e quer parar de ser pego de surpresa por throttling, esse post é seu.

O que é rate limiting (e por que sua automação precisa de um)
Rate limiting é o mecanismo que limita quantas requisições um cliente pode fazer a uma API dentro de uma janela de tempo. Pode ser 100 requisições por minuto, 1.000 por hora, ou qualquer coisa que o provedor decidir.
Quando você ultrapassa esse limite, a API retorna HTTP 429. E aqui está o problema prático: sua automação não espera. Ela continua enviando, acumula erros, e dependendo do sistema, pode até perder dados no processo.
Um rate limiter local resolve isso com uma premissa simples: antes de enviar, verifica se pode enviar. Se não pode, espera. Quando pode, dispara.
Por que local? Por que não Redis?
Redis é ótimo. Sério. Mas se sua automação roda em um VPS de R$30/mês ou num Raspberry Pi na sua estante, adicionar Redis é overkill. SQLite já está no Python. Já está no seu sistema. E para rate limiting local, ele é mais que suficiente.
A diferença prática:
- Redis: processo extra, configuração, monitoramento, mais um ponto de falha
- SQLite: arquivo, zero configuração, embedded no processo, backup trivial (copia o arquivo)
Arquitetura do Rate Limiter
O sistema funciona assim:
- Tabela de registro: cada requisição é logada com timestamp e identificador do endpoint
- Consulta de janela: antes de enviar, conta quantas requisições foram feitas na janela (ex: último minuto)
- Decisão: se abaixo do limite, envia. Se acima, calcula quanto esperar e dorme
- Cleanup: registros antigos são removidos para manter a tabela leve
É simples porque rate limiting não precisa ser complexo. Precisa ser confiável.

Implementação Completa
1. A classe base — RateLimiter
Aqui está o esqueleto funcional. Copia, cola, adapta:
import sqlite3
import time
from contextlib import contextmanager
from dataclasses import dataclass
@dataclass
class RateLimitConfig:
max_requests: int # Máximo de requisições na janela
window_seconds: float # Tamanho da janela em segundos
endpoint: str = "default"
class RateLimiter:
def __init__(self, db_path: str = "rate_limiter.db"):
self.db_path = db_path
self._init_db()
def _get_conn(self):
conn = sqlite3.connect(self.db_path)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def _init_db(self):
with self._get_conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS request_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
timestamp REAL NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_endpoint_time
ON request_log(endpoint, timestamp)
""")
@contextmanager
def _connection(self):
conn = self._get_conn()
try:
yield conn
conn.commit()
finally:
conn.close()
def _count_requests(self, endpoint: str, window: float) -> int:
cutoff = time.time() - window
with self._connection() as conn:
cursor = conn.execute(
"SELECT COUNT(*) FROM request_log WHERE endpoint = ? AND timestamp > ?",
(endpoint, cutoff)
)
return cursor.fetchone()[0]
def _log_request(self, endpoint: str):
with self._connection() as conn:
conn.execute(
"INSERT INTO request_log (endpoint, timestamp) VALUES (?, ?)",
(endpoint, time.time())
)
def _cleanup_old(self, endpoint: str, max_age: float = 86400):
cutoff = time.time() - max_age
with self._connection() as conn:
conn.execute(
"DELETE FROM request_log WHERE endpoint = ? AND timestamp < ?",
(endpoint, cutoff)
)
def wait_if_needed(self, config: RateLimitConfig) -> float:
"""Espera se necessário. Retorna tempo de espera (0 se imediato)."""
self._cleanup_old(config.endpoint)
current = self._count_requests(config.endpoint, config.window_seconds)
if current >= config.max_requests:
cutoff = time.time() - config.window_seconds
with self._connection() as conn:
cursor = conn.execute(
"SELECT MIN(timestamp) FROM request_log WHERE endpoint = ? AND timestamp > ?",
(config.endpoint, cutoff)
)
oldest = cursor.fetchone()[0]
if oldest:
wait_time = (oldest + config.window_seconds) - time.time()
wait_time = max(wait_time, 0.1)
time.sleep(wait_time)
return wait_time
return 0.0
def acquire(self, config: RateLimitConfig):
"""Adquire permissão para enviar requisição."""
self.wait_if_needed(config)
self._log_request(config.endpoint)
2. O wrapper HTTP — integrando com requests
import requests
from typing import Optional
class RateLimitedHTTP:
def __init__(self, rate_limiter: RateLimiter):
self.limiter = rate_limiter
self.session = requests.Session()
def get(self, url: str, config: RateLimitConfig, **kwargs) -> requests.Response:
self.limiter.acquire(config)
return self.session.get(url, **kwargs)
def post(self, url: str, config: RateLimitConfig, **kwargs) -> requests.Response:
self.limiter.acquire(config)
return self.session.post(url, **kwargs)
3. Uso real — consumindo uma API com limite
# Configuração: 5 requisições por minuto para a API de weather
weather_config = RateLimitConfig(
max_requests=5,
window_seconds=60,
endpoint="api.weather"
)
crm_config = RateLimitConfig(
max_requests=10,
window_seconds=60,
endpoint="api.crm"
)
http = RateLimitedHTTP(RateLimiter())
for cidade in ["São Paulo", "Rio", "Curitiba", "Salvador", "Recife", "Manaus"]:
resp = http.get(
f"https://api.weather.com/forecast?city={cidade}",
config=weather_config,
timeout=10
)
print(f"{cidade}: {resp.status_code}")
Token Bucket: A Alternativa Mais Flexível
O rate limiter de janela fixa acima funciona, mas tem uma limitação: ele é binário. Ou passa, ou espera. O Token Bucket é mais suave — você acumula “tokens” ao longo do tempo e gasta um por requisição. Se não tem token, espera.
A vantagem? Permite bursts controlados. Se ficou 10 minutos sem usar a API, tem 10 minutos de tokens acumulados. Pode disparar várias requisições seguidas até esgotar o bucket.
class TokenBucketLimiter:
def __init__(self, db_path: str = "token_bucket.db"):
self.db_path = db_path
self._init_db()
def _init_db(self):
with sqlite3.connect(self.db_path) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS buckets (
endpoint TEXT PRIMARY KEY,
tokens REAL NOT NULL,
last_refill REAL NOT NULL,
max_tokens REAL NOT NULL,
refill_rate REAL NOT NULL
)
""")
def _get_bucket(self, endpoint, max_tokens, refill_rate):
now = time.time()
with sqlite3.connect(self.db_path) as conn:
row = conn.execute(
"SELECT tokens, last_refill, max_tokens, refill_rate FROM buckets WHERE endpoint = ?",
(endpoint,)
).fetchone()
if not row:
conn.execute(
"INSERT INTO buckets VALUES (?, ?, ?, ?, ?)",
(endpoint, max_tokens, now, max_tokens, refill_rate)
)
return max_tokens
tokens, last_refill, max_tok, rate = row
elapsed = now - last_refill
new_tokens = min(max_tok, tokens + elapsed * rate)
conn.execute(
"UPDATE buckets SET tokens = ?, last_refill = ? WHERE endpoint = ?",
(new_tokens, now, endpoint)
)
return new_tokens
def acquire(self, endpoint, max_tokens, refill_rate, tokens_needed=1.0):
current = self._get_bucket(endpoint, max_tokens, refill_rate)
if current >= tokens_needed:
new_tokens = current - tokens_needed
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE buckets SET tokens = ? WHERE endpoint = ?",
(new_tokens, endpoint)
)
return 0.0
deficit = tokens_needed - current
wait_time = deficit / refill_rate
time.sleep(wait_time)
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"UPDATE buckets SET tokens = 0, last_refill = ? WHERE endpoint = ?",
(time.time(), endpoint)
)
return wait_time
Múltiplos Endpoints, Um Só Banco
Num sistema real, você consome várias APIs. Cada uma com limite diferente. O rate limiter precisa gerenciar todas simultaneamente.
class MultiEndpointLimiter:
def __init__(self, db_path="multi_limiter.db"):
self.limiter = RateLimiter(db_path)
self.configs = {}
def register(self, config):
self.configs[config.endpoint] = config
def acquire(self, endpoint):
config = self.configs.get(endpoint)
if not config:
raise ValueError(f"Endpoint '{endpoint}' não registrado")
self.limiter.acquire(config)
def status(self):
"""Mostra uso atual de cada endpoint."""
status = {}
for name, config in self.configs.items():
count = self.limiter._count_requests(name, config.window_seconds)
status[name] = {
"usado": count,
"limite": config.max_requests,
"janela": f"{config.window_seconds}s",
"disponivel": max(0, config.max_requests - count)
}
return status
limiter = MultiEndpointLimiter()
limiter.register(RateLimitConfig(5, 60, "api.weather"))
limiter.register(RateLimitConfig(10, 60, "api.crm"))
limiter.register(RateLimitConfig(100, 3600, "api.email"))
print(limiter.status())

Monitoramento: Sabendo Quando Está Perto do Limite
Um rate limiter sem monitoramento é como velocímetro sem ponteiro — funciona, mas você não sabe a velocidade. Aqui está um decorator simples que loga quando você está acima de 80% do limite:
import logging
import functools
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("rate_limiter")
def warn_near_limit(limiter, config):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
count = limiter._count_requests(config.endpoint, config.window_seconds)
usage_pct = (count / config.max_requests) * 100
if usage_pct >= 80:
logger.warning(
f"[RATE LIMIT] {config.endpoint}: {count}/{config.max_requests} "
f"({usage_pct:.0f}% usado na janela de {config.window_seconds}s)"
)
return func(*args, **kwargs)
return wrapper
return decorator
@warn_near_limit(limiter.limiter, limiter.configs["api.weather"])
def buscar_clima(cidade):
return limiter.limiter.acquire(limiter.configs["api.weather"])
Quando o 429 Acontece Mesmo Assim
Sim, pode acontecer. Se outra instância da sua automação (ou outro serviço) consome a mesma API, seu rate limiter local não sabe. A estratégia é dupla:
- Rate limiter conservador: configure 70-80% do limite real. A margem cobre consumo concorrente.
- Fallback no 429: quando receber 429, extraia o header
Retry-Aftere respeite-o:
def resilient_get(url, config, limiter, session=None):
session = session or requests.Session()
max_retries = 3
for attempt in range(max_retries):
limiter.acquire(config)
resp = session.get(url, timeout=10)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", 60))
logger.warning(f"429 recebido. Esperando {retry_after}s antes de retry")
time.sleep(retry_after)
continue
resp.raise_for_status()
return resp
raise RuntimeError(f"429 após {max_retries} tentativas para {url}")
Resumo das Escolhas
| Decisão | Por quê |
|---|---|
| SQLite em vez de Redis | Zero dependência extra, já embutido no Python |
| Janela deslizante | Mais preciso que janela fixa — não reseta no limite |
| Token Bucket como alternativa | Permite bursts controlados quando a API ficou ociosa |
| 70-80% do limite real | Margem para consumo concorrente de outros serviços |
| PRAGMA journal_mode=WAL | Performance de escrita sem bloquear leituras simultâneas |
Links Relacionados
Se esse tipo de controle te interessa, dá uma olhada nos posts anteriores:
- Automação Resiliente com Python: Retries, Lockfile e Idempotência
- Context Managers Python para Automações
- Todos os posts de Lab da Garra
A Pergunta Que Fica
Quantas vezes sua automação quebrou por causa de rate limiting que você não viu chegando? Se a resposta for “mais de uma vez”, esse rate limiter é pra você. Se nunca aconteceu, parabéns — mas vai acontecer. E quando acontecer, você já sabe onde voltar.
Qual automação você quer que eu desmonte e reconstrua no próximo post? Manda a ideia. Se for problema real, vira artigo.
