# Como chamar LLMs no Laravel com filas, retries e timeouts sem duplicar custo

> Chamadas ao Claude ou à OpenAI falham por 429, 529 e timeout. Veja como usar filas, retry com retry-after, rate limit no Horizon e idempotência no Laravel.

Por Lucas Roberto · IA · publicado em 2026-10-09 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/como-chamar-llms-no-laravel-com-filas-retries-e-timeouts-sem-duplicar-custo

Chamar um LLM (modelo de linguagem como Claude ou GPT) de forma confiável no Laravel exige quatro coisas: tirar a chamada do request HTTP e colocá-la numa fila, separar erros que valem retry dos que não valem, configurar timeouts coerentes entre o cliente HTTP, o job e o Horizon, e gravar o resultado antes de qualquer efeito colateral, para que um retry não pague a mesma chamada duas vezes. Sem isso, um pico de `429` ou `529` do provedor vira fila travada, job executado em dobro e fatura maior do que deveria.

Vim de pagamentos, onde provedor instável é rotina. A API de um LLM se comporta parecido com a de um gateway: latência alta e variável, limites de taxa, sobrecarga em horário de pico e cobrança por uso. A diferença é que aqui a cobrança acontece mesmo quando você descarta a resposta. A seguir está o desenho que uso para essas chamadas.

## Por que não chamar o LLM direto no controller?

Uma chamada à API de Messages da Anthropic ou à de Chat Completions da OpenAI pode levar de um a dezenas de segundos, dependendo do modelo e do tamanho da saída. Feita dentro do request, ela causa três problemas:

1. **Prende um worker do PHP-FPM** pelo tempo inteiro da geração. Com poucos workers, alguns usuários esperando o modelo já derrubam o resto do app.
2. **Esbarra em timeouts que você não controla**: o do Nginx, o do load balancer, o do cliente que chamou sua API. A requisição ao provedor continua, você é cobrado, e ninguém recebe a resposta.
3. **Não tem para onde ir quando falha.** Num request, a única opção é devolver erro ao usuário. Numa fila, dá para esperar e tentar de novo.

A exceção é o chat com o usuário na tela, onde faz sentido usar streaming (resposta enviada em pedaços via Server-Sent Events à medida que o modelo gera). Classificar mensagens, extrair dados, resumir, gerar respostas para o WhatsApp: tudo isso vai para fila.

## Quais erros da API do Claude e da OpenAI valem retry?

Antes de escrever qualquer retry, classifique os erros. Tentar de novo um `400` é queimar dinheiro e tempo; desistir de um `529` é perder trabalho que daria certo em 30 segundos.

| Status | O que significa | Retry? |
|---|---|---|
| 400 | Requisição inválida (payload, parâmetro, contexto grande demais) | Não |
| 401 / 403 | Chave inválida ou sem permissão | Não, e alerte alguém |
| 404 | Modelo ou endpoint inexistente | Não |
| 413 | Requisição grande demais | Não |
| 429 | Rate limit estourado | Sim, respeitando o header `retry-after` |
| 500 / 502 / 503 / 504 | Erro do lado do provedor | Sim, com backoff |
| 529 | `overloaded_error` da Anthropic: API sobrecarregada | Sim, com backoff mais longo |
| Timeout de conexão/leitura | Rede ou geração lenta demais | Sim, com cuidado (veja a seção de idempotência) |

Backoff é o intervalo crescente entre tentativas, para não martelar um serviço que já está com problema. O header `retry-after` é o provedor dizendo quantos segundos esperar; quando ele vem, vale mais do que qualquer backoff que você inventar.

## Como configurar o cliente HTTP do Laravel para chamar o Claude?

Uso o `Http::` direto em vez de SDK. É um POST com JSON, e assim controlo timeout e erros sem camadas extras. O cliente fica pequeno e só traduz a resposta em sucesso ou numa exceção classificada:

```php
namespace App\Services\Llm;

use Illuminate\Support\Facades\Http;

class AnthropicClient
{
    public function messages(array $payload): array
    {
        $response = Http::baseUrl('https://api.anthropic.com/v1')
            ->withHeaders([
                'x-api-key' => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])
            ->acceptJson()
            ->connectTimeout(5)
            ->timeout(90)
            ->post('/messages', $payload);

        if ($response->successful()) {
            return $response->json();
        }

        throw LlmException::fromResponse($response);
    }
}
```

O payload é o JSON padrão da API de Messages:

```json
{
  "model": "<modelo vindo do config>",
  "max_tokens": 1024,
  "system": "Você classifica mensagens de clientes em: agendamento, cancelamento, dúvida, outro.",
  "messages": [
    {"role": "user", "content": "Consigo remarcar pra sexta à tarde?"}
  ]
}
```

O nome do modelo fica no `.env`, nunca no código. Modelos são descontinuados e trocar de versão não deveria exigir deploy.

A exceção carrega o que o job precisa para decidir:

```php
namespace App\Services\Llm;

use Illuminate\Http\Client\Response;
use RuntimeException;

class LlmException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $status,
        public readonly bool $retryable,
        public readonly ?int $retryAfter = null,
    ) {
        parent::__construct($message, $status);
    }

    public static function fromResponse(Response $response): self
    {
        $status = $response->status();
        $retryAfter = $response->header('retry-after');

        return new self(
            message: $response->json('error.type', 'unknown').': '.$response->json('error.message', ''),
            status: $status,
            retryable: in_array($status, [408, 429, 500, 502, 503, 504, 529], true),
            retryAfter: is_numeric($retryAfter) ? (int) $retryAfter : null,
        );
    }
}
```

Sobre os dois timeouts: `connectTimeout` é o tempo para abrir a conexão e deve ser curto, porque se nem conectou em 5 segundos algo está errado. `timeout` é o tempo total da requisição e depende do tamanho da saída que você pede. Para respostas curtas, 30 a 60 segundos sobram. Se você gera textos longos com `max_tokens` alto, ou aumenta esse valor, ou usa streaming, que mantém a conexão ativa enquanto os tokens chegam. A própria Anthropic recomenda streaming para requisições longas justamente para evitar quedas de conexão ociosa.

## Retry no Http::retry ou no job da fila?

Aqui mora um erro comum: retry em duas camadas. Se o cliente HTTP tenta 3 vezes e o job tem `$tries = 5`, um provedor fora do ar gera até 15 chamadas por item. Se você usa um SDK oficial, confira o padrão dele: o da Anthropic, por exemplo, já faz retries automáticos por padrão.

Minha regra:

1. **No cliente HTTP, no máximo um retry rápido**, e só para falha de conexão (o servidor nem respondeu). Muitas vezes nem isso.
2. **Todo o resto vai para a fila**, que tem backoff, persistência e visibilidade no Horizon. Um `429` com `retry-after: 40` não deve segurar um worker parado num `sleep` por 40 segundos; o job é devolvido para a fila e o worker pega outro.

## Como montar o job com tries, backoff e retry-after?

```php
namespace App\Jobs;

use App\Models\Mensagem;
use App\Services\Llm\AnthropicClient;
use App\Services\Llm\LlmException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;

class ClassificarMensagem implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 8;
    public int $maxExceptions = 4;
    public int $timeout = 120;

    public function __construct(public int $mensagemId)
    {
        $this->onQueue('llm');
    }

    public function middleware(): array
    {
        return [new RateLimited('anthropic')];
    }

    public function backoff(): array
    {
        return [15, 45, 120, 300];
    }

    public function handle(AnthropicClient $client): void
    {
        $mensagem = Mensagem::findOrFail($this->mensagemId);

        try {
            $resposta = $client->messages($this->payload($mensagem));
        } catch (LlmException $e) {
            if (! $e->retryable) {
                $this->fail($e);
                return;
            }

            if ($e->retryAfter !== null) {
                $this->release($e->retryAfter);
                return;
            }

            throw $e;
        }

        // processa $resposta (veja a seção de idempotência)
    }
}
```

Alguns detalhes que fazem diferença:

- **`$tries` conta toda tentativa**, inclusive as liberadas pelo `release()` e pelo middleware de rate limit. Por isso ele é mais alto do que parece necessário.
- **`$maxExceptions` limita só as exceções lançadas.** Assim o job aguenta várias esperas por rate limit, mas desiste depois de 4 erros reais.
- **`$this->fail()` para erro não retryable** manda o job direto para `failed_jobs`, sem gastar as tentativas restantes. Um `400` hoje vai ser `400` daqui a cinco minutos.
- **`backoff()` com array** define o intervalo de cada tentativa; o último valor se repete nas seguintes.

Se preferir limitar por tempo em vez de contagem, `retryUntil()` resolve: o job tenta enquanto não passar do horário que você devolver. Para tarefas que perdem o sentido depois de um tempo, como responder um cliente no WhatsApp, isso é mais honesto do que número de tentativas.

## Como respeitar o rate limit do provedor com RateLimited e Horizon?

Reagir ao `429` funciona, mas é melhor nem chegar nele. O middleware `RateLimited` usa um limiter nomeado, definido no `AppServiceProvider`:

```php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('anthropic', fn (object $job) => Limit::perMinute(
        (int) config('services.anthropic.rpm', 40)
    ));
}
```

Quando o limite estoura, o job volta para a fila e tenta depois, sem chamar a API. Duas ressalvas. Primeiro, isso limita requisições por minuto, mas os provedores também limitam tokens por minuto, e um único prompt grande consome muito desse orçamento. Deixo o limite de requisições abaixo do contratado e conto com o tratamento de `429` para o resto. Segundo, se vários sistemas usam a mesma chave de API, o limiter de um não enxerga o outro. Chave por aplicação evita essa surpresa.

No Horizon, a fila `llm` ganha um supervisor próprio. Isso isola o problema: se o provedor engasgar, os jobs de e-mail, webhook e conciliação continuam rodando.

```php
// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-llm' => [
            'connection' => 'redis',
            'queue' => ['llm'],
            'balance' => 'simple',
            'maxProcesses' => 4,
            'timeout' => 150,
            'tries' => 8,
        ],
    ],
],
```

`maxProcesses` funciona como um segundo limitador: com 4 processos, nunca há mais de 4 chamadas simultâneas, independente de quantos jobs entrem na fila.

E a regra que mais causa execução duplicada: **o `retry_after` da conexão Redis em `config/queue.php` precisa ser maior que o timeout do job.** O `retry_after` diz quanto tempo um job pode ficar reservado antes de o Redis considerá-lo perdido e entregá-lo para outro worker. Se ele for 90 e a chamada ao modelo levar 100 segundos, um segundo worker pega o mesmo job enquanto o primeiro ainda está esperando a resposta, e você paga duas vezes. Minha escala é sempre esta:

1. `timeout` do `Http::` (90s)
2. menor que `$timeout` do job (120s)
3. menor que `timeout` do supervisor no Horizon (150s)
4. menor que `retry_after` da conexão (180s)

Se as chamadas ao LLM precisam de um `retry_after` bem maior que o resto do sistema, crie uma conexão de fila separada só para elas.

## Como evitar pagar duas vezes pela mesma chamada ao LLM?

Retry resolve falha, mas cria outro risco: o job chamou o modelo, recebeu a resposta, e quebrou depois, ao salvar no banco ou ao enviar a mensagem no WhatsApp. Na próxima tentativa, chama o modelo de novo. Em volume, isso aparece na fatura.

A solução é a mesma que uso para [processar webhooks de pagamento uma única vez](/blog/webhooks-de-pagamento-duplicados-e-fora-de-ordem-como-processar-cada-evento-uma-unica-vez-no-laravel): uma chave de idempotência e o resultado gravado antes dos efeitos colaterais. Idempotência, aqui, significa que rodar o job duas vezes produz o mesmo resultado e o mesmo custo que rodar uma.

```php
$chamada = LlmChamada::firstOrCreate(
    ['chave' => "classificar-mensagem:{$mensagem->id}"],
    ['status' => 'pendente'],
);

if ($chamada->status !== 'concluida') {
    $resposta = $client->messages($this->payload($mensagem));

    $chamada->update([
        'status' => 'concluida',
        'resposta' => $resposta,
        'input_tokens' => $resposta['usage']['input_tokens'],
        'output_tokens' => $resposta['usage']['output_tokens'],
    ]);
}

// daqui pra baixo, só $chamada->resposta é usado
$this->aplicarClassificacao($mensagem, $chamada->resposta);
```

A tabela `llm_chamadas` tem índice único em `chave` e `resposta` com cast para `array`. De quebra, ela vira um registro de consumo: com `input_tokens` e `output_tokens` por chamada, dá para saber quanto custa cada funcionalidade sem esperar a fatura do mês.

O caso que não tem solução perfeita é o timeout de leitura. Se a conexão caiu enquanto o modelo gerava, o provedor provavelmente processou e cobrou, mas você não tem a resposta. Não há como recuperá-la, então a nova tentativa é inevitável. O que dá para fazer é medir: se timeouts são frequentes, o `timeout` está curto demais para o `max_tokens` que você pede, ou essa tarefa deveria usar streaming.

Outro cuidado: se o job é disparado logo depois de criar a mensagem dentro de uma transação, ele pode rodar antes do commit e não achar o registro. É o [bug silencioso do afterCommit no Laravel](/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel), e com LLM ele custa caro, porque o `findOrFail` gera um retry que pode acabar chamando o modelo para nada.

## O que fazer quando a resposta vem cortada por max_tokens?

Uma resposta `200` não significa resposta completa. A API de Messages devolve o campo `stop_reason`, que diz por que o modelo parou. `end_turn` é o fim natural; `max_tokens` significa que a saída foi cortada no limite que você definiu.

Repetir a mesma requisição dá o mesmo corte, então isso não é caso de retry. Trato assim:

1. Se a saída precisa ser JSON, resposta cortada é inválida. Marco a chamada como falha e registro para análise.
2. Se é recorrente para aquela tarefa, aumento o `max_tokens` no config ou reviso o prompt para pedir uma saída mais enxuta.
3. Nunca tento "emendar" pedindo ao modelo para continuar em produção. Funciona em chat, mas complica o parsing e a idempotência.

Validar o formato do que voltou faz parte da mesma etapa. O status HTTP diz que a chamada funcionou; só a validação diz que a resposta serve.

## Vale a pena ter fallback para outro modelo ou provedor?

A ideia é tentadora: se o Claude devolver `529`, chama a OpenAI. Na prática, os trade-offs são estes:

| Estratégia | Vantagem | Custo |
|---|---|---|
| Só retry com backoff | Simples, comportamento previsível | Atraso durante instabilidade |
| Fallback para modelo menor do mesmo provedor | Mesmo formato de API, mesmo prompt | Qualidade menor, não ajuda se o provedor todo cair |
| Fallback para outro provedor | Resiste a queda do provedor | Dois formatos de API, prompts que se comportam diferente, dois contratos de dados para a LGPD |

Para tarefas em background, retry com backoff resolve quase sempre: a sobrecarga costuma durar minutos e o job pode esperar. Fallback entre provedores só compensa quando há um usuário esperando e a tarefa tolera variação na resposta. Se for por esse caminho, cada provedor precisa passar pelos mesmos casos de teste antes de entrar em produção. Escrevi sobre como montar isso em [testes de código que usa LLM com Http::fake e evals](/blog/como-testar-codigo-que-usa-llm-no-laravel-httpfake-respostas-gravadas-e-evals), e o mesmo `Http::fake` serve para simular `429` e `529` e verificar que o job faz `release` em vez de falhar.

## Conclusão: o que configurar antes de colocar LLM em produção

Se eu fosse subir hoje uma funcionalidade nova com LLM no Laravel, faria nesta ordem:

1. Cliente com `Http::`, `connectTimeout` curto, `timeout` compatível com o `max_tokens` e uma exceção que classifica o erro como retryable ou não.
2. Job na fila `llm`, com `fail()` para erro definitivo, `release()` com o `retry-after` do provedor e `backoff()` para o resto.
3. Supervisor próprio no Horizon com `maxProcesses` baixo e o limiter `RateLimited` abaixo do limite contratado.
4. A escala de timeouts conferida: HTTP menor que job, job menor que supervisor, supervisor menor que `retry_after`.
5. Tabela de chamadas com chave única, resposta gravada antes de qualquer efeito colateral e tokens registrados.
6. Verificação de `stop_reason` e validação do formato antes de usar a resposta.

Nada disso é específico de IA. É o mesmo cuidado que qualquer integração com gateway de pagamento já exige. A diferença é que, com LLM, cada erro de desenho aparece primeiro na fatura e só depois no log.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
