Laravel Horizon per chiamate LLM asincrone: retry strategy, cost tracking, timeout management in produzione

Laravel Horizon per chiamate LLM asincrone: retry strategy, cost tracking, timeout management in produzione

La mia pipeline personale di automazione AI processa circa 4.200 job LLM al giorno su Laravel Horizon dal gennaio 2026: classificazione di email ricevute, arricchimento di note CRM, generazione di bozze di risposta, summarization di documenti, batch di embedding. L'infrastruttura è un Hetzner CCX33 (8 vCPU AMD EPYC 9454P, 32 GB RAM DDR5, 240 GB NVMe) su Debian 12, stack Laravel 12 su PHP 8.3, Redis 7 come driver di coda, MariaDB 11 per il ledger di costo, Claude Sonnet 4.6 come modello primario via Anthropic SDK PHP. Nei primi due mesi di rodaggio ho visto tre classi di incidente che hanno riscritto completamente il mio setup iniziale: uno picco di rate limit 429 che ha saturato i retry e bloccato la coda per 40 minuti il 14 gennaio, un caso di timeout a cascata su un batch da 800 summarization il 22 gennaio che ha bruciato 180 minuti di worker senza produrre nulla, un job che ha consumato da solo 6 dollari di API Anthropic in una notte il 31 gennaio per un retry loop su errore fatale mal classificato. Ogni incidente ha prodotto una riga nella checklist che segue. Non è teoria: è la lista degli ingegneri non ho capito al primo tentativo e che, dopo averli capiti, non sbaglio più.

Perché le chiamate LLM sincrone dal controller sono un anti-pattern garantito?

La risposta in una frase è che una chiamata LLM ha il profilo di latenza, costo e tasso di errore di una long-running remote procedure, e infilarla dentro un ciclo HTTP PHP-FPM produce quattro problemi che si manifestano tutti entro la prima settimana di produzione. Problema uno: latenza percepita dall'utente. Una chiamata Claude Sonnet 4.6 con 1.500 token di output dura 8-14 secondi medi, e l'utente vede lo spinner per tutto quel tempo. Problema due: blocco del worker PHP-FPM. Mentre il worker aspetta Anthropic, non serve altre richieste; con 50 worker e 50 chat attive, l'intera applicazione è bloccata. Problema tre: propagazione del fallimento. Se Anthropic è lento o in 529 overloaded, l'errore risale fino al browser anche se l'operazione di dominio sarebbe stata completabile in background. Problema quattro: retry incontrollabili. L'utente esasperato ricarica la pagina, e la seconda chiamata parte senza sapere della prima - paghi due volte la stessa operazione.

La separazione sincrono/asincrono è la premessa di tutta questa checklist. Il controller HTTP non chiama MAI Claude API direttamente. Il controller valida l'input, persiste un record di job in stato queued, dispatcha un job Horizon e restituisce un job ID al client. Il client fa polling o ascolta un WebSocket per il risultato. L'intera disciplina di retry, cost tracking e timeout vive dentro il worker Horizon, dove il tempo non è prezioso come nel ciclo HTTP e dove posso permettermi di ragionare.

Se vuoi vedere come affronto pipeline AI production-grade dove ogni chiamata LLM passa da governance e observability, nel mio hub sull'automazione AI per aziende trovo articoli che descrivono lo stesso insieme di pattern applicati a use case diversi - classificazione, RAG, generazione documentazione - con criterio comune di non spendere un byte di API budget senza tracciarlo.

1. Coda dedicata llm con worker dedicati e concorrenza controllata

Il primo errore ingenuo è buttare i job LLM nella coda default insieme a invio email, notifiche push, import CSV. La conseguenza è che un picco di LLM satura il pool generale e le operazioni leggere (invio email, notifiche) restano bloccate per decine di minuti. La regola è coda dedicata con configurazione dedicata.

// config/horizon.php
'environments' => [
    'production' => [
        'supervisor-llm' => [
            'connection' => 'redis',
            'queue' => ['llm'],
            'balance' => 'simple',
            'processes' => 4,
            'tries' => 1, // gestiamo il retry a mano, non lasciamo Horizon
            'timeout' => 240,
        ],
        'supervisor-default' => [
            'connection' => 'redis',
            'queue' => ['default', 'mail', 'notifications'],
            'balance' => 'auto',
            'minProcesses' => 2,
            'maxProcesses' => 10,
            'timeout' => 60,
        ],
    ],
],

Quattro process per la coda llm è un valore derivato empiricamente: il mio rate limit su Anthropic per tier corrente è 50 richieste al minuto sul modello Sonnet, una chiamata media dura 8-12 secondi, quindi quattro worker girano al 50-60% di utilizzo senza superare il limite per burst. Salire a otto worker produce 429 a raffica. Scendere a due lascia job in coda inutilmente. Il valore giusto è calibrato sul tuo tier, non preso a caso da un blog post.

Il 'tries' => 1 sul supervisor llm è deliberato: Horizon di default riprova un job fallito. Per chiamate LLM il retry standard di Horizon è sbagliato - non distingue errori transitori da fatali, non implementa backoff, non traccia costi su retry. La politica di retry la scrivo io dentro il Job.

2. Retry intelligente: backoff esponenziale, jitter, distinzione transient/fatal

Questo è il punto 2 e anche il punto dove ho bruciato più soldi prima di capirlo. Anthropic restituisce errori in quattro classi distinte, e ogni classe ha una policy di retry diversa:

  • 429 Too Many Requests (rate limit): transient, retry con backoff, fino a 5 tentativi
  • 529 Overloaded (capacity): transient, retry con backoff più lungo, fino a 3 tentativi
  • 500/502/503 (server error): transient, retry con backoff, fino a 3 tentativi
  • 400 Bad Request (malformed prompt, context too long): fatal, ZERO retry
  • 401 Unauthorized (chiave API revocata): fatal, ZERO retry, alert immediato
  • 403 Forbidden (policy violation): fatal, ZERO retry, alert al team di sicurezza

Implemento la logica in una classe dedicata LlmRetryPolicy e la uso dal Job.

<?php
declare(strict_types=1);

namespace App\Llm;

final class LlmRetryPolicy
{
    private const BACKOFF_429 = [2, 5, 12, 30, 75]; // secondi per tentativo
    private const BACKOFF_529 = [15, 60, 180];
    private const BACKOFF_5XX = [3, 10, 30];

    public function classify(\Throwable $err): RetryDecision
    {
        $status = $this->extractHttpStatus($err);
        return match (true) {
            $status === 429 => RetryDecision::transient(self::BACKOFF_429),
            $status === 529 => RetryDecision::transient(self::BACKOFF_529),
            $status >= 500 && $status < 600 => RetryDecision::transient(self::BACKOFF_5XX),
            $status === 400 || $status === 401 || $status === 403 => RetryDecision::fatal($status),
            default => RetryDecision::fatal(0),
        };
    }

    public function computeDelay(array $schedule, int $attempt): int
    {
        $base = $schedule[min($attempt - 1, count($schedule) - 1)];
        $jitter = random_int(0, (int) ($base * 0.3));
        return $base + $jitter;
    }
}

Il jitter da 0 a 30% del backoff è cruciale: se hai quattro worker che ricevono tutti un 429 nello stesso momento (perché stai saturando il rate limit di tier) e fanno retry tutti esattamente dopo 2 secondi, si sincronizzano e si dannano a vicenda per ore. Il jitter li desincronizza. È l'equivalente del backoff-with-jitter che descrivo nell'articolo su task scheduling robusto con Laravel Horizon: l'analogia di pattern con altri sistemi di retry regge, la calibrazione dei numeri cambia perché i costi sono diversi.

3. Timeout management: distinguere retry-safe da fatal-timeout

Il 'timeout' => 240 sul supervisor significa che Horizon killa il worker se il job non finisce in 4 minuti. Per una chiamata LLM generica è una protezione sensata - una chiamata che impiega più di 4 minuti è probabilmente bloccata. Ma la distinzione importante è tra timeout lato client HTTP (la connessione Anthropic è morta) e timeout lato Horizon (il worker stesso è stato killato). I due casi hanno implicazioni diverse.

Se la connessione HTTP verso Anthropic timeouta (client-side 120 secondi nel mio caso), io NON so se la chiamata è stata completata server-side o no. Un retry cieco rischia di pagare due volte lo stesso lavoro. La soluzione è l'idempotency key supportata nativamente da Anthropic API: aggiungo un header Idempotency-Key: {uuid-v7 del job} alla richiesta. Se Anthropic vede la stessa chiave entro 24 ore, restituisce il risultato cached della prima chiamata invece di elaborare nuovamente. Costo pagato: uno. Risultato restituito: uno.

<?php
declare(strict_types=1);

namespace App\Llm;

final class AnthropicClient
{
    public function call(string $idempotencyKey, array $payload, int $timeoutSeconds = 120): AnthropicResponse
    {
        $response = Http::withHeaders([
            'x-api-key' => config('services.anthropic.key'),
            'anthropic-version' => '2023-06-01',
            'content-type' => 'application/json',
            'Idempotency-Key' => $idempotencyKey,
        ])
        ->timeout($timeoutSeconds)
        ->connectTimeout(10)
        ->post('https://api.anthropic.com/v1/messages', $payload);

        if (!$response->successful()) {
            throw AnthropicException::fromResponse($response);
        }
        return AnthropicResponse::fromArray($response->json());
    }
}

Il connectTimeout(10) è un dettaglio che il developer frettoloso dimentica: se DNS o SSL handshake impiegano più di 10 secondi, la rete ha un problema - non ha senso aspettare 120 secondi sperando che risolva. Fallisci veloce, ritenta rapidamente, e il worker si libera per altri job.

4. Cost tracking per job: dove finisce il budget LLM mese dopo mese

Se non tracci il costo per job, la fattura Anthropic di fine mese è un buco nero. La pratica che uso è: ogni job, prima di iniziare, scrive in una tabella llm_jobs il proprio job_id, il user_id (se applicabile), il feature (stringa che categorizza l'operazione: email-classify, crm-enrich, report-summary), il model, e il started_at. Alla fine, aggiorna input_tokens, output_tokens, cached_tokens, cost_usd, completed_at, status.

<?php
// App\Jobs\ClassifyEmailLlmJob::handle()
public function handle(AnthropicClient $client, CostCalculator $calc, LlmJobLedger $ledger): void
{
    $ledger->markStarted($this->jobUuid, 'email-classify', 'claude-sonnet-4-6', $this->userId);

    try {
        $response = $client->call($this->jobUuid, $this->payload, timeoutSeconds: 60);
        $cost = $calc->compute('claude-sonnet-4-6', $response->usage);
        $ledger->markCompleted($this->jobUuid, $response->usage, $cost);
        $this->onSuccess($response);
    } catch (AnthropicException $e) {
        $this->handleFailure($e, $ledger);
    }
}

Con questo ledger posso rispondere a domande operative concrete che senza sono indovinelli. Quale feature mi costa di più? Quale utente ha consumato 40% del budget mensile? C'è un feature che ha avuto un picco inspiegabile la scorsa settimana? Su un cruscotto Grafana connesso a MariaDB vedo in tempo reale ogni job, il costo corrispondente, e posso impostare alert budget - "se email-classify supera 2€/giorno, svegliami".

5. Dead letter queue e alerting su anomalie

Un job che ha esaurito i retry per errore transitorio o ha fallito su errore fatale deve finire in una dead letter queue persistente, NON essere semplicemente loggato. La failed_jobs table di Laravel è sufficiente se ben usata: Horizon la popola nativamente, e su Horizon UI vedo ogni failed job con il suo stack trace, il payload originale, i tentativi fatti. Quello che aggiungo è una classificazione dei failure per severity, esposta via Prometheus.

<?php
// App\Llm\FailureClassifier
public function classify(\Throwable $err, string $jobClass): FailureSeverity
{
    $status = $this->extractHttpStatus($err);
    return match (true) {
        $status === 401 || $status === 403 => FailureSeverity::CRITICAL, // keypair compromessa o policy
        $status === 400 && str_contains($err->getMessage(), 'context length') => FailureSeverity::WARNING,
        $err instanceof BudgetExceededException => FailureSeverity::WARNING,
        default => FailureSeverity::ERROR,
    };
}

Gli alert che uso hanno tre livelli. Slack #llm-alerts per WARNING (budget esaurito, prompt troppo lungo - non urgente, da vedere in giornata). Slack #llm-oncall per ERROR con tag @here per failure che richiedono indagine immediata. PagerDuty per CRITICAL (auth revocata, policy violation) - questi svegliano chi di dovere a qualsiasi ora. Senza questa graduazione, gli alert diventano rumore e tutti li ignorano dopo due settimane.

6. Deploy senza downtime dei worker e graceful shutdown dei job in corso

Il deploy di una versione nuova di Laravel killa i worker in esecuzione. Su job HTTP da 200ms questo non è un problema. Su job LLM da 30 secondi significa: killi un worker a metà di una chiamata Claude da 0,08 dollari, il job ricomincia da capo al restart, paghi due volte. La soluzione è il supervisorctl pattern e php artisan horizon:terminate:

# Sul server di produzione, durante il deploy
php artisan horizon:terminate
# Horizon lascia finire i job in corso (fino a grace period, default 300s)
# poi termina pulito. Supervisor riavvia Horizon sulla nuova versione.

Il horizon:terminate dà ai worker il segnale di finire il job corrente e poi uscire. Fino a che il job è in esecuzione, il process non muore. Questo richiede che timeout + grace period del supervisor siano coerenti: se timeout job è 240s e stopwaitsecs di supervisord è 120s, supervisord manda SIGKILL prima che il job finisca. Nel mio setup, stopwaitsecs=360 e timeout=240 - il grace è sempre più largo del timeout massimo atteso di un job.

Durante il periodo di grace, nuovi job finiscono nella coda Redis in attesa. Al restart della versione nuova, i worker li prendono in carico. Zero job persi, zero double-billing, deploy continuo. È il pattern che raccomando per qualunque worker che interagisce con API esterne a pagamento - non è specifico a LLM ma con LLM il costo del fare male diventa visibile nella bolletta.

7. Test di resilienza: inject failure per vedere cosa regge davvero

L'ultimo checkpoint è il più trascurato. Configurare retry, backoff e DLQ senza testarli è come avere un piano di disaster recovery che nessuno ha mai esercitato. Uso un test environment separato dove periodicamente inietto failure artificiali nel client Anthropic: un mock che restituisce 429 su 20% delle chiamate per 5 minuti, un mock che genera 529 per 2 minuti, un mock che spegne la connessione a metà risposta. Il test verifica che: nessun job viene perso, nessun cost counter diventa negativo, le metriche Prometheus contabilizzano correttamente gli errori, gli alert scattano al livello giusto.

Il pattern è quello della pipeline di streaming Node.js per chat AI descritta nel mio articolo: lì il test è sulla reconnection SSE lato client, qui il test è sul retry lato server. La classe di disciplina è la stessa - non ti fidi della configurazione finché non l'hai rotta deliberatamente.

Quando questo pattern è sproporzionato

Se fai meno di 200 chiamate LLM al giorno e la tua applicazione è un prototipo interno, tutto questo è over-engineering: un semplice Queue::dispatch con retry di default e logging va benissimo. Se il tuo use case è puramente conversazionale - una chat che streama token al browser - il broker async aggiunge latenza che ammazza il time-to-first-token: serve un pattern diverso, che ho descritto nell'articolo sullo streaming real-time di LLM con Node.js e TypeScript. Se stai usando LLM self-hosted via Ollama invece di API esterne, il problema del cost tracking scompare ma quello del timeout peggiora: un modello che va in CUDA OOM può restare bloccato indefinitamente - la checklist resta valida, cambiano solo i valori numerici.

Il pattern Horizon + retry classificato + cost ledger + DLQ con alerting si giustifica quando hai contemporaneamente: volume superiore ai 1.000 job LLM/giorno, budget Anthropic (o equivalente) oltre i 100 euro al mese, SLA interno sulla consegna dei risultati (es. "l'email deve essere classificata entro 5 minuti dall'arrivo"), e un team che si sveglia di notte se qualcosa si rompe. Senza questi requisiti, spendi più tempo a configurare retry policy di quanto ne spenderesti a risolvere i failure a mano.

L'errore più frequente che vedo fare sui sistemi LLM in produzione non è uno dei sette punti qui sopra preso singolarmente. È l'assenza completa di observability: sistemi che girano per mesi senza che nessuno sappia quanti job falliscono, quanto costano davvero, quanto tempo impiegano. Quando poi la bolletta Anthropic arriva doppia del previsto, o un cliente si lamenta che un'email non è stata classificata, l'unica risposta del team è "guardiamo nei log" - e i log sono un disastro senza correlation ID, senza cost stamp, senza severity. La checklist che hai letto non aggiunge complessità per il gusto di aggiungerla: aggiunge superfici osservabili in ogni punto in cui qualcosa può andare storto, perché sapere che qualcosa sta andando storto è la premessa necessaria per poterci rimediare.

Se stai pianificando o già stai gestendo una pipeline LLM asincrona in Laravel e vuoi un audit della tua configurazione Horizon, retry policy e cost tracking, il modulo di preventivo gratuito ti dà una prima lettura in 7 domande, 2 minuti. Ti dico se il tuo progetto rientra nelle cose che so fare bene e, se il caso richiede un profilo diverso, te lo dico e ti indico una direzione utile quando posso.

Ultima modifica: