Blog

Rate limit e fila de prioridade para APIs de LLM

Toda API de LLM tem um teto, e ele quase nunca é o que você imagina. O provedor não vende chamadas ilimitadas: vende uma cota por minuto, medida em requisições e em tokens, e quando o seu tráfego encosta nesse teto a API começa a devolver 429. O primeiro reflexo de quase todo time é tentar de novo, imediatamente, em todas as chamadas que falharam ao mesmo tempo. É o pior movimento possível: as tentativas simultâneas batem no limite outra vez, geram mais 429, disparam mais retentativas, e o sistema entra numa tempestade de retry que consome a cota inteira em erros e não entrega uma resposta sequer. O erro conceitual é tratar o rate limit como uma falha esporádica quando ele é um recurso escasso a ser administrado. Um pico de demanda não deveria virar uma chuva de erros: deveria virar uma fila. E se é para haver fila, ela não pode ser cega, porque nem toda chamada vale o mesmo: o usuário esperando uma resposta na tela não pode ficar atrás de dez mil linhas de um relatório noturno. Este artigo mostra como transformar o teto do provedor em vazão controlada: o token bucket que segura a taxa antes do erro acontecer, a fila com classes de prioridade que decide quem passa primeiro, o retry com backoff e jitter que respeita o Retry-After, e as métricas que dizem se a cota está apertada ou se o problema é outro.

2026-07-15 / IA Aplicada / 13 min

01

Por que o retry ingênuo piora o problema

Quando uma chamada volta com 429, o significado é literal: o provedor está dizendo que você excedeu a cota e precisa desacelerar. Repetir a chamada no instante seguinte é responder ao pedido de desaceleração acelerando. E o problema não é uma chamada repetida: é que todas as chamadas que falharam no mesmo segundo tendem a repetir no mesmo segundo, porque falharam juntas. Esse é o efeito manada, e ele converte um pico de tráfego numa oscilação que se sustenta sozinha, com a cota inteira sendo gasta em requisições rejeitadas.

Vale entender também que a cota de uma API de LLM raramente é uma dimensão só. Costuma haver um limite de requisições por minuto e outro, independente, de tokens por minuto, e o segundo é o que morde primeiro em cargas de contexto longo: dez chamadas com um prompt gigante estouram a cota de tokens muito antes de encostar na de requisições. Controlar apenas a contagem de chamadas dá a ilusão de estar dentro do limite enquanto o consumo real de tokens já passou dele.

EstratégiaO que acontece no picoEfeito na cotaResultado para o usuário
Retry imediatoTodas as falhas repetem juntasConsumida por requisições rejeitadasErro depois de uma espera longa
Retry com backoff fixoManada volta em bloco, só que mais tardePicos sincronizados de rejeiçãoLatência irregular e imprevisível
Backoff exponencial com jitterTentativas se espalham no tempoRejeições caem, mas ainda há erroFunciona, sem controle de ordem
Token bucket com fila e prioridadeExcedente espera em fila ordenadaUsada quase toda em chamadas aceitasEspera previsível, crítico passa antes

A diferença entre as duas últimas linhas é a tese deste artigo. Backoff com jitter é reativo: espera o erro acontecer para então se comportar bem. O token bucket com fila é preventivo: ele nunca deixa a chamada sair se ela vai estourar a cota, então o 429 quase não acontece, e quando acontece (porque outra instância também consome a mesma cota) o backoff é a rede de segurança, não a estratégia principal.

02

Token bucket: segurar a taxa antes do erro

O token bucket é o algoritmo certo para essa tarefa porque modela exatamente o que o provedor vende: uma taxa média com tolerância a rajada. O balde tem uma capacidade máxima e é reabastecido continuamente a uma taxa fixa. Cada chamada precisa retirar tokens do balde para sair; se não houver tokens suficientes, ela espera até haver. A capacidade define o tamanho da rajada que você aceita mandar de uma vez, e a taxa de reabastecimento define o regime sustentado. É melhor que uma janela fixa de contagem, que sofre do problema de borda: duas rajadas nas pontas de janelas vizinhas passam pela contagem mas concentram o dobro do tráfego no meio.

A sutileza para LLM é que você precisa de dois baldes, um para requisições e um para tokens, e a chamada só sai quando os dois autorizam. O balde de tokens é debitado por uma estimativa do custo da chamada antes de ela sair, e reconciliado com o consumo real quando a resposta chega, porque só aí se sabe quantos tokens de saída o modelo gerou.

  reabastecimento continuo (rate/s)
            |
            v
   +--------------------+
   |  balde de requisicoes |  capacidade = rajada aceita
   +--------------------+
            |  precisa de 1 token
            v
   +--------------------+
   |   balde de tokens    |  capacidade = tokens/min do plano
   +--------------------+
            |  precisa de custo_estimado
            v
     os dois autorizam? --nao--> espera na fila
            |
           sim
            v
       chamada sai para a API
            |
            v
   resposta traz uso real -> reconcilia o balde de tokens
// src/token-bucket.js
// Balde de tokens com reabastecimento continuo.
// Nao usa setInterval: calcula o nivel sob demanda a partir do tempo
// decorrido, o que evita drift e nao segura o event loop.

export class TokenBucket {
  constructor({ capacity, refillPerSecond }) {
    this.capacity = capacity;
    this.refillPerSecond = refillPerSecond;
    this.level = capacity;
    this.lastRefill = Date.now();
  }

  refill() {
    const now = Date.now();
    const elapsedSeconds = (now - this.lastRefill) / 1000;
    if (elapsedSeconds <= 0) return;
    this.level = Math.min(
      this.capacity,
      this.level + elapsedSeconds * this.refillPerSecond,
    );
    this.lastRefill = now;
  }

  // Tenta retirar 'amount' tokens. Devolve true se conseguiu.
  tryRemove(amount) {
    this.refill();
    if (this.level < amount) return false;
    this.level -= amount;
    return true;
  }

  // Quantos ms faltam ate haver 'amount' tokens disponiveis.
  waitTimeMs(amount) {
    this.refill();
    if (this.level >= amount) return 0;
    const missing = amount - this.level;
    return Math.ceil((missing / this.refillPerSecond) * 1000);
  }

  // Devolve tokens ao balde quando a estimativa foi maior que o uso real.
  giveBack(amount) {
    this.refill();
    this.level = Math.min(this.capacity, this.level + Math.max(0, amount));
  }
}

Dois detalhes de implementação valem o comentário. O reabastecimento é calculado sob demanda, a partir do tempo decorrido desde a última consulta, em vez de um setInterval que soma tokens periodicamente: além de não segurar o event loop com um timer eterno, isso elimina o desvio acumulado que um intervalo impreciso introduziria ao longo de horas. E o giveBack existe porque a estimativa de custo é feita antes da chamada, quando ainda não se sabe o tamanho da saída; quando a resposta chega com o uso real e ele foi menor que o estimado, a diferença volta para o balde, senão a cota real fica ociosa por causa de uma estimativa pessimista.

03

A fila de prioridade: nem toda chamada vale o mesmo

Quando a demanda excede a cota, alguém vai esperar. A pergunta é quem. Uma fila FIFO responde "quem chegou depois", o que é a resposta errada, porque ela ignora completamente o custo de negócio da espera. Um usuário olhando para a tela esperando o chat responder tem tolerância de segundos; um relatório em lote que roda de madrugada tolera minutos sem que ninguém perceba. Se ambos entram na mesma fila por ordem de chegada, o lote de dez mil linhas que entrou às três da manhã vai fazer o usuário das três e um esperar o lote inteiro.

A solução é classificar cada chamada por prioridade na origem e servir a fila por classe. Três classes cobrem quase todo caso real, e a disciplina é simples: só sirva a classe mais baixa quando as mais altas estiverem vazias.

ClasseExemplo típicoTolerância de esperaO que fazer sob pressão
InterativaChat com usuário na tela, autocompleteSegundosPassa na frente, sempre
AssíncronaResumo de ticket, classificação de e-mailDezenas de segundosEspera as interativas escoarem
LoteReprocessar histórico, indexação noturnaMinutos ou horasSó roda com cota sobrando

A prioridade estrita tem um risco conhecido: a fome (starvation). Se a classe interativa nunca esvazia, o lote nunca roda. A defesa é o envelhecimento: uma chamada que espera além de um teto sobe de classe, garantindo que ela eventualmente saia. O código abaixo implementa a fila com essa promoção.

// src/priority-queue.js
// Fila por classes com envelhecimento: um item que espera demais
// sobe de prioridade, para que lote nunca morra de fome.

const CLASSES = ['interactive', 'async', 'batch'];
const AGING_MS = { async: 30_000, batch: 120_000 };

export class PriorityQueue {
  constructor() {
    this.queues = new Map(CLASSES.map((name) => [name, []]));
  }

  push(item, priority = 'async') {
    const queue = this.queues.get(priority) || this.queues.get('async');
    queue.push({ ...item, priority, enqueuedAt: Date.now() });
  }

  // Promove itens que esperaram alem do teto da sua classe.
  applyAging() {
    const now = Date.now();
    for (const [name, threshold] of Object.entries(AGING_MS)) {
      const queue = this.queues.get(name);
      const higher = CLASSES[CLASSES.indexOf(name) - 1];
      // Percorre do mais antigo para o mais novo e para no primeiro
      // que ainda nao venceu: a fila esta ordenada por chegada.
      while (queue.length && now - queue[0].enqueuedAt >= threshold) {
        this.queues.get(higher).push(queue.shift());
      }
    }
  }

  // Serve a classe mais alta que tiver item.
  shift() {
    this.applyAging();
    for (const name of CLASSES) {
      const queue = this.queues.get(name);
      if (queue.length) return queue.shift();
    }
    return null;
  }

  get size() {
    return CLASSES.reduce((total, n) => total + this.queues.get(n).length, 0);
  }

  depthByClass() {
    return Object.fromEntries(
      CLASSES.map((n) => [n, this.queues.get(n).length]),
    );
  }
}

O envelhecimento é o que torna a prioridade estrita segura de usar. Sem ele, um sistema com carga interativa constante deixa o lote parado para sempre, e o time descobre isso quando o relatório de segunda não existe. Com ele, o pior caso de um item de lote é esperar o teto configurado antes de subir para a classe assíncrona, o que dá um limite superior de espera que se pode prometer.

04

Retry com backoff, jitter e Retry-After

Mesmo com o token bucket calibrado, o 429 vai acontecer. A cota é do provedor e é compartilhada por todas as instâncias do seu serviço, então o balde local de cada processo não conhece o consumo dos outros; e o provedor pode ajustar a cota sem avisar. O retry, então, existe como rede de segurança para o erro que escapa, não como estratégia de vazão.

Três regras tornam o retry correto. A primeira: se a resposta trouxer o cabeçalho Retry-After, obedeça, porque é o provedor dizendo exatamente quanto esperar, e nenhuma heurística sua vai adivinhar melhor. A segunda: sem Retry-After, use backoff exponencial, dobrando a espera a cada tentativa, com um teto para não esperar minutos. A terceira, e a mais esquecida: aplique jitter, um componente aleatório na espera, porque sem ele todas as chamadas que falharam juntas voltam juntas e a manada se reconstitui.

Igualmente importante é saber o que não retentar. Um 429 e um 503 são transitórios e merecem nova tentativa; um 400 de prompt malformado e um 401 de credencial errada vão falhar de novo, exatamente igual, quantas vezes você tentar. Retentar erro permanente é queimar cota para reproduzir o mesmo erro.

// src/retry.js
// Backoff exponencial com jitter completo, respeitando Retry-After.
// So retenta erro transitorio: 429 e 5xx. Erro de cliente falha na hora.

const MAX_ATTEMPTS = 5;
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 20_000;

const isRetryable = (status) => status === 429 || (status >= 500 && status < 600);

// Jitter completo: sorteia em [0, teto]. Espalha a manada melhor
// que somar um ruido pequeno a um valor fixo.
const backoffWithJitter = (attempt, random) => {
  const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
  return Math.floor(random() * ceiling);
};

const parseRetryAfter = (header) => {
  if (!header) return null;
  const seconds = Number(header);
  if (Number.isFinite(seconds)) return seconds * 1000;
  const date = Date.parse(header);
  return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
};

export async function callWithRetry(doCall, { sleep, random = Math.random } = {}) {
  let lastError;
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
    const response = await doCall();
    if (response.ok) return response;

    if (!isRetryable(response.status)) {
      // Erro permanente: retentar so reproduz a mesma falha.
      throw new Error(`Falha permanente: HTTP ${response.status}`);
    }

    lastError = new Error(`HTTP ${response.status}`);
    const serverHint = parseRetryAfter(response.headers.get('retry-after'));
    const delay = serverHint ?? backoffWithJitter(attempt, random);
    await sleep(delay);
  }
  throw lastError;
}

O jitter completo, que sorteia a espera no intervalo inteiro de zero até o teto exponencial, espalha melhor do que somar um pequeno ruído a um valor fixo: com o ruído pequeno, as tentativas ainda se agrupam em torno do valor central e a manada sobrevive, só que mais discreta. E repare que o Retry-After tem precedência sobre o cálculo local: quando o provedor diz quanto esperar, essa informação vale mais que qualquer fórmula.

05

Juntar as peças: o cliente com vazão controlada

As três peças se compõem numa ordem específica. A fila decide quem é o próximo; o token bucket decide quando esse próximo pode sair; o retry cuida do erro que escapou apesar dos dois. O laço que orquestra isso é um despachante único, e é ele que deve ter o limite de concorrência, porque respeitar a taxa não basta se cem chamadas ficarem abertas ao mesmo tempo esperando resposta.

// src/dispatcher.js
// Une fila, balde e retry. A chamada so sai quando ha vaga de
// concorrencia e os dois baldes autorizam.

import { TokenBucket } from './token-bucket.js';
import { PriorityQueue } from './priority-queue.js';
import { callWithRetry } from './retry.js';

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export class Dispatcher {
  constructor({ requestsPerMinute, tokensPerMinute, maxConcurrent }) {
    this.requests = new TokenBucket({
      capacity: Math.ceil(requestsPerMinute / 6),
      refillPerSecond: requestsPerMinute / 60,
    });
    this.tokens = new TokenBucket({
      capacity: tokensPerMinute,
      refillPerSecond: tokensPerMinute / 60,
    });
    this.queue = new PriorityQueue();
    this.maxConcurrent = maxConcurrent;
    this.inFlight = 0;
    this.running = false;
  }

  submit(call, { priority = 'async', estimatedTokens }) {
    return new Promise((resolve, reject) => {
      this.queue.push({ call, estimatedTokens, resolve, reject }, priority);
      this.pump();
    });
  }

  async pump() {
    if (this.running) return;
    this.running = true;
    while (this.queue.size > 0) {
      if (this.inFlight >= this.maxConcurrent) break;

      const next = this.queue.shift();
      if (!next) break;

      const cost = next.estimatedTokens;
      if (!this.requests.tryRemove(1) || !this.tokens.tryRemove(cost)) {
        // Sem cota agora: devolve a fila e espera o balde encher.
        this.queue.push(next, next.priority);
        await sleep(Math.max(
          this.requests.waitTimeMs(1),
          this.tokens.waitTimeMs(cost),
        ));
        continue;
      }

      this.inFlight += 1;
      callWithRetry(next.call, { sleep })
        .then((response) => {
          // Reconcilia: estimativa maior que o uso real devolve cota.
          const used = response.usage?.totalTokens ?? cost;
          if (used < cost) this.tokens.giveBack(cost - used);
          next.resolve(response);
        })
        .catch(next.reject)
        .finally(() => {
          this.inFlight -= 1;
          this.pump();
        });
    }
    this.running = false;
  }
}

O ponto mais fácil de errar aqui é o item que não conseguiu cota. Ele volta para a fila e o laço espera o balde encher, em vez de descartar a chamada ou girar em laço apertado consumindo CPU. E o limite de concorrência é uma dimensão independente da taxa: dez chamadas por segundo com respostas de trinta segundos significam trezentas chamadas abertas ao mesmo tempo, o que estoura memória e conexões muito antes de estourar a cota. Taxa e concorrência precisam de tetos separados.

06

Métricas: saber se a cota está apertada

Um controlador de vazão sem métrica é um lugar onde a latência vai se esconder. Do lado de fora, uma chamada que espera quarenta segundos na fila e uma que espera quarenta segundos no modelo parecem a mesma coisa, e sem separar as duas o time otimiza o modelo quando o problema é cota, ou compra cota quando o problema é o prompt. Meça o tempo de fila separado do tempo de chamada, sempre.

MétricaO que revelaSinal de alerta
Tempo de espera na fila (p95, por classe)Quanto a cota está atrasando cada classeInterativa acima de poucos segundos
Profundidade da fila por classeSe a demanda excede a cota de forma sustentadaCresce sem voltar ao normal
Taxa de 429 recebidosSe o balde local está mal calibradoQualquer valor não desprezível
Utilização do balde de tokensQuanto da cota comprada é de fato usadaAlta com fila cheia: cota é o gargalo
Promoções por envelhecimentoSe o lote está morrendo de fomeTodo item de lote sendo promovido

A leitura conjunta dessas métricas é o que responde à pergunta cara: comprar mais cota ou consertar o código. Fila funda com utilização do balde de tokens perto do teto significa demanda maior que a cota, e aí é decisão comercial. Fila funda com utilização baixa significa que o gargalo é outro, quase sempre o limite de concorrência ou uma chamada lenta segurando vaga. E 429 acontecendo com o balde local dentro do limite significa que a cota está sendo dividida com outra instância ou outro serviço, e o balde precisa ser compartilhado num Redis em vez de viver na memória de cada processo.

07

Colocar em produção sem surpresa

A sequência abaixo é a ordem em que essas peças devem entrar, cada passo entregando valor antes do seguinte.

  1. Descubra a cota real do seu plano em ambas as dimensões, requisições por minuto e tokens por minuto, e não confie na memória de ninguém: leia a documentação do provedor e confirme com uma medição.
  2. Calibre o balde para uma folga abaixo do teto nominal, algo como 80 por cento, porque a contabilidade do provedor e a sua nunca coincidem exatamente e a folga é o que evita o 429 de borda.
  3. Classifique cada ponto de chamada do código em interativa, assíncrona ou lote. Se todo mundo se declarar interativo, a prioridade não existe: essa conversa é de arquitetura, não de código.
  4. Ponha o despachante único no caminho de todas as chamadas ao provedor. Um caminho paralelo que escapa do controlador destrói a garantia, porque consome cota que o balde acha que ainda tem.
  5. Instrumente tempo de fila separado do tempo de chamada antes de calibrar qualquer coisa, senão você vai otimizar no escuro.
  6. Se rodar mais de uma instância, mova o balde para um contador compartilhado (Redis com script atômico), porque quatro processos com balde local consomem quatro vezes a cota.
  7. Só então ajuste concorrência e tetos de envelhecimento, com a métrica na tela, mudando um parâmetro por vez.

O passo que mais gera retrabalho quando pulado é o do balde compartilhado. É comum o controlador funcionar perfeitamente em desenvolvimento, com um processo só, e desmoronar em produção com quatro réplicas, porque cada uma acredita ter a cota inteira. O sintoma é característico: 429 constante enquanto o painel de cada instância jura estar dentro do limite.

FAQ

Perguntas frequentes

Token bucket ou leaky bucket para API de LLM?

Token bucket, porque ele permite rajada até a capacidade do balde e é assim que os provedores de LLM medem a cota: uma taxa média com tolerância a picos curtos. O leaky bucket suaviza a saída para uma taxa constante, o que desperdiça a rajada que você tem direito de mandar e piora a latência da primeira chamada depois de um período ocioso. Use a capacidade do balde para definir o tamanho da rajada aceitável e a taxa de reabastecimento para o regime sustentado.

Preciso de fila de prioridade mesmo com pouco tráfego?

Não no dia normal, mas a fila existe para o dia ruim. Com tráfego bem abaixo da cota, todas as chamadas saem na hora e a fila fica vazia, sem custo nenhum. O valor aparece no pico, na campanha, no reprocessamento que alguém disparou sem avisar: é quando a diferença entre o usuário esperar dois segundos ou dois minutos é decidida pela existência da classificação. Classificar as chamadas custa pouco e é muito mais difícil de fazer depois, com o incidente em andamento.

Como controlar a cota com várias instâncias do serviço?

Um balde em memória só conhece o consumo do próprio processo, então quatro réplicas com balde local consomem até quatro vezes a cota e o 429 volta. A saída é mover a contagem para um contador compartilhado, tipicamente Redis com um script Lua que faz o refill e o débito de forma atômica, para que não haja corrida entre as réplicas. Alternativa mais simples, quando a precisão pode ser aproximada: dividir a cota estaticamente pelo número de réplicas, aceitando o desperdício de uma réplica ociosa com cota reservada.

Rate limit é um recurso a administrar, não um erro a retentar

O 429 não é uma falha esporádica: é o provedor cobrando disciplina de vazão. Um token bucket calibrado nas duas dimensões evita quase todo erro antes dele acontecer, a fila com classes de prioridade e envelhecimento decide quem espera quando a cota aperta, e o retry com backoff, jitter e Retry-After cuida do que escapa. Junte isso a métricas que separam tempo de fila de tempo de modelo e o pico deixa de ser incidente para virar espera previsível.