Networking con Laravel

Networking con Laravel

In un'applicazione Laravel il networking si presenta quasi sempre sotto forma di chiamate verso servizi esterni: API di terze parti, microservizi interni, webhook, gateway di pagamento. Il framework offre un client HTTP costruito sopra Guzzle che rende queste operazioni espressive e testabili, ma un'integrazione davvero robusta richiede di affrontare con metodo timeout, ritentativi, concorrenza, osservabilità e test. In questo articolo costruiremo passo dopo passo un livello di integrazione di rete solido, per poi spingerci oltre HTTP con un comando Artisan per la verifica di servizi TCP e DNS.

Il client HTTP di Laravel in breve

La facade Http restituisce un oggetto PendingRequest configurabile in modo fluente. A differenza di Guzzle usato direttamente, gli errori HTTP (status 4xx e 5xx) non sollevano eccezioni per impostazione predefinita: è lo sviluppatore a decidere come gestirli.

use Illuminate\Support\Facades\Http;

$response = Http::acceptJson()
    ->withToken(config('services.inventory.token'))
    ->get('https://inventory.example.com/api/products', [
        'page' => 1,
        'per_page' => 50,
    ]);

if ($response->successful()) {
    $products = $response->json('data');
}

// Metodi di ispezione disponibili sulla risposta
$response->status();
$response->clientError();
$response->serverError();
$response->header('X-RateLimit-Remaining');

Questa sintassi è comoda, ma disseminare chiamate Http:: nei controller porta rapidamente a configurazioni duplicate e incoerenti. Il primo passo verso un'integrazione solida è centralizzare.

Timeout: il primo requisito

Il timeout predefinito del client HTTP di Laravel è di 30 secondi. In una richiesta web sincrona è un'eternità: se il servizio remoto rallenta, i worker PHP-FPM restano occupati in attesa e l'intera applicazione può diventare irraggiungibile. Laravel distingue due timeout:

  • connectTimeout(): il tempo massimo per stabilire la connessione TCP e completare l'handshake TLS.
  • timeout(): il tempo massimo complessivo dell'intera richiesta, compresa la ricezione della risposta.
$response = Http::connectTimeout(2)
    ->timeout(5)
    ->get('https://inventory.example.com/api/health');

Un timeout di connessione breve è quasi sempre corretto: se un host non accetta la connessione entro un paio di secondi, è molto probabile che non lo farà affatto. Il timeout complessivo, invece, va calibrato sulle caratteristiche dell'endpoint.

Configurazione centralizzata con le macro

Le macro permettono di registrare client preconfigurati, uno per ogni servizio esterno. La configurazione vive in config/services.php:

// config/services.php
return [
    // ...
    'inventory' => [
        'base_url' => env('INVENTORY_BASE_URL', 'https://inventory.example.com/api'),
        'token' => env('INVENTORY_TOKEN'),
        'connect_timeout' => (int) env('INVENTORY_CONNECT_TIMEOUT', 2),
        'timeout' => (int) env('INVENTORY_TIMEOUT', 5),
        'retries' => (int) env('INVENTORY_RETRIES', 3),
    ],
];

La macro viene registrata in un service provider:

namespace App\Providers;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;

class HttpClientServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Http::macro('inventory', function (): PendingRequest {
            $config = config('services.inventory');

            // Client preconfigurato per il servizio di magazzino
            return Http::baseUrl($config['base_url'])
                ->withToken($config['token'])
                ->acceptJson()
                ->asJson()
                ->connectTimeout($config['connect_timeout'])
                ->timeout($config['timeout'])
                ->withUserAgent(config('app.name') . '/1.0');
        });
    }
}

Da questo momento qualsiasi parte dell'applicazione può usare Http::inventory()->get('/products') ereditando automaticamente URL di base, autenticazione e timeout.

Ritentativi con backoff

I guasti di rete sono spesso transitori: un pacchetto perso, un bilanciatore che riavvia un nodo, un servizio momentaneamente sovraccarico. Il metodo retry() ripete la richiesta in caso di errore, ma va usato con giudizio. Ritentare indiscriminatamente un errore 422 è inutile, e ritentare una richiesta POST non idempotente può generare duplicati.

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Throwable;

$response = Http::inventory()
    ->retry(
        // Attese crescenti tra un tentativo e l'altro (in millisecondi)
        [200, 500, 1000],
        when: function (Throwable $exception, PendingRequest $request): bool {
            // Si ritenta solo per errori di connessione o errori lato server
            if ($exception instanceof ConnectionException) {
                return true;
            }

            return $exception instanceof RequestException
                && ($exception->response->serverError() || $exception->response->status() === 429);
        },
        throw: false
    )
    ->get('/products');

Passando un array come primo argomento, Laravel interpreta ogni elemento come l'attesa prima del tentativo successivo, ottenendo un backoff esponenziale. Con throw: false, al termine dei tentativi viene restituita l'ultima risposta invece di sollevare un'eccezione.

Per le richieste non idempotenti la soluzione corretta è una chiave di idempotenza, se il servizio remoto la supporta:

use Illuminate\Support\Str;

$idempotencyKey = (string) Str::uuid();

// La stessa chiave viene inviata a ogni tentativo: il server elabora l'ordine una sola volta
$response = Http::inventory()
    ->withHeaders(['Idempotency-Key' => $idempotencyKey])
    ->retry(3, 300, throw: false)
    ->post('/orders', $payload);

Gestione degli errori in un client dedicato

Anche con le macro, il codice applicativo non dovrebbe conoscere i dettagli HTTP del servizio remoto. Una classe client dedicata traduce le risposte in oggetti di dominio e gli errori di rete in eccezioni significative.

namespace App\Services\Inventory;

use App\Services\Inventory\Exceptions\InventoryUnavailableException;
use App\Services\Inventory\Exceptions\ProductNotFoundException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class InventoryClient
{
    public function findProduct(string $sku): ProductData
    {
        try {
            $response = Http::inventory()
                ->retry([200, 500], throw: false)
                ->get("/products/{$sku}");
        } catch (ConnectionException $e) {
            // Host irraggiungibile, timeout o errore TLS
            throw new InventoryUnavailableException('Servizio di magazzino non raggiungibile', previous: $e);
        }

        if ($response->notFound()) {
            throw new ProductNotFoundException("Prodotto {$sku} non trovato");
        }

        try {
            $response->throw();
        } catch (RequestException $e) {
            throw new InventoryUnavailableException(
                "Errore del servizio di magazzino: HTTP {$response->status()}",
                previous: $e
            );
        }

        return ProductData::fromArray($response->json('data'));
    }

    public function stockLevel(string $sku): int
    {
        return (int) Http::inventory()
            ->get("/products/{$sku}/stock")
            ->throw()
            ->json('data.quantity', 0);
    }
}

Il DTO restituito isola il resto dell'applicazione dal formato JSON del servizio:

namespace App\Services\Inventory;

final readonly class ProductData
{
    public function __construct(
        public string $sku,
        public string $name,
        public int $priceInCents,
        public bool $available,
    ) {
    }

    public static function fromArray(array $data): self
    {
        return new self(
            sku: $data['sku'],
            name: $data['name'],
            priceInCents: (int) $data['price_cents'],
            available: (bool) $data['available'],
        );
    }
}

Richieste concorrenti con Http::pool

Quando una pagina deve aggregare dati da più servizi, eseguire le richieste in sequenza somma le latenze. Con Http::pool() le richieste vengono inviate contemporaneamente sfruttando il multiplexing di cURL, e il tempo totale si avvicina a quello della richiesta più lenta.

use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Http;

$responses = Http::pool(fn (Pool $pool) => [
    $pool->as('products')
        ->connectTimeout(2)->timeout(5)
        ->get('https://inventory.example.com/api/products'),
    $pool->as('orders')
        ->connectTimeout(2)->timeout(5)
        ->get('https://orders.example.com/api/orders/recent'),
    $pool->as('shipping')
        ->connectTimeout(2)->timeout(5)
        ->get('https://shipping.example.com/api/status'),
]);

// Una richiesta fallita a livello di connessione restituisce un'eccezione invece di una risposta
$products = $responses['products'] instanceof \Illuminate\Http\Client\Response
    && $responses['products']->successful()
        ? $responses['products']->json('data')
        : [];

All'interno del pool le macro registrate sulla facade non sono disponibili, per cui le opzioni vanno ripetute oppure estratte in una closure di configurazione riutilizzabile. È importante anche gestire il caso in cui un elemento del pool non sia una risposta ma un'eccezione di connessione.

Osservabilità: eventi e middleware

Il client HTTP emette eventi che possono essere ascoltati per registrare metriche e log: RequestSending, ResponseReceived e ConnectionFailed. Un listener può misurare i tempi di risposta e segnalare le chiamate lente:

namespace App\Listeners;

use Illuminate\Http\Client\Events\ConnectionFailed;
use Illuminate\Http\Client\Events\ResponseReceived;
use Illuminate\Support\Facades\Log;

class LogOutgoingHttpRequests
{
    public function handleResponse(ResponseReceived $event): void
    {
        $stats = $event->response->handlerStats();
        $totalTime = round(($stats['total_time'] ?? 0) * 1000);

        $context = [
            'method' => $event->request->method(),
            'url' => $event->request->url(),
            'status' => $event->response->status(),
            'duration_ms' => $totalTime,
            // Tempi di dettaglio forniti da cURL
            'dns_ms' => round(($stats['namelookup_time'] ?? 0) * 1000),
            'connect_ms' => round(($stats['connect_time'] ?? 0) * 1000),
        ];

        if ($totalTime > 1000) {
            Log::warning('Richiesta HTTP lenta', $context);
            return;
        }

        Log::debug('Richiesta HTTP completata', $context);
    }

    public function handleFailure(ConnectionFailed $event): void
    {
        Log::error('Connessione HTTP fallita', [
            'method' => $event->request->method(),
            'url' => $event->request->url(),
        ]);
    }

    public function subscribe(): array
    {
        return [
            ResponseReceived::class => 'handleResponse',
            ConnectionFailed::class => 'handleFailure',
        ];
    }
}

I campi namelookup_time e connect_time di handlerStats() sono preziosi per la diagnosi: permettono di capire se la lentezza dipende dal DNS, dalla connessione TCP o dal tempo di elaborazione del server remoto.

Per aggiungere header a tutte le richieste in uscita, come un identificativo di correlazione utile al tracing distribuito, si possono usare i middleware globali:

use Illuminate\Support\Facades\Context;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\RequestInterface;

// In AppServiceProvider::boot()
Http::globalRequestMiddleware(
    fn (RequestInterface $request) => $request->withHeader(
        'X-Request-Id',
        Context::get('request_id', 'unknown')
    )
);

Proteggere l'applicazione con un circuit breaker

I ritentativi aiutano con i guasti brevi, ma diventano dannosi quando un servizio è giù per minuti: ogni richiesta attende inutilmente il timeout, moltiplicato per il numero di tentativi. Un circuit breaker interrompe le chiamate dopo un certo numero di fallimenti e le riprende dopo un periodo di raffreddamento. Con la cache di Laravel se ne può implementare uno minimale:

namespace App\Support;

use Closure;
use Illuminate\Support\Facades\Cache;
use RuntimeException;
use Throwable;

class CircuitBreaker
{
    public function __construct(
        private readonly string $service,
        private readonly int $failureThreshold = 5,
        private readonly int $cooldownSeconds = 60,
    ) {
    }

    public function call(Closure $callback): mixed
    {
        if ($this->isOpen()) {
            throw new RuntimeException("Circuito aperto per il servizio {$this->service}");
        }

        try {
            $result = $callback();
            // Una chiamata riuscita azzera il contatore dei fallimenti
            Cache::forget($this->failuresKey());

            return $result;
        } catch (Throwable $e) {
            $this->recordFailure();
            throw $e;
        }
    }

    private function isOpen(): bool
    {
        return Cache::has($this->openKey());
    }

    private function recordFailure(): void
    {
        $failures = Cache::increment($this->failuresKey());

        if ($failures >= $this->failureThreshold) {
            // Apertura del circuito per il periodo di raffreddamento
            Cache::put($this->openKey(), true, $this->cooldownSeconds);
            Cache::forget($this->failuresKey());
        }
    }

    private function failuresKey(): string
    {
        return "circuit:{$this->service}:failures";
    }

    private function openKey(): string
    {
        return "circuit:{$this->service}:open";
    }
}
$breaker = new CircuitBreaker('inventory');

$product = $breaker->call(fn () => app(InventoryClient::class)->findProduct('SKU-001'));

Con un driver di cache condiviso come Redis, lo stato del circuito è comune a tutti i worker e a tutti i server dell'applicazione.

Oltre HTTP: verifica di servizi TCP e DNS con Artisan

Non tutto il networking di un'applicazione passa per HTTP. Database, Redis, server SMTP e code di messaggi parlano protocolli propri su TCP. Un comando Artisan che verifica la raggiungibilità di queste dipendenze è utile in fase di deploy e nei controlli di salute.

namespace App\Console\Commands;

use Illuminate\Console\Command;

class CheckNetworkDependencies extends Command
{
    protected $signature = 'network:check {--timeout=2 : Timeout di connessione in secondi}';

    protected $description = 'Verifica la raggiungibilità di rete delle dipendenze dell\'applicazione';

    public function handle(): int
    {
        $timeout = (float) $this->option('timeout');
        $rows = [];
        $failures = 0;

        foreach ($this->dependencies() as $name => [$host, $port]) {
            $resolved = $this->resolve($host);
            [$reachable, $latency, $error] = $this->probe($host, $port, $timeout);

            if (! $reachable) {
                $failures++;
            }

            $rows[] = [
                $name,
                "{$host}:{$port}",
                $resolved ?: 'non risolto',
                $reachable ? '<info>OK</info>' : '<error>KO</error>',
                $reachable ? "{$latency} ms" : $error,
            ];
        }

        $this->table(['Servizio', 'Endpoint', 'IP', 'Stato', 'Dettaglio'], $rows);

        return $failures === 0 ? self::SUCCESS : self::FAILURE;
    }

    /**
     * @return array<string, array{0: string, 1: int}>
     */
    private function dependencies(): array
    {
        // Gli endpoint vengono letti dalla configurazione esistente
        return [
            'database' => [config('database.connections.' . config('database.default') . '.host'), (int) config('database.connections.' . config('database.default') . '.port')],
            'redis' => [config('database.redis.default.host'), (int) config('database.redis.default.port')],
            'smtp' => [config('mail.mailers.smtp.host'), (int) config('mail.mailers.smtp.port')],
        ];
    }

    private function resolve(string $host): ?string
    {
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            return $host;
        }

        $ip = gethostbyname($host);

        // gethostbyname restituisce il nome invariato in caso di errore
        return $ip === $host ? null : $ip;
    }

    /**
     * @return array{0: bool, 1: int, 2: string}
     */
    private function probe(string $host, int $port, float $timeout): array
    {
        $start = hrtime(true);

        $socket = @stream_socket_client("tcp://{$host}:{$port}", $errorCode, $errorMessage, $timeout);

        $latency = (int) round((hrtime(true) - $start) / 1_000_000);

        if ($socket === false) {
            return [false, $latency, trim($errorMessage) ?: "errore {$errorCode}"];
        }

        fclose($socket);

        return [true, $latency, ''];
    }
}

Il comando restituisce un codice di uscita diverso da zero se una dipendenza non è raggiungibile, così da poter essere usato direttamente in una pipeline di deploy:

php artisan network:check --timeout=3 || exit 1

Test senza rete: Http::fake e preventStrayRequests

Un test che effettua richieste reali verso servizi esterni è lento, instabile e potenzialmente pericoloso. Laravel permette di intercettare tutte le richieste con Http::fake() e di impedire quelle non previste con Http::preventStrayRequests().

use App\Services\Inventory\Exceptions\InventoryUnavailableException;
use App\Services\Inventory\Exceptions\ProductNotFoundException;
use App\Services\Inventory\InventoryClient;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;

beforeEach(function () {
    // Qualsiasi richiesta non simulata fa fallire il test
    Http::preventStrayRequests();
});

it('restituisce il prodotto quando il servizio risponde correttamente', function () {
    Http::fake([
        'inventory.example.com/api/products/SKU-001' => Http::response([
            'data' => ['sku' => 'SKU-001', 'name' => 'Tastiera', 'price_cents' => 4990, 'available' => true],
        ]),
    ]);

    $product = app(InventoryClient::class)->findProduct('SKU-001');

    expect($product->name)->toBe('Tastiera')
        ->and($product->priceInCents)->toBe(4990);

    Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization')
        && $request->url() === 'https://inventory.example.com/api/products/SKU-001');
});

it('solleva un errore di dominio per un prodotto inesistente', function () {
    Http::fake(['*' => Http::response(null, 404)]);

    app(InventoryClient::class)->findProduct('SKU-404');
})->throws(ProductNotFoundException::class);

it('ritenta dopo un errore del server e poi riesce', function () {
    // Sequenza di risposte: prima un 503, poi un 200
    Http::fake([
        '*' => Http::sequence()
            ->push(null, 503)
            ->push(['data' => ['sku' => 'SKU-001', 'name' => 'Mouse', 'price_cents' => 1990, 'available' => true]]),
    ]);

    $product = app(InventoryClient::class)->findProduct('SKU-001');

    expect($product->name)->toBe('Mouse');
    Http::assertSentCount(2);
});

it('converte gli errori di connessione in eccezioni di dominio', function () {
    Http::fake(fn () => throw new ConnectionException('Connessione rifiutata'));

    app(InventoryClient::class)->findProduct('SKU-001');
})->throws(InventoryUnavailableException::class);

Abilitare preventStrayRequests() globalmente nella classe base dei test è una pratica consigliata: garantisce che nessuna richiesta reale sfugga, anche quando si aggiunge una nuova integrazione dimenticando di simularla.

Buone pratiche

  • Abbassare i timeout predefiniti: 30 secondi sono troppi per una richiesta sincrona. Impostare sempre sia connectTimeout() sia timeout().
  • Ritentare solo ciò che ha senso ritentare: errori di connessione, 5xx e 429. Mai le richieste non idempotenti senza una chiave di idempotenza.
  • Spostare nelle code le chiamate lente: se l'utente non ha bisogno della risposta immediata, un job con i propri ritentativi è più resiliente di una richiesta sincrona.
  • Centralizzare la configurazione con macro e classi client dedicate, mantenendo i dettagli HTTP fuori dal codice di dominio.
  • Misurare: senza metriche su latenza ed errori, i problemi di rete vengono scoperti solo quando gli utenti li segnalano.

Conclusioni

Il client HTTP di Laravel rende semplice effettuare richieste, ma la robustezza nasce dalle scelte che lo circondano: timeout adeguati, ritentativi mirati, isolamento tramite classi dedicate, protezione con un circuit breaker, osservabilità tramite eventi e test che non toccano mai la rete. Affiancando a tutto questo strumenti di verifica delle dipendenze TCP, si ottiene un'applicazione capace di degradare con eleganza quando la rete, inevitabilmente, si comporta in modo imprevisto.