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:
- 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.
- 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.
- 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:
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:
{ "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:
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:
- 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.
- Todo o resto vai para a fila, que tem backoff, persistência e visibilidade no Horizon. Um
429comretry-after: 40não deve segurar um worker parado numsleeppor 40 segundos; o job é devolvido para a fila e o worker pega outro.
Como montar o job com tries, backoff e retry-after?
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:
$triesconta toda tentativa, inclusive as liberadas pelorelease()e pelo middleware de rate limit. Por isso ele é mais alto do que parece necessário.$maxExceptionslimita 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 parafailed_jobs, sem gastar as tentativas restantes. Um400hoje vai ser400daqui 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:
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.
// 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:
timeoutdoHttp::(90s)- menor que
$timeoutdo job (120s) - menor que
timeoutdo supervisor no Horizon (150s) - menor que
retry_afterda 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: 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.
$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, 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:
- Se a saída precisa ser JSON, resposta cortada é inválida. Marco a chamada como falha e registro para análise.
- Se é recorrente para aquela tarefa, aumento o
max_tokensno config ou reviso o prompt para pedir uma saída mais enxuta. - 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, 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:
- Cliente com
Http::,connectTimeoutcurto,timeoutcompatível com omax_tokense uma exceção que classifica o erro como retryable ou não. - Job na fila
llm, comfail()para erro definitivo,release()com oretry-afterdo provedor ebackoff()para o resto. - Supervisor próprio no Horizon com
maxProcessesbaixo e o limiterRateLimitedabaixo do limite contratado. - A escala de timeouts conferida: HTTP menor que job, job menor que supervisor, supervisor menor que
retry_after. - Tabela de chamadas com chave única, resposta gravada antes de qualquer efeito colateral e tokens registrados.
- Verificação de
stop_reasone 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.