429 em API de IA: fila com concorrência e orçamento de tokens
Promise.all sobre quinhentos itens não é paralelismo, é um ataque. Como respeitar os dois limites que essas APIs impõem sem serializar tudo.
O erro tratado aqui
RateLimitError: 429 Rate limit reached. Limit: 30000 tokens/min. Try again in 1.28sAmbiente testado
- Node 22
- TypeScript 5.8
- API de LLM compatível com OpenAI
Contexto: o que estava rodando
Uma rotina noturna que classifica tickets de suporte. Ela pega os tickets do dia, manda cada um para um modelo de linguagem pedindo categoria e urgência, e grava o resultado.
O código era honesto e errado:
const tickets = await prisma.ticket.findMany({ where: { classificado: false } });
// 500 chamadas disparadas no mesmo milissegundo.
const resultados = await Promise.all(tickets.map(classificarTicket));O erro
RateLimitError: 429 Rate limit reached for model `gpt-class-mini` in organization
org-8fJ2k on tokens per min (TPM): Limit 30000, Used 29984, Requested 1180.
Please try again in 1.28s. Visit https://... to learn more.
status: 429,
headers: {
'x-ratelimit-limit-requests': '500',
'x-ratelimit-remaining-requests': '441',
'x-ratelimit-limit-tokens': '30000',
'x-ratelimit-remaining-tokens': '16',
'x-ratelimit-reset-tokens': '1.28s',
'retry-after': '2'
}De 500 tickets, 63 foram classificados e 437 falharam.
Diagnóstico
São dois limites, e o que estoura é o segundo
Repare nos cabeçalhos: remaining-requests ainda tinha 441 disponíveis. O
que zerou foi remaining-tokens, com 16 sobrando de 30.000.
APIs de modelo de linguagem cobram por duas dimensões:
| Limite | Unidade | O que consome |
|---|---|---|
| RPM | requisições por minuto | Cada chamada, independentemente do tamanho |
| TPM | tokens por minuto | Prompt + resposta, somados |
Controlar concorrência resolve o RPM. Não resolve o TPM: dez chamadas com prompt grande consomem mais orçamento que cem chamadas curtas. Qualquer controle que conte só requisições vai estourar tokens.
Promise.all não é controle de nada
Promise.all não limita concorrência — ele apenas espera todo mundo terminar.
As 500 chamadas partem juntas. As primeiras dezenas passam, o orçamento do minuto
acaba, e todo o resto volta 429 de uma vez.
O retry ingênuo piora
A primeira correção foi repetir os que falharam. Como todos falharam no mesmo instante e todos esperaram o mesmo tempo, as 437 tentativas partiram juntas de novo. Segunda rodada de 429.
Sem espalhamento, retry em lote reproduz exatamente a rajada que causou o problema.
A solução
-
Limitar concorrência de verdade.
Um pool de trabalhadores mantém N chamadas em voo e vai puxando o próximo item conforme cada uma termina — sem a barreira que o
Promise.allimpõe.src/lib/concorrencia.tsexport async function mapaComLimite<T, R>( itens: readonly T[], limite: number, fn: (item: T, indice: number) => Promise<R>, ): Promise<PromiseSettledResult<R>[]> { const resultados = new Array<PromiseSettledResult<R>>(itens.length); let proximo = 0; async function trabalhador() { while (proximo < itens.length) { const i = proximo++; try { resultados[i] = { status: 'fulfilled', value: await fn(itens[i]!, i) }; } catch (reason) { // Um item que falha não pode derrubar os outros 499. resultados[i] = { status: 'rejected', reason }; } } } await Promise.all( Array.from({ length: Math.min(limite, itens.length) }, trabalhador), ); return resultados; } -
Controlar o orçamento de tokens numa janela deslizante.
Este é o pedaço que falta na maioria das implementações. Antes de disparar uma chamada, o trabalhador reserva os tokens que ela vai custar; se não houver orçamento, espera.
src/lib/orcamento-tokens.tsexport class OrcamentoTokens { private gastos: Array<{ em: number; tokens: number }> = []; constructor( private readonly limitePorMinuto: number, // Margem: a estimativa de tokens nunca é exata, e o teto é rígido. private readonly folga = 0.85, ) {} private disponivel(agora: number) { this.gastos = this.gastos.filter((g) => agora - g.em < 60_000); const usado = this.gastos.reduce((s, g) => s + g.tokens, 0); return this.limitePorMinuto * this.folga - usado; } async reservar(tokens: number) { for (;;) { const agora = Date.now(); if (this.disponivel(agora) >= tokens) { this.gastos.push({ em: agora, tokens }); return; } // Espera o gasto mais antigo sair da janela, e não um valor fixo. const maisAntigo = this.gastos[0]!.em; const esperar = Math.max(50, 60_000 - (agora - maisAntigo)); await new Promise((r) => setTimeout(r, esperar)); } } /** Corrige a reserva com o consumo real informado pela resposta. */ ajustar(estimado: number, real: number) { const ultimo = this.gastos.at(-1); if (ultimo) ultimo.tokens += Math.max(0, real - estimado); } } -
Estimar tokens antes de chamar.
Não precisa ser exato — precisa errar para cima:
src/lib/tokens.ts/** * Aproximação suficiente para orçamento: cerca de 4 caracteres por token em * português. Some a resposta máxima esperada, que também consome cota. */ export function estimarTokens(prompt: string, maxResposta: number) { return Math.ceil(prompt.length / 3.5) + maxResposta; }Uso
3.5em vez de4de propósito: acentos e palavras longas do português rendem mais tokens que o inglês, e subestimar é o erro que causa 429. -
Obedecer o
Retry-Afterquando ele vier.Quando a API diz quanto esperar, esse número vale mais que qualquer backoff calculado:
src/lib/chamar-modelo.tsimport { OrcamentoTokens } from './orcamento-tokens'; import { estimarTokens } from './tokens'; const orcamento = new OrcamentoTokens(30_000); export async function chamarModelo(prompt: string, maxResposta = 200) { const estimado = estimarTokens(prompt, maxResposta); for (let tentativa = 0; tentativa < 5; tentativa++) { await orcamento.reservar(estimado); const res = await fetch(`${process.env.LLM_URL}/chat/completions`, { method: 'POST', headers: { authorization: `Bearer ${process.env.LLM_KEY}`, 'content-type': 'application/json', }, body: JSON.stringify({ model: 'gpt-class-mini', max_tokens: maxResposta, messages: [{ role: 'user', content: prompt }], }), signal: AbortSignal.timeout(30_000), }); if (res.status === 429) { const espera = Number(res.headers.get('retry-after') ?? 2) * 1000; await res.body?.cancel(); // Jitter para as chamadas concorrentes não voltarem todas juntas. await new Promise((r) => setTimeout(r, espera + Math.random() * 500)); continue; } if (!res.ok) { await res.body?.cancel(); throw new Error(`modelo respondeu ${res.status}`); } const dados = await res.json(); orcamento.ajustar(estimado, dados.usage?.total_tokens ?? estimado); return dados.choices[0].message.content as string; } throw new Error('rate limit persistente após 5 tentativas'); } -
Agrupar itens quando o formato permitir.
src/jobs/classificar.tsimport { mapaComLimite } from '@/lib/concorrencia'; import { chamarModelo } from '@/lib/chamar-modelo'; function emGrupos<T>(itens: T[], tamanho: number): T[][] { return Array.from({ length: Math.ceil(itens.length / tamanho) }, (_, i) => itens.slice(i * tamanho, (i + 1) * tamanho), ); } const grupos = emGrupos(tickets, 10); // 500 tickets viram 50 chamadas, com 4 em voo por vez. const resultados = await mapaComLimite(grupos, 4, async (grupo) => { const prompt = montarPromptDeLote(grupo); const resposta = await chamarModelo(prompt, 40 * grupo.length); return JSON.parse(resposta) as Classificacao[]; }); const falhas = resultados.filter((r) => r.status === 'rejected'); console.log(`${resultados.length - falhas.length}/${grupos.length} grupos ok`);
Como confirmar que resolveu
Zero 429 numa execução completa. Este é o critério direto, e vale rodar sobre o volume real, não sobre uma amostra.
Acompanhe o consumo restante em vez de esperar o erro:
console.log('cota', {
requisicoes: res.headers.get('x-ratelimit-remaining-requests'),
tokens: res.headers.get('x-ratelimit-remaining-tokens'),
});
Se remaining-tokens nunca chega perto de zero, dá para aumentar a folga. Se
raspa o fundo, reduza.
Compare custo, não só tempo. O agrupamento é a mudança que aparece na fatura:
antes 500 chamadas · 1.284.000 tokens · 12 min · 437 erros 429
depois 50 chamadas · 389.000 tokens · 6 min · 0 errosMenos tokens porque a instrução do sistema foi enviada 50 vezes em vez de 500 — o assunto rende bem além do rate limit.
Rode duas execuções ao mesmo tempo. Se dois processos compartilham a mesma chave de API, cada um mantém o próprio orçamento em memória e a soma estoura o limite real — o que revela o próximo problema.
Armadilhas que sobram depois disso
Orçamento em memória não é compartilhado. Duas instâncias serverless, dois contêineres ou dois jobs simultâneos multiplicam o consumo. Para controle real, o contador precisa morar em Redis ou numa tabela do Postgres.
Grupo grande demais estraga a qualidade. Vinte itens numa chamada pioram a precisão e aumentam a chance de a resposta vir com JSON malformado. Dez costuma ser o limite confortável — teste antes de subir.
Resposta em JSON precisa de validação. Modelo de linguagem devolve texto, não estrutura garantida. Valide com zod e trate a falha como item a reprocessar, não como exceção fatal do lote.
Existe limite de fila também. Algumas APIs limitam requisições em espera, não só por minuto. Concorrência muito alta pode gerar erro mesmo com orçamento de tokens sobrando.
Job longo em serverless esbarra em outro teto. Seis minutos de execução não cabem numa função com limite de sessenta segundos — o processamento precisa ser fatiado.
Continue por aqui
APIs & Resiliência
Fila no Postgres com SKIP LOCKED, ou serviço de fila: qual escolher
Dois workers pegando a mesma tarefa é o defeito clássico de fila caseira. A cláusula que resolve, e quando vale trocar o banco por um serviço.
APIs & Resiliência
Cortando o custo de API de LLM: medir, rotear e cachear
A fatura triplicou sem o uso triplicar. Como atribuir custo por funcionalidade e quais alavancas realmente rendem, na ordem de retorno.
APIs & Resiliência
Retry com backoff, jitter e circuit breaker em TypeScript
Repetir na hora piora a queda que você está tentando contornar. As quatro regras que separam um retry útil de um ataque contra o seu próprio fornecedor.