A Laravel checkout page calls a shipping carrier's rate API with Http::retry(3, 1000)->get() and no timeout; when the carrier hangs, what happens and how do you fix it?
answer
- 30 s timeout, 10 s connect
- each hung attempt throws ConnectionException
- attempts times timeout plus sleeps
- PHP workers held while waiting
- short limits, filtered retries, fallback
basics
~20 sLaravel's default 30-second timeout applies per attempt, so retry(3, 1000) can hold a PHP worker about 92 seconds per checkout. Set short timeout() and connectTimeout(), retry only transient failures, and fall back or move the call off the request.
solid answer
~40 sEvery Laravel HTTP request defaults to a 30-second `timeout` and a 10-second `connect_timeout`. When the carrier accepts the connection but never answers, each attempt waits 30 seconds and throws `ConnectionException`; with no when-callback it is retried, so three attempts plus two 1-second sleeps block for about 92 seconds, then the exception reaches the controller as a 500. Meanwhile that PHP worker serves nobody else, and a burst of shoppers can exhaust the pool and slow the whole site. The fix: `connectTimeout(2)->timeout(3)`, a retry budget that fits what a user will wait, a when-callback limited to `ConnectionException`, 5xx and 429, a catch that shows cached or flat rates, and queued jobs for work the user does not wait on. `Http::globalOptions(['timeout' => 10])` stops forgotten calls inheriting 30 seconds.
code
php · 17 lines<?php
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
try {
$rates = Http::connectTimeout(2)
->timeout(3)
->retry(2, 200, fn (\Throwable $e) => $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError()))
->get('https://carrier.example/api/rates', ['zip' => $zip])
->json('rates');
} catch (ConnectionException|RequestException $e) {
report($e);
$rates = config('shipping.flat_rates'); // degrade instead of a 500
}go deeper
Remember the defaults: 30 seconds per request, 10 seconds to connect, and that a timeout throws ConnectionException rather than returning a response.
Explain how timeout(), connectTimeout() and retry() combine, and compute the worst-case wait from attempts, timeout and sleeps.
Show you protect shared PHP workers: tight limits, filtered retries, graceful fallbacks, queued jobs for non-interactive calls, and monitoring of client events.
Discuss the budget across layers: web-server limits, HTTP timeouts, retries and queue timeouts must nest, and a team default via globalOptions prevents forgotten calls.
## The defaults Laravel gives you Every request from Laravel's HTTP client starts with limits set in the `PendingRequest` constructor and in `retry()`: | Setting | Method | Default | What it limits | |---|---|---|---| | `timeout` | `timeout($seconds)` | 30 seconds | the whole request, connect to last byte | | `connect_timeout` | `connectTimeout($seconds)` | 10 seconds | only establishing the connection | | attempts | `retry($times, ...)` | 1 | how many times the request may run | | retry sleep | `retry(..., $sleepMilliseconds)` | 0 ms | the pause between attempts | Both time methods accept an `int` or a `float`, so `timeout(1.5)` is valid. When either limit expires, Laravel throws `Illuminate\Http\Client\ConnectionException`: there is no response and no status code. ## What a hung carrier does to the checkout Take `Http::retry(3, 1000)->get($ratesUrl)` and a carrier that accepts the connection but never answers: 1. Attempt 1 waits the full 30 seconds and fails with `ConnectionException`. 2. With no when-callback, that failure is retryable, so Laravel sleeps 1 second in the same process. 3. Attempts 2 and 3 repeat the pattern. 4. After roughly **92 seconds** the last `ConnectionException` reaches the controller and, unless caught, becomes a server error — if a PHP or web-server time limit has not cut the request off first. How long each attempt takes depends on how the carrier fails: | Failure | Which limit fires | Cost per attempt | |---|---|---| | host up, port closed (connection refused) | none; the error is immediate | milliseconds | | host unreachable, packets dropped | `connect_timeout` | up to 10 seconds by default | | connection accepted, answer never comes | `timeout` | up to 30 seconds by default | | answer arrives slowly | `timeout`, once it is exceeded | up to 30 seconds by default | The fast failure is the kind one; the "accepts but never answers" case is the expensive one, and it is exactly how an overloaded upstream behaves. The damage is not limited to one shopper. A PHP-FPM pool, like any server with a fixed number of PHP workers, has a fixed number of slots. Every checkout that hits the carrier holds one slot for the whole wait, so a burst of traffic can occupy all of them and make unrelated pages queue behind the stuck ones. A slow dependency becomes an outage of the whole application. ## Fixing it 1. **Set explicit, short limits**: `connectTimeout(2)->timeout(3)` for an interactive page, chosen from the carrier's real latency rather than the defaults. 2. **Bound the retry budget.** The worst case is roughly attempts × `timeout` + the sum of the sleeps; two attempts with a 3-second timeout and a 200 ms sleep cost about 6.2 seconds at most. 3. **Retry only transient failures** with a when-callback — `ConnectionException`, 5xx and 429 — so a 4xx that will never succeed fails immediately. 4. **Catch both exception classes** (`ConnectionException` and `RequestException`) and degrade: show cached rates, a flat rate or a "rates unavailable" message instead of a 500. 5. **Move work the shopper does not wait for off the request.** Buying a label or syncing tracking can run in a queued job, where a slow carrier costs a worker rather than a customer. Keep the HTTP timeout below the job's own time limit so it fails with a catchable exception. 6. **Set an app-wide ceiling** with `Http::globalOptions(['timeout' => 10])` in a service provider, so a call written without `timeout()` no longer inherits 30 seconds. ## Seeing it in production Laravel dispatches events around each call — `Illuminate\Http\Client\Events\RequestSending`, `ResponseReceived` and `ConnectionFailed` — and listeners on them can count failures per host. `$response->handlerStats()` exposes the Guzzle handler's transfer statistics, including timings, for one response. Watching these shows a degrading carrier before timeouts pile up: - a rising share of `ConnectionFailed` events for one host; - response times creeping toward the configured `timeout`; - checkout latency tracking the carrier's latency. ## A note on scope Stopping calls to a dependency that keeps failing (a circuit breaker) and choosing backoff curves are general resilience patterns. Laravel's contribution is the set of knobs — `timeout()`, `connectTimeout()`, `retry()` with a when-callback, `globalOptions()` — and the exceptions and events above.
- How do you estimate the worst-case time of a Laravel HTTP call that uses retry()?Multiply the attempts by the `timeout` and add the sleeps between them. `retry(3, 1000)` with the default 30-second timeout is about 3 × 30 + 2 × 1 = 92 seconds; `retry(2, 200)` with `timeout(3)` is about 6.2 seconds.
- Why does a queued job calling the carrier still need an explicit HTTP timeout?A hung call holds the queue worker just as it holds a web worker. If the HTTP timeout outlasts the job's own time limit, the worker is killed mid-call instead of receiving a `ConnectionException` the job can handle, retry or record.
- How can you measure how long carrier calls take in a Laravel app?Listen to `Illuminate\Http\Client\Events\ResponseReceived` and `ConnectionFailed` to count outcomes per host, and read `$response->handlerStats()` for the handler's transfer timings on individual responses.
A cashier who phones a supplier for every price check and holds the line for half a minute: while the phone rings, the whole queue behind that till waits, and a few such calls stall every till in the shop.
saying these in an interview costs you the question
- Laravel's HTTP client has no default timeout, so a hung upstream waits forever.
- retry(3, 1000) caps the total wait at about three seconds.
- connectTimeout() limits how long the whole response may take.
- A slow upstream only hurts the one user whose request is waiting.
- Retrying every failure is always safer than failing fast on a checkout page.