Good LLM API timeout handling comes down to three decisions. Set a short connect timeout and a separate, longer limit for the whole request. Give every call one overall deadline that retries have to fit inside. And treat a timeout as “outcome unknown”, not “failed”, because the provider may have finished the work and billed it after your code stopped listening. Retry freely when the connection never opened, and retry at most once when it did. The code on this page targets PHP 8.1 or later with the bundled cURL extension. It needs no Composer packages.
Why LLM Calls Time Out Differently From Other APIs
A typical REST call returns fast or fails fast. An LLM call is closer to a job. The response time depends on the prompt length, how many tokens the model decides to generate, which model you picked and how busy the provider is at that moment. The same request can finish quickly one minute and take far longer the next, with nothing wrong on your side.
That spread breaks the usual habit of picking one timeout number and moving on. Set it too short and you cut off long but healthy answers. Set it too long and a stuck request holds a PHP worker while your visitor stares at a spinner. Neither choice is safe on its own, which is why the rest of this page splits the problem into phases and budgets instead of one magic number.
The other difference is cost. A retried database query costs you almost nothing. A retried LLM request can mean paying for the same generation twice, and that is the part most timeout advice skips.
The Three Timeouts You Actually Control in PHP
When you call a model provider with cURL, three settings do the real work:
CURLOPT_CONNECTTIMEOUT: how long to wait for the connection to open. The PHP manual lists its default as 300 seconds, which is far too patient for a user-facing request. A few seconds is plenty; if the provider cannot accept a connection that quickly, waiting longer rarely helps.CURLOPT_TIMEOUT: the ceiling for the entire transfer, from DNS lookup to the last byte. libcurl’s own documentation gives its default as 0, which means no limit at all. If you never set it, a hung response can wait forever.CURLOPT_LOW_SPEED_LIMITplusCURLOPT_LOW_SPEED_TIME: abort if the transfer stays below a given number of bytes per second for a given number of seconds. This is the idle timeout, and it matters most for streaming, covered below.
If you are inside WordPress and using wp_remote_post() instead of raw cURL, check its timeout argument. The WordPress HTTP API defaults to 5 seconds, which is a common reason an LLM feature “always times out” on WordPress and works fine in a standalone script.
PHP’s max_execution_time Will Not Save You
Here is the trap that catches a lot of PHP developers. You assume max_execution_time puts a hard cap on the whole request, so a slow API call cannot run past it. On Linux, that assumption is wrong.
The PHP manual page for set_time_limit (read 2026-09-23) states that set_time_limit() and max_execution_time “only affect the execution time of the script itself”, and that time spent on “stream operations, database queries, etc.” is not counted. It adds that this is not true on Windows, where the measured time is real.
In practice: your script can sit waiting on a model provider far longer than max_execution_time suggests, because waiting on the network is not script execution. What stops it instead is whatever sits outside PHP: PHP-FPM’s request_terminate_timeout, your web server’s upstream read timeout, a CDN or load balancer in front of the site. When one of those fires first, your code never gets a chance to log anything. The visitor gets a 502 or 504, and your logs show nothing useful.
The rule that follows: your cURL timeout must be shorter than every layer outside it. The innermost timeout should always fire first, because it is the only one where your own code can catch the failure, log it and show a sensible message. Look up the real values on your host, since each layer has its own default and hosts change them.
A Timeout Is Not a Failure: The Request That Never Came Back
When cURL reports a timeout (error 28, CURLE_OPERATION_TIMEDOUT in libcurl), it tells you one thing: your side stopped waiting. It does not tell you what happened on the provider’s side. There are two very different cases hiding behind the same error code.
The connection never opened. DNS was slow, the network dropped, the provider refused new connections. Your request body never left the machine. Retrying is safe, costs nothing extra, and is usually the right call.
The connection opened and the request was sent. Now the provider may have received it, started generating and finished, and the answer was on its way back when your timer ran out. From the provider’s point of view, that request succeeded. Depending on how your provider bills, you may have paid for it. A blind retry asks for the same work again.
This is the distinction the generic “wrap it in retry with backoff” advice skips. A connect timeout and a read timeout both come back as error 28, so you have to look at what else cURL knows. On a fresh handle, CURLINFO_CONNECT_TIME stays at 0 when the connection never completed, which is enough to separate the two cases. Treat anything else as “may have reached the provider”.
LLM API Timeout Handling in PHP: Complete Code With a Deadline
The first function makes one attempt and reports what kind of failure happened. The second owns the retry policy and the overall deadline.
<?php
declare(strict_types=1);
// Targets PHP 8.1+ (readonly promoted properties) with ext-curl. No Composer packages.
final class LlmTransportException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $curlErrno,
public readonly bool $mayHaveReachedProvider,
) {
parent::__construct($message);
}
}
/**
* One HTTP attempt. Returns [statusCode, responseBody].
*/
function llm_attempt(string $endpoint, string $apiKey, string $jsonBody, int $timeoutSeconds): array
{
$ch = curl_init($endpoint);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonBody,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey,
],
CURLOPT_CONNECTTIMEOUT => min(5, $timeoutSeconds),
CURLOPT_TIMEOUT => $timeoutSeconds,
]);
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$connectTime = (float) curl_getinfo($ch, CURLINFO_CONNECT_TIME);
if ($errno !== 0 || $body === false) {
// A connect time of 0 on a fresh handle means the connection never opened.
throw new LlmTransportException("cURL error {$errno}: {$error}", $errno, $connectTime > 0.0);
}
return [$status, $body];
}
/**
* Calls the model with one overall time budget shared by every attempt.
*/
function llm_complete(string $endpoint, string $apiKey, array $payload, int $budgetSeconds = 60): string
{
$jsonBody = json_encode($payload, JSON_THROW_ON_ERROR);
$deadline = microtime(true) + $budgetSeconds;
$maxAttempts = 3;
$ambiguousFailures = 0;
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$remaining = (int) floor($deadline - microtime(true));
if ($remaining < 2) {
break;
}
try {
[$status, $body] = llm_attempt($endpoint, $apiKey, $jsonBody, $remaining);
} catch (LlmTransportException $e) {
error_log(sprintf(
'LLM transport error on attempt %d: errno=%d reached_provider=%s',
$attempt,
$e->curlErrno,
$e->mayHaveReachedProvider ? 'maybe' : 'no'
));
if ($e->mayHaveReachedProvider && ++$ambiguousFailures > 1) {
break; // One blind retry after an ambiguous failure is the limit.
}
continue;
}
if ($status >= 200 && $status < 300) {
return $body;
}
if ($status === 429 || $status >= 500) {
usleep(random_int(500_000, 1_500_000) * $attempt);
continue;
}
throw new RuntimeException("LLM API returned HTTP {$status}");
}
throw new RuntimeException('LLM request did not complete within its time budget.');
}
A few choices here are deliberate. Each attempt gets whatever is left of the budget, not a fresh full timeout, so three attempts can never add up to three times your limit. There is no curl_close(), because since PHP 8.0 it has no effect and the handle is freed when it goes out of scope. The log line records the errno and whether the request may have reached the provider, but never the headers, since the Authorization header carries your key. If you are reviewing every place that key could leak, the API key security checklist for AI coding assistants is a good companion to this code. The 60-second budget is an example value for you to replace with numbers from your own logs; nothing about it is a recommendation for your model or provider.
Streaming Changes Which Timeout Matters
If you stream the response, a total CURLOPT_TIMEOUT becomes awkward. A long, healthy stream might legitimately run for minutes, and a strict total ceiling would chop it off mid-sentence. What you actually care about is whether data is still arriving.
That is the job of the low-speed pair:
curl_setopt_array($ch, [
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 300, // Hard ceiling so nothing runs forever.
CURLOPT_LOW_SPEED_LIMIT => 1, // Bytes per second.
CURLOPT_LOW_SPEED_TIME => 20, // Abort after 20 seconds below that rate.
CURLOPT_WRITEFUNCTION => function ($ch, string $chunk): int {
echo $chunk;
flush();
return strlen($chunk);
},
]);
With this, a stream that keeps producing tokens can run as long as it needs up to the hard ceiling, while a stream that goes silent is cut after 20 seconds of silence. The abort also comes back as error 28, so the same handler covers it. One honest caveat: if the stream dies halfway, you already have a partial answer. Decide explicitly whether you show it, discard it or retry, because a retry starts the generation from scratch.
Silent Timeouts: When Nothing Errors at All
The worst timeout bug is the one that returns normally. A function that swallows the exception and returns an empty string, or a default like “Sorry, try again”, can make it all the way to the page without anyone noticing that half your calls are failing. That is a textbook case of the pattern in debugging silent failure in PHP when the code runs but gives the wrong result: no error, wrong answer.
The fix is to make timeouts loud in your logs even when they are quiet for the user. On a WordPress site, you can send errors to the debug log without displaying them to visitors, so the error_log() lines above land somewhere you will actually read. Once they are there, count them. A timeout rate that creeps up over a week is usually telling you something changed: a longer prompt, a slower model, a host that tightened a proxy limit.
It is also worth checking your provider’s usage dashboard against your own success logs now and then. If the provider shows more completed requests than you show successful ones, ambiguous timeouts are the likely gap, and each one is money spent on answers nobody saw. For the wider picture of where AI spend goes, see what AI coding tools actually cost on a real project.
What the Official SDKs Do by Default
If you use an official SDK instead of raw cURL, read its defaults before trusting them. As one example, the openai-python README (read 2026-09-23) states that requests “time out after 10 minutes” by default and that certain errors, including connection errors, 408, 429 and 500-level responses, are “automatically retried 2 times by default”. It also says timed-out requests follow that same retry behavior.
Ten minutes per attempt, retried, is a sensible default for a background job and a poor one for a web request with a visitor waiting. Other SDKs choose differently, and defaults change between versions, so your provider’s current documentation beats this page on the exact numbers. The point stands either way: an SDK default is a decision someone else made for a use case that may not be yours.
Frequently Asked Questions
What is a good timeout for an LLM API call?
There is no single correct number, because response time depends on prompt size, output length and model. Use a short connect timeout of a few seconds, then set the total limit from your own logged response times plus headroom, and keep it below every proxy and PHP-FPM limit on your host.
Should I retry an LLM request after a timeout?
Retry freely if the connection never opened, because the provider never saw the request. If the connection opened, the provider may already have processed and billed it, so retry at most once, inside an overall deadline, and log that it happened.
Why does my LLM call time out in WordPress but not in a plain PHP script?
The usual cause is wp_remote_post(), whose default timeout is 5 seconds. Pass a larger timeout argument, and make sure it still sits below your web server and PHP-FPM limits so your code, not a proxy, handles the failure.
Does max_execution_time stop a slow API request in PHP?
Not on Linux. According to the PHP manual, time spent on stream operations is not counted toward max_execution_time except on Windows. Set CURLOPT_TIMEOUT explicitly instead of relying on PHP’s script limit.

