Rate limit, timeout e retry: o que quebra quando seu app de IA ganha usuários
Erro 429, sobrecarga do provedor e função que estoura o tempo derrubam app de IA no primeiro pico de uso. Veja como implementar retry com backoff, fila e limite por usuário.
Sumário do artigo

Seu app funcionou perfeitamente enquanto só você usava. No dia em que dez pessoas clicaram ao mesmo tempo, metade viu erro. O código não mudou. O que mudou foi a concorrência, e nenhum app gerado por IA nasce preparado para ela.
Três coisas quebram nesse momento: o limite de requisições do provedor de IA, o tempo máximo de execução da sua função e a ausência de qualquer fila entre o clique do usuário e a chamada cara.
Os erros que você vai ver
| Status | Nome | Significado | Tem retry? |
|---|---|---|---|
| 400 | Bad request | Sua requisição está malformada | Não |
| 401 | Unauthorized | Chave errada ou ausente | Não |
| 403 | Forbidden | Sem permissão para o recurso | Não |
| 429 | Rate limit | Você passou do limite de requisições ou de tokens | Sim, com espera |
| 500 | Server error | Erro do lado do provedor | Sim |
| 529 | Overloaded | API sobrecarregada no momento | Sim |
| Timeout | Sem resposta a tempo | Sua função morreu antes de a resposta chegar | Depende |
A regra é simples: erro na casa dos 400 é problema seu e retry só piora. Erro na casa dos 500 e o 429 são temporários e merecem nova tentativa.
Ler os headers antes de apanhar
A API da Anthropic devolve o estado do seu limite em headers de resposta. Ler esses valores evita descobrir o teto por tentativa e erro.
| Header | O que informa |
|---|---|
anthropic-ratelimit-requests-remaining |
Requisições restantes na janela |
anthropic-ratelimit-tokens-remaining |
Tokens restantes na janela |
anthropic-ratelimit-requests-reset |
Quando a janela de requisições reinicia |
retry-after |
Quantos segundos esperar antes de tentar de novo |
Quando vier retry-after, respeite o número. Tentar antes disso conta como nova violação.
Retry com backoff exponencial
Retry sem espera crescente transforma um problema pequeno em tempestade: todo mundo tenta ao mesmo tempo, o provedor recusa, todo mundo tenta de novo no mesmo instante.
async function chamarComRetry(fn, tentativas = 4) {
for (let i = 0; i < tentativas; i++) {
try {
return await fn();
} catch (erro) {
const status = erro.status;
const temporario = status === 429 || status === 529 || status >= 500;
// erro definitivo: falha na hora, sem insistir
if (!temporario || i === tentativas - 1) throw erro;
const retryAfter = Number(erro.headers?.['retry-after']);
const espera = retryAfter
? retryAfter * 1000
: Math.min(1000 * 2 ** i, 16000) + Math.random() * 500; // jitter
await new Promise(r => setTimeout(r, espera));
}
}
}
Os intervalos ficam em torno de 1s, 2s, 4s e 8s, com um pedaço aleatório somado. Esse pedaço aleatório é o jitter, e ele existe para espalhar as tentativas de clientes diferentes em vez de sincronizar todas no mesmo segundo.
Quatro tentativas é um bom teto. Acima disso o usuário já foi embora.
Timeout de função
Toda função serverless tem tempo máximo de execução. Geração longa de texto passa fácil desse limite, e o resultado é uma função que morre depois de você já ter pago os tokens.
Três saídas, em ordem de esforço:
Streaming. A resposta começa a chegar em pedaços e a conexão fica ativa. Resolve a percepção de lentidão e evita o corte por inatividade.
Resposta menor. Limitar max_tokens ao que a tela realmente exibe corta tempo e custo ao mesmo tempo.
Fila. Para tarefa que leva minutos, a requisição do usuário não deve esperar a resposta.
Fila: o padrão que resolve a maioria dos casos
Em vez de o clique disparar a chamada cara, ele registra um pedido. Um worker processa depois.
create table jobs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
tipo text not null,
entrada jsonb not null,
status text not null default 'pendente',
tentativas int not null default 0,
resultado jsonb,
erro text,
criado_em timestamptz default now(),
atualizado_em timestamptz default now()
);
create index on jobs (status, criado_em);
O fluxo fica assim: o clique insere uma linha com status pendente e devolve resposta imediata. Um cron chama a função de processamento a cada minuto. A função pega os pendentes mais antigos, processa e grava o resultado. O front consulta o status por polling ou realtime e atualiza a tela quando fica pronto.
Três detalhes que fazem a fila funcionar de verdade:
Marque como processando antes de chamar a API. Sem isso, duas execuções do cron pegam o mesmo job.
Conte as tentativas. Job que falhou três vezes vai para erro e para de consumir chamada.
Processe em lote pequeno. Pegar dez por rodada em vez de todos evita estourar o limite do provedor de uma vez.
Limite por usuário no seu lado
Rate limit do provedor protege o provedor. Quem protege a sua conta é o limite que você impõe.
// janela de 1 hora, 20 chamadas por usuário
const desde = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const { count } = await supabaseAdmin
.from('uso_ia')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.gte('criado_em', desde);
if (count >= 20) {
return new Response(
JSON.stringify({ erro: 'limite_horario', tente_em_minutos: 60 }),
{ status: 429 }
);
}
Devolver 429 do seu próprio backend permite ao front mostrar mensagem clara com o tempo de espera, em vez de erro genérico. A mesma tabela serve para cobrar por uso e para achar o usuário que está gerando custo desproporcional.
Custo como limite
Contagem de chamadas é aproximação. Quem tem prompt de tamanho variável faz melhor guardando tokens consumidos por chamada e limitando por soma no período. O campo usage da resposta traz os números de entrada e de saída, e gravar isso desde o primeiro dia evita a arqueologia de tentar descobrir depois quem gastou o quê.
Erros comuns
| Erro | Consequência | Correção |
|---|---|---|
| Retry em erro 400 ou 401 | Repete a falha e queima limite | Só repetir 429, 5xx e 529 |
| Retry sem espera crescente | Piora a sobrecarga | Backoff exponencial com jitter |
| Retry infinito | Custo descontrolado e usuário travado | Teto de tentativas com mensagem final |
| Chamada cara direto no clique | Timeout no primeiro pico | Fila com processamento assíncrono |
| Sem limite por usuário | Um usuário gera a conta do mês | Contagem em tabela com janela |
| Erro genérico na tela | Usuário tenta de novo e piora | Mensagem específica com tempo de espera |
A ordem de implementação
Nem tudo precisa entrar no primeiro dia. A ordem que dá mais resultado por esforço:
Primeiro, retry com backoff. É código pequeno e resolve a maior parte das falhas transitórias.
Segundo, limite por usuário. Protege a conta antes de você precisar dela.
Terceiro, mensagem de erro decente. Barato e muda a percepção de qualidade.
Quarto, fila. Só quando existir tarefa que passe de alguns segundos.
Fazer os três primeiros leva uma tarde e cobre o cenário que derruba a maioria dos apps no primeiro pico de acesso.
Não perca a próxima edição.
Toda quinta, 9h. Direto na sua caixa.
- Ferramentas que economizam horas do seu trabalho
- Agentes e automações que funcionam
- Bastidores do que estamos construindo