Rate limit API LLM Python: por que backoff não resolve (2026)
Às 3h da manhã um script que processa 2 mil prompts em lote trava inteiro com erro 429, mesmo tendo um decorator de retry com exponential backoff colado de um gist qualquer. O backoff estava funcionando exatamente como projetado. O problema é que ele nunca foi desenhado pra evitar o erro, só pra reagir a ele depois que já aconteceu. Isso é rate limit em API LLM, e o retry em Python que todo tutorial ensina resolve metade do problema. A outra metade ninguém conta.
O 429 que aparece no meio do lote: por que o backoff que você copiou do Stack Overflow não segura
O cenário é chato de tão comum: você tem uma lista de 2 mil, 5 mil, 20 mil prompts pra rodar contra a API da OpenAI, Anthropic ou qualquer provedor que cobra por token. Sobe um loop com asyncio.gather, dispara tudo mais ou menos ao mesmo tempo (ou em lotes de 50, 100), cada chamada tem um decorator de retry com backoff exponencial, e você dorme tranquilo achando que resolveu.
Três horas depois o script travou. Não porque o backoff falhou, ele funcionou exatamente como devia: esperou 1s, depois 2s, depois 4s, depois 8s antes de tentar de novo. O problema é que, se as 100 chamadas concorrentes batem no limite de requisições por minuto (RPM) ou tokens por minuto (TPM) da sua conta, todas recebem 429 praticamente ao mesmo tempo, todas entram em backoff ao mesmo tempo e, quando o tempo de espera expira, todas tentam de novo ao mesmo tempo, recriando o mesmo estouro que causou o erro originalmente. Isso tem nome, efeito manada (thundering herd), e é um problema clássico de sistemas distribuídos, não exclusivo de IA.
O retry trata o sintoma. A causa é que nada no seu código sabia, antes de disparar a requisição, que a conta já estava perto do limite.
Exponential backoff resolve rate limit ou só empurra a fila pra frente?
Vale separar duas coisas que o senso comum mistura. Retry com backoff é uma estratégia de recuperação de falha: assume que o erro vai acontecer e define como reagir depois que acontece. Controle de concorrência é prevenção pura, define quantas requisições simultâneas (ou quantos tokens por minuto) você permite disparar, pra nunca estourar o limite em primeiro lugar.
Backoff com jitter (aquela variação aleatória no tempo de espera pra evitar que todo mundo tente de novo no mesmo milissegundo) reduz a chance de colisão, mas não elimina o problema estrutural. Se você está disparando mais requisições por minuto do que sua conta permite, vai continuar tomando 429 em ciclos, só que espaçados. É gestão reativa de fila, não controle de vazão.
Bibliotecas como tenacity ou backoff em Python fazem muito bem o trabalho a que se propõem: tentar de novo com inteligência quando algo falha. O problema é usar isso como única camada de defesa contra rate limit, quando na prática ele deveria ser a rede de segurança, não a estratégia principal. É como confiar só no cinto de segurança e nunca olhar pro painel do carro avisando que o tanque tá quase seco.
Dá pra prever o limite antes do erro? Os headers que 90% do código ignora
Aqui está o ponto que a maioria dos tutoriais de backoff simplesmente não menciona: a API já avisa antes de estourar. A documentação oficial de rate limits da OpenAI (platform.openai.com/docs/guides/rate-limits) descreve que toda resposta, inclusive as que deram certo, vem com headers informando seu consumo atual:
x-ratelimit-limit-requestsex-ratelimit-remaining-requests: quantas requisições você ainda pode fazer na janela atual.x-ratelimit-limit-tokensex-ratelimit-remaining-tokens: o mesmo, mas em tokens.x-ratelimit-reset-requestsex-ratelimit-reset-tokens: quando a janela reseta.retry-after: quando você já tomou 429, quanto tempo esperar.
A informação de que você está perto do limite chega em respostas bem anteriores à que finalmente estoura, não só no 429 que derruba o lote. Um dev que só percebe que estava perto do limite depois do primeiro 429 simplesmente nunca leu esses headers nas respostas anteriores, porque a maioria dos SDKs e tutoriais não expõe isso de forma óbvia. Quem copia um snippet de retry do Stack Overflow geralmente só olha pro except e ignora o response.headers.
Ler esses headers e ajustar a velocidade de disparo em tempo real é a diferença entre dirigir olhando o marcador de combustível e dirigir até o carro apagar no meio da pista.
Rate limiting proativo: token bucket e semaphore antes do request sair, não depois que ele falha
A solução prática de engenharia aqui chama-se controle de concorrência proativo. Duas ferramentas resolvem isso bem: asyncio.Semaphore (limita quantas chamadas rodam ao mesmo tempo) e um token bucket (limita quantas chamadas, ou tokens, passam por unidade de tempo). O padrão token bucket é descrito, entre outros lugares, na documentação de rate limiting da AWS API Gateway, e é o mesmo conceito usado internamente pelos provedores de LLM pra aplicar os limites na própria API.
Um exemplo simplificado de semaphore limitando concorrência em Python:
import asyncio
import httpx
MAX_CONCORRENCIA = 10 # ajuste conforme seu tier de conta
sem = asyncio.Semaphore(MAX_CONCORRENCIA)
async def chamar_api(client, prompt):
async with sem:
resp = await client.post("https://api.openai.com/v1/chat/completions", json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": prompt}]
})
restantes = resp.headers.get("x-ratelimit-remaining-requests")
if restantes and int(restantes) < 5:
await asyncio.sleep(2) # desacelera antes de bater no limite
return resp
Isso já evita boa parte do estouro, porque nunca deixa mais de 10 chamadas simultâneas saírem, e ainda reage à informação que o header está dando, desacelerando antes do 429, não depois. Um token bucket rudimentar pode ser feito com uma fila e um asyncio.sleep calculado a partir do x-ratelimit-reset-requests, redistribuindo as chamadas ao longo da janela em vez de disparar tudo de uma vez e esperar o erro acontecer.
Retry é seguro quando a chamada não é idempotente?
Esse é o ponto que o conselho padrão de “implementa backoff e tá resolvido” ignora de forma perigosa: retry cego assume que repetir a chamada é inofensivo. Isso é verdade pra uma consulta de leitura. Não é verdade quando a chamada de LLM está acoplada a function calling que já disparou um efeito colateral.
Um pipeline comum funciona assim: o modelo recebe um prompt, decide chamar uma function tipo criar_pedido ou enviar_notificacao, seu código executa essa function, e só depois o modelo formata a resposta final pro usuário. Se a requisição de formatação final falhar com 429 e seu backoff tentar de novo do zero (reenviando o histórico da conversa inteiro, incluindo a parte que já disparou a function call), existe risco real de o modelo decidir chamar a function de novo, duplicando a ação. Isso não é reexecutar código, é reexecutar efeito colateral: duplicar um pedido, mandar duas notificações, criar dois registros no banco.
A prática de engenharia de confiabilidade pra isso tem nome: idempotência. A OpenAI, por exemplo, documenta suporte a idempotency keys em requisições pra evitar que um retry de rede duplique o processamento do lado do servidor. Mas isso resolve duplicação na chamada da API em si, não resolve duplicação de efeito colateral causada pelo seu próprio código reexecutando uma function que já rodou. Pra isso, a saída é desenhar suas functions com checagem de idempotência própria (um ID de operação que você verifica antes de criar o registro de novo) ou separar claramente etapas com efeito colateral das etapas que são só geração de texto, aplicando retry irrestrito só na segunda.
O código final: backoff reativo e controle proativo trabalhando juntos
A arquitetura que de fato resolve rate limit em lote não escolhe entre backoff e controle de concorrência. Usa os dois, em papéis diferentes:
import asyncio
import random
import httpx
MAX_CONCORRENCIA = 8
sem = asyncio.Semaphore(MAX_CONCORRENCIA)
async def chamar_com_seguranca(client, prompt, tentativa=0):
async with sem:
resp = await client.post(URL, json=payload(prompt))
if resp.status_code == 429:
if tentativa >= 5:
raise RuntimeError("limite de tentativas excedido")
espera = resp.headers.get("retry-after")
delay = float(espera) if espera else (2 ** tentativa + random.random())
await asyncio.sleep(delay)
return await chamar_com_seguranca(client, prompt, tentativa + 1)
restantes = int(resp.headers.get("x-ratelimit-remaining-requests", 999))
if restantes < 5:
await asyncio.sleep(1.5)
return resp
O semaphore controla quantas chamadas saem ao mesmo tempo. A leitura do header desacelera antes do erro. O backoff com retry-after só entra em ação quando, mesmo com tudo isso, o 429 ainda aparece, porque outro processo na mesma conta também está consumindo cota, ou porque o limite mudou. Backoff vira rede de segurança, não plano A.
Checklist antes de rodar um lote grande contra API de LLM
Antes de soltar um script contra milhares de prompts, passa por essa lista:
- Você sabe qual é o RPM e TPM do seu tier de conta na documentação do provedor?
- Seu código lê
x-ratelimit-remaining-requests/x-ratelimit-remaining-tokens, ou só trata oexcept? - Existe limite explícito de concorrência (semaphore, fila, pool) ou tudo dispara de uma vez com
gather? - Alguma das suas chamadas aciona function calling, escrita em banco, webhook ou envio de notificação? Se sim, essa etapa tem proteção contra duplicação?
- O backoff usa o valor de
retry-afterda própria resposta, ou um número fixo chutado? - Você tem logging suficiente pra saber, depois do fato, quantas vezes cada prompt foi retentado?
Minha opinião sobre esse papo de “só colar backoff e resolver”
Acho que esse conselho virou clichê de tutorial porque é fácil de copiar e dá a sensação de ter feito engenharia de verdade. Mas rate limit em produção é um problema de controle de fluxo, não de tratamento de exceção. Tratar só a exceção é o equivalente a instalar um extintor de incêndio e nunca checar o disjuntor: funciona até o dia em que não funciona, geralmente de madrugada, geralmente no lote mais importante do mês.
Ler os headers que a API já entrega de graça devia ser o primeiro passo de qualquer integração em lote, não um detalhe de nicho pra quem “já é avançado”. E se sua function calling tem efeito colateral, trate retry como risco de duplicação até provar o contrário, não como reexecução inofensiva de código.
Fontes consultadas
- OpenAI, Rate limits, link
- Amazon Web Services (AWS), Throttle requests to your REST APIs for better throughput in API Gateway, link
- AWS Well-Architected Framework, REL05-BP02 Throttle requests, link
- GitHub, jd/tenacity: Retrying library for Python, link
- AWS Compute Blog, Building well-architected serverless applications: Regulating inbound request rates, link