Boring Observability GitHub

The default timeouts of PHP HTTP clients, measured

Laravel's HTTP client gives up after 30 seconds. A bare Guzzle client, and every SDK that builds one, waits for as long as the server keeps the connection open. Here is what each client a Laravel app tends to pull in does by default, measured against servers that never answer, and the line that changes it.

12 clients · PHP 8.4.25 · Measured

The defaults#

Each client sent one request with its default configuration to a server that never completes the connection and to one that accepts the connection and never answers. The table gives the timeout that ended each wait, and how many attempts reached a server that answers 500 and one that closes the connection without a reply.

Client Connect timeout Server never answers Attempts on a 500 Attempts on a dropped connection
Laravel HTTP client v13.35.0 10 s 30 s 1 1
Guzzle 7.15.5 None (the network gave up at 76 s) None (still waiting at 150 s) 1 1
Symfony HttpClient v7.4.20 60 s 60 s 1 1
AWS SDK for PHP 3.401.0 None (3 attempts, each until the network gave up) None (still waiting at 150 s) 3 3
Google API client v2.20.1 None (the network gave up at 76 s) None (still waiting at 150 s) 1 1
Resend v1.16.0 None (the network gave up at 76 s) None (still waiting at 150 s) 1 1
Stripe v17.6.0 30 s 80 s 1 1
Twilio 8.13.0 60 s 60 s 1 1
OpenAI (Symfony client) v0.15.0 60 s 60 s 1 1
OpenAI (Guzzle) v0.15.0 None (the network gave up at 77 s) None (still waiting at 150 s) 1 1
Mailgun (Symfony client) v4.5.2 60 s 60 s 1 1
Mailgun (Guzzle) v4.5.2 None (the network gave up at 77 s) None (still waiting at 150 s) 1 1

Measured 13 October 2026 on PHP 8.4.25 with cURL 8.14.1 and default_socket_timeout 60. A wait still going at 150 seconds was stopped there. The raw measurements are at /laravel/http-client-timeouts.json.

What a missing timeout costs#

A request with no timeout holds whatever started it until the far side gives up, and a server that accepted the connection and stopped answering may never give up. In a queue worker the job holds a process until the worker's own timeout kills it. If that timeout is not below the connection's retry_after, the job is handed to a second worker while the first is still waiting on the same call. In a web request it holds a PHP-FPM worker. Requests that never get a response are also the ones that most logging misses, because there is no response to log.

Client by client#

Laravel HTTP client#

illuminate/http v13.35.0. Used by Http::get() and every Http:: call.

PendingRequest sets connect_timeout 10 and timeout 30 on every request. None unless you call retry(). A 500 is returned as a response; throw() turns it into a RequestException.

Http::connectTimeout(2)->timeout(5)->get($url);

// or for every request, in a service provider:
Http::globalOptions(['connect_timeout' => 2, 'timeout' => 5]);

Guzzle#

guzzlehttp/guzzle 7.15.5. Used by new GuzzleHttp\Client, and every SDK that builds one without options.

No timeout and no connect_timeout. A request to a server that accepts and never answers waits for ever. None. A 5xx throws ServerException through the http_errors middleware.

new GuzzleHttp\Client(['connect_timeout' => 2, 'timeout' => 5]);

Symfony HttpClient#

symfony/http-client v7.4.20. Used by HttpClient::create(), and the PSR-18 client discovery picks when it is installed.

timeout is an idle timeout taken from PHP's default_socket_timeout, 60 seconds unless php.ini changes it. It covers waiting to connect too. max_duration, the cap on the whole request, is 0: none. None unless you wrap it in RetryableHttpClient.

HttpClient::create(['timeout' => 5, 'max_duration' => 30]);

AWS SDK for PHP#

aws/aws-sdk-php 3.401.0. Used by S3 filesystem disks, SQS queues, SES mail, DynamoDB cache.

No timeout and no connect timeout, and the default retry mode makes three attempts. A silent endpoint holds the call for ever; an unreachable one costs three full connect attempts. 3 attempts in total for a 500 and for a dropped connection.

// config/filesystems.php, the s3 disk: Laravel passes the disk's
// config to the S3Client, so an 'http' key reaches it as-is.
's3' => [
    'driver' => 's3',
    // ...
    'http' => ['connect_timeout' => 2, 'timeout' => 5],
],

Google API client#

google/apiclient v2.20.1. Used by Google\Client, and every Google\Service built on it.

Builds a Guzzle client with no timeout options, so it inherits Guzzle's none. None by default (its retry config is empty).

$client = new Google\Client;
$client->setHttpClient(new GuzzleHttp\Client([
    'connect_timeout' => 2,
    'timeout' => 5,
    'http_errors' => false, // the default client sets it, and the library expects it
]));

Resend#

resend/resend-php v1.16.0. Used by Laravel's resend mail transport.

Resend::client() builds a Guzzle client with no options, and takes none from you. Laravel's resend mailer calls it, so mail sent through Resend inherits no timeout. None.

// AppServiceProvider::boot(): replace the transport Laravel builds.
use Illuminate\Mail\Transport\ResendTransport;
use Resend\Transporters\HttpTransporter;
use Resend\ValueObjects\ApiKey;
use Resend\ValueObjects\Transporter\{BaseUri, Headers};

Mail::extend('resend', fn (array $config) => new ResendTransport(
    new Resend\Client(new HttpTransporter(
        new GuzzleHttp\Client(['connect_timeout' => 2, 'timeout' => 5]),
        BaseUri::from('api.resend.com'),
        Headers::withAuthorization(ApiKey::from($config['key'] ?? config('services.resend.key'))),
    )),
));

Stripe#

stripe/stripe-php v17.6.0. Used by StripeClient, and Laravel Cashier through it.

Its own cURL client, with DEFAULT_CONNECT_TIMEOUT 30 and DEFAULT_TIMEOUT 80. None: max_network_retries defaults to 0.

$curl = new Stripe\HttpClient\CurlClient;
$curl->setConnectTimeout(2);
$curl->setTimeout(5);
Stripe\ApiRequestor::setHttpClient($curl);

// retries are per client:
new Stripe\StripeClient(['api_key' => $key, 'max_network_retries' => 2]);

Twilio#

twilio/sdk 8.13.0. Used by Twilio\Rest\Client.

Its own cURL client, with CURLOPT_TIMEOUT 60 and no connect timeout of its own. The 60 seconds is the whole request, so it also bounds the connect. None. A 500 comes back as a response, not an exception.

new Twilio\Rest\Client($sid, $token, null, null, new Twilio\Http\CurlClient([
    CURLOPT_CONNECTTIMEOUT => 2,
    CURLOPT_TIMEOUT => 5,
]));

OpenAI (openai-php/client), with symfony/http-client installed#

openai-php/client v0.15.0. Used by OpenAI::client() and OpenAI::factory().

Discovers a PSR-18 client. With symfony/http-client installed, discovery picks Symfony's, so it inherits the 60-second idle timeout. None.

OpenAI::factory()
    ->withApiKey($key)
    ->withHttpClient(new GuzzleHttp\Client(['connect_timeout' => 2, 'timeout' => 30]))
    ->make();

OpenAI (openai-php/client), without symfony/http-client#

openai-php/client v0.15.0. Used by the same calls, in an app with only Guzzle.

Discovery falls back to a bare GuzzleHttp\Client, so a model that never starts answering holds the request for ever. None.

OpenAI::factory()
    ->withApiKey($key)
    ->withHttpClient(new GuzzleHttp\Client(['connect_timeout' => 2, 'timeout' => 30]))
    ->make();

Mailgun, with symfony/http-client installed#

mailgun/mailgun-php v4.5.2. Used by Mailgun::create().

Discovers a PSR-18 client, so the same split as OpenAI: Symfony's 60-second idle timeout when it is installed. None.

new Mailgun\Mailgun((new Mailgun\HttpClient\HttpClientConfigurator)
    ->setApiKey($key)
    ->setHttpClient(new GuzzleHttp\Client(['connect_timeout' => 2, 'timeout' => 5])));

Mailgun, without symfony/http-client#

mailgun/mailgun-php v4.5.2. Used by the same call, in an app with only Guzzle.

A bare GuzzleHttp\Client from discovery: no timeout. None.

new Mailgun\Mailgun((new Mailgun\HttpClient\HttpClientConfigurator)
    ->setApiKey($key)
    ->setHttpClient(new GuzzleHttp\Client(['connect_timeout' => 2, 'timeout' => 5])));

Questions#

What timeout does Laravel's HTTP client use by default?

Ten seconds to connect and thirty for the whole request. PendingRequest sets connect_timeout 10 and timeout 30 on every request, and Http::connectTimeout() and Http::timeout() change them per call. It does not retry unless you call retry().

Does Guzzle have a default timeout?

No. A GuzzleHttp\Client created without options sets neither timeout nor connect_timeout. Against a server that accepted the connection and never answered, it was still waiting when the probe stopped it at 150 seconds. Any SDK that builds a bare Guzzle client inherits that, which includes Google's, Resend's and, in an app without symfony/http-client, OpenAI's and Mailgun's.

Why does the connect column say the network gave up?

A client with no connect timeout of its own waits until the operating system or the network stops trying. In the probe's environment that was about 75 seconds. On your servers it may be longer or shorter, which is the point: it is not a number you chose.

How were these measured?

Each client was installed at the version shown and sent one request with its default configuration, changing only the address through the SDK's own endpoint option. One test server never answers a connect, one accepts and goes quiet, one answers 500, and one closes the connection without a reply. The same run applies every fix on this page and checks that the request then fails in about the time the fix sets.

See the calls that never came back

Requizon records every request made through Laravel's HTTP client, including the ones where the connection failed and there was no response at all, with how long each one took. An SDK that builds its own Guzzle client is covered by pushing the same middleware onto its handler stack.

See Requizon