DEV Community

Tuğrul Yıldırım
Tuğrul Yıldırım

Posted on

Calling Flaky Third-Party APIs from Laravel: Timeouts, Retries and a Circuit Breaker

The screen that broke was not the one that called the API.

A few years ago I worked on a business app that pulled exchange rates and shipping quotes from two external services. One afternoon the shipping provider started answering slowly: not down, just slow. Every request hung for the default 30 seconds. PHP-FPM workers piled up waiting, and within minutes the order list, which never talked to that provider, stopped loading too, because there were no free workers left to serve it.

Nothing in our code was wrong in the usual sense. We simply had no opinion about how long to wait, when to try again, and when to give up. This article is that opinion, written down for Laravel's HTTP client.

The problem: defaults are not a strategy

Laravel's HTTP client is a thin, pleasant wrapper around Guzzle. Out of the box:

  • A request waits up to 30 seconds for a response, and up to 10 seconds to connect.
  • It does not retry.
  • It does not throw on 4xx/5xx responses. You get a Response object and must check it yourself.

Each of those is reasonable on its own. Together, in a web request that calls a slow partner API, they mean one slow dependency can hold a worker for half a minute, return a 500 body that your code treats as data, and never try again even when the failure was a one-off blip.

What we want instead:

  1. Short, explicit timeouts that match what the caller can afford.
  2. Retries only for failures that retrying can fix (connection errors, 429, 5xx), never for 422 Unprocessable Entity.
  3. Backoff with jitter, and respect for Retry-After.
  4. Safe retries for writes, using an idempotency key.
  5. A circuit breaker, so when the partner is clearly down we stop hammering it and fail fast.
  6. Tests that prove all of this without touching the network.

Step 1: one configured client, defined once

Do not sprinkle Http::get(...) with different options across controllers. Define a macro in AppServiceProvider::boot() so every call to the partner shares the same rules:

<?php

namespace App\Providers;

use App\Support\RetryDelay;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;
use Throwable;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Http::macro('shipping', function (): PendingRequest {
            return Http::baseUrl(config('services.shipping.url'))
                ->withToken(config('services.shipping.token'))
                ->acceptJson()
                ->connectTimeout(2)
                ->timeout(5)
                ->retry(
                    3,
                    fn (int $attempt, \Exception $e) => RetryDelay::forAttempt($attempt, $e),
                    fn (Throwable $e, PendingRequest $request) => RetryDelay::isRetryable($e),
                );
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the call site is boring, which is the goal:

$quote = Http::shipping()
    ->get('/v1/quotes', ['from' => 'IST', 'to' => 'HAM', 'kg' => 120])
    ->json();
Enter fullscreen mode Exit fullscreen mode

Two details matter here. First, the timeouts: 2 seconds to connect and 5 to respond is a budget, not a guess. Ask "how long can the user wait for this screen?" and work backwards. Second, retry() with the default throw: true means that if every attempt fails, Laravel throws a RequestException (or a ConnectionException for network failures). You will not silently treat an error page as JSON.

Step 2: decide what is worth retrying

The retry method's third argument is a callable that decides whether another attempt should happen. Laravel calls it with the exception for the failed attempt: a ConnectionException for timeouts and DNS failures, or a RequestException (which carries ->response) for 4xx/5xx replies.

<?php

namespace App\Support;

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

final class RetryDelay
{
    public static function isRetryable(Throwable $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true; // timeout, refused connection, DNS hiccup
        }

        if ($e instanceof RequestException) {
            $status = $e->response->status();

            return $status === 429 || $status >= 500;
        }

        return false;
    }

    public static function forAttempt(int $attempt, \Exception $e): int
    {
        // Respect Retry-After (seconds form only), capped at 5s.
        if ($e instanceof RequestException) {
            $retryAfter = $e->response->header('Retry-After');

            if (ctype_digit($retryAfter)) {
                return min((int) $retryAfter, 5) * 1000;
            }
        }

        // Exponential backoff with "full jitter": random between 0 and 200ms * 2^(attempt-1)
        $base = (int) config('services.shipping.retry_base_ms', 200);
        $cap = $base * (2 ** ($attempt - 1));

        return random_int(0, $cap);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why exclude most 4xx? Because they describe your request. A 422 will be a 422 on the third try too, and a 401 needs a fresh token, not patience. Retrying them just multiplies traffic and delays the real error.

Why jitter? If 50 workers all fail at the same second and all wait exactly 200 ms, they all come back at the same moment and knock the recovering service over again. Randomising the wait spreads them out.

Step 3: make retried writes safe

Retrying a GET is harmless. Retrying a POST /shipments is how you book the same truck twice: the first attempt may have succeeded on their side and only the response timed out.

If the provider supports an idempotency key (many payment and logistics APIs do; check their docs, the header name varies), generate it once per business operation, outside the retry loop, and store it with your record:

use Illuminate\Support\Str;

$shipment->idempotency_key ??= (string) Str::uuid();
$shipment->save();

$response = Http::shipping()
    ->withHeaders(['Idempotency-Key' => $shipment->idempotency_key])
    ->post('/v1/shipments', $shipment->toProviderPayload());
Enter fullscreen mode Exit fullscreen mode

The header is set on the pending request, so every retry carries the same key, and the provider can recognise the duplicates. If the provider offers no such mechanism, do not auto-retry that write at all: remove retry() for that call and push the work into a queued job whose failure a human can inspect.

Step 4: a small circuit breaker

Retries help with blips. They hurt during an outage: three attempts per request, each waiting up to 5 seconds, is exactly the worker pile-up from the opening story, only slower.

A circuit breaker counts consecutive failures. After a threshold it "opens" for a cooldown period, and calls fail immediately without touching the network. You do not need a package for the basic version; the cache is enough:

<?php

namespace App\Support;

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

final class CircuitBreaker
{
    public function __construct(
        private string $name,
        private int $threshold = 5,
        private int $cooldownSeconds = 60,
    ) {}

    public function call(Closure $callback): mixed
    {
        if (Cache::has($this->key('open'))) {
            throw new RuntimeException("Circuit [{$this->name}] is open.");
        }

        try {
            $result = $callback();
        } catch (Throwable $e) {
            $this->recordFailure();
            throw $e;
        }

        Cache::forget($this->key('failures'));

        return $result;
    }

    private function recordFailure(): void
    {
        Cache::add($this->key('failures'), 0, $this->cooldownSeconds * 2);
        $failures = Cache::increment($this->key('failures'));

        if ($failures >= $this->threshold) {
            Cache::put($this->key('open'), true, $this->cooldownSeconds);
            Cache::forget($this->key('failures'));
        }
    }

    private function key(string $suffix): string
    {
        return "circuit:{$this->name}:{$suffix}";
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage, with a fallback the screen can live with:

$breaker = new CircuitBreaker('shipping', threshold: 5, cooldownSeconds: 60);

try {
    $quote = $breaker->call(fn () => Http::shipping()->get('/v1/quotes', $params)->json());
} catch (Throwable $e) {
    report($e);
    $quote = null; // UI shows "Live quote unavailable, try again in a minute"
}
Enter fullscreen mode Exit fullscreen mode

Cache::add only writes the key if it does not exist yet, so the counter gets a TTL and increment works across drivers. Use a shared store (Redis, database) in production; the array driver resets every request and would never open the circuit.

Step 5: prove it with Http::fake

The HTTP client's testing tools make this cheap to verify. Http::sequence() returns responses in order, Http::failedConnection() simulates a network failure, and assertSentCount tells you how many requests actually went out.

<?php

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

beforeEach(function () {
    config(['services.shipping.url' => 'https://shipping.test', 'services.shipping.retry_base_ms' => 0]);
    Http::preventStrayRequests();
});

it('retries server errors and eventually succeeds', function () {
    Http::fake([
        'shipping.test/*' => Http::sequence()
            ->pushStatus(503)
            ->pushStatus(502)
            ->push(['price' => 420], 200),
    ]);

    $quote = Http::shipping()->get('/v1/quotes')->json();

    expect($quote['price'])->toBe(420);
    Http::assertSentCount(3);
});

it('does not retry validation errors', function () {
    Http::fake(['shipping.test/*' => Http::response(['error' => 'bad weight'], 422)]);

    expect(fn () => Http::shipping()->get('/v1/quotes'))->toThrow(RequestException::class);
    Http::assertSentCount(1);
});
Enter fullscreen mode Exit fullscreen mode

preventStrayRequests() makes any un-faked request throw, so a typo in a URL pattern cannot quietly hit a real partner from CI.

Common pitfalls

  • Forgetting the total budget. Worst case is roughly attempts × (connectTimeout + timeout) + sleeps. With 3 attempts at 2 s + 5 s that is over 20 seconds. If that is too long for a web request, lower the numbers or move the call to a queue.
  • Retrying in two layers. A queued job with $tries = 5 wrapping a client with retry(3) can make 15 requests. Pick one layer to own retries.
  • Treating throw: false as "never throws". Even with throw: false, if every attempt fails at the connection level, a ConnectionException is still thrown.
  • Logging the token. Exception reports can include request details. Keep secrets in headers set by the macro, and review what your error tracker captures.
  • Using the array cache for the breaker. It works in tests and silently does nothing in production.

Summary

Every external API you call is part of your application's uptime whether you plan for it or not. Configure one client per partner, give it short explicit timeouts, retry only ConnectionException, 429 and 5xx with jittered backoff, protect writes with an idempotency key, fail fast with a circuit breaker during outages, and lock it all down with Http::fake tests.

The code is small. The difference it makes on the day a partner slows down is not.

What is your worst "the other company's API was slow and our app went down" story, and what did you change afterwards?

Top comments (0)