An HTTP client that is part of the framework

The application talked to four internal services and had four different ways of doing it. One used Guzzle directly, one had a wrapper, one used file_get_contents with a stream context, and one had a class that had started as a wrapper and grown a retry loop, a logger and a circuit breaker. None of them had the same timeout.

The symptom

$ grep -rn 'new Client(|file_get_contents(.http|curl_init' app/ | wc -l
17

$ grep -rn "'timeout'" app/Services/ | sed 's/.*timeout.//' | sort | uniq -c
      4  => 30,
      2  => 5,
      1  => 60,
      3  => 0,          ← no timeout at all

$ grep -rln 'retry' app/Services/
app/Services/PricingClient.php     # 3 attempts, 1s apart
app/Services/StockClient.php       # 5 attempts, exponential

Three call sites with no timeout is the finding that mattered: an upstream that stops responding holds a PHP-FPM worker until the socket gives up, which on a default configuration is minutes. Four different retry policies is the second — none of them written down, all of them discovered by reading.

Why it happens

An HTTP call is three lines and a wrapper feels like ceremony, so the first one is written inline. The second is copied from the first, and by the fourth the copies have diverged because each was adjusted for its own upstream. Nothing about that process is unreasonable and the result is a codebase with no policy.

The reason a framework shipping one changes this is not that it is a better client. It is that a default exists — a new call written next week uses the framework’s client because it is there, and the defaults it inherits are the ones somebody decided rather than the library’s.

The fix

The fluent API, and the defaults in one place

$response = Http::withToken($token)
    ->timeout(3)
    ->connectTimeout(1)
    ->retry(3, 100)
    ->acceptJson()
    ->post('https://pricing.internal/quotes', ['sku' => $sku]);

if ($response->failed()) {
    throw new PricingUnavailable($response->status());
}

return $response->json('cents');

The separate connect timeout is the setting most often missing and the one that matters most: an unreachable host should fail in a second rather than at the full read timeout. Three seconds rather than thirty is the other change — thirty was never a real number, no user waits that long, and the only thing it bought was a worker held for half a minute.

failed(), clientError() and serverError() exist because the client does not throw by default, which is the same decision fetch made and is worth knowing before a 500 flows through as data. throw() is available for the call sites that want the exception.

A macro per service, so the configuration is named

// AppServiceProvider::boot()
Http::macro('pricing', function () {
    return Http::baseUrl(config('services.pricing.url'))
        ->withToken(config('services.pricing.token'))
        ->timeout(3)
        ->connectTimeout(1)
        ->retry(3, 100, fn($e) => $e instanceof ConnectionException)
        ->acceptJson();
});

// every call site, everywhere
Http::pricing()->post('/quotes', ['sku' => $sku]);

This is the piece that actually solved the original problem: the timeout, the token and the retry policy for a service are declared once, in one file, and changing them is one edit rather than seventeen. The macro is a global mutation of a shared class, which is the usual objection — and confining it to one provider with a short list of well-named services is what keeps it legible.

The retry predicate is the part worth being deliberate about. Retrying on any request exception includes a 500, which is usually right, and includes a 422, which is three identical failures and a slower error.

Faking it, without a mock server

public function testAQuoteIsFetched(): void
{
    Http::fake([
        'pricing.internal/*' => Http::response(['cents' => 4900], 200),
    ]);

    $this->postJson('/quote', ['sku' => 'FR-100'])
        ->assertOk()
        ->assertJsonPath('cents', 4900);

    Http::assertSent(fn($request) =>
        $request->url() === 'https://pricing.internal/quotes'
        && $request['sku'] === 'FR-100');
}

The assertion on the outgoing request is the half people skip, and without it the test passes when the wrong URL is called with the wrong body. An unmatched URL returns an empty 200 by default, which is a silent pass — adding a catch-all fake that throws is the arrangement that makes a stray request loud.

// in the base TestCase, so no test can reach the network
protected function setUp(): void
{
    parent::setUp();

    Http::fake([
        '*' => fn() => throw new RuntimeException('unfaked HTTP request'),
    ]);
}

A suite that can reach the network is a suite that fails when somebody else’s staging environment is down, and that failure is attributed to whatever changed most recently. Blocking it globally in the base class is ten lines and removes an entire category of flakiness.

Concurrency, and where the pool helps

$responses = Http::pool(fn(Pool $pool) => [
    $pool->as('pricing')->get('https://pricing.internal/quotes/FR-100'),
    $pool->as('stock')->get('https://stock.internal/levels/FR-100'),
    $pool->as('reviews')->get('https://reviews.internal/FR-100'),
]);

$cents = $responses['pricing']->json('cents');

// three sequential calls at 180ms: 540ms
// the same three concurrently:      190ms

Naming the responses with as is what makes the result readable; the positional form works and produces code where $responses[1] means something only to whoever wrote it. The pool is worth reaching for whenever a page assembles data from several independent services, which is most of a product page in a service-oriented system.

It does not help when the calls are dependent, and a pool of one is overhead. The other limit is that a failure in one response does not affect the others, so each has to be checked — which is correct and is more code than the sequential version.

Verifying it worked

$ grep -rn 'new Client(|file_get_contents(.http|curl_init' app/ | wc -l
0

$ grep -rn 'Http::macro' app/Providers/AppServiceProvider.php | wc -l
4

# the timeout, proven rather than configured
$ php artisan tinker
>>> Http::pricing()->get('http://blackhole.internal/');
[3.02s] ConnectionException: cURL error 28: Operation timed out

$ php artisan test
Tests: 1,284 passed        # with the network blocked entirely

Pointing a client at a host that accepts connections and never responds is the test worth doing by hand once, because a timeout that is configured and not applied is a common and invisible failure. Three seconds and an exception is the correct outcome; anything longer means the macro is not being used where you think.

What this costs

A facade, in a codebase that was trying to avoid them. Http::pricing() is a static call to a global, which is untestable in the pure sense and is testable in the sense that matters because the framework provides the fake. That is a genuine architectural compromise and it is worth naming rather than pretending — the alternative is injecting a client interface everywhere, which is what the four hand-rolled wrappers were doing badly.

The second cost is that it is Guzzle underneath, so the dependency did not go away and the version conflicts it can cause did not either. What changed is that there is now one place where the version matters rather than seventeen. For a package rather than an application the right answer remains PSR-18, and this is an application-level convenience that a library should not adopt.