PHP curl errno 28: Connection Timed Out — but Only From the Server


An external API call worked perfectly when I ran it by hand and timed out every time from PHP:

cURL error 28: Operation timed out after 10001 milliseconds with 0 bytes received

Same server. Same URL. Same network. The difference took me an embarrassing amount of time to find, because I was debugging the API, and the API was fine.

What’s actually happening

The host resolved to both an IPv6 (AAAA) and an IPv4 (A) address. curl prefers IPv6 and tries it first. My NAS had IPv6 disabled at the network level — not rejected, just dropped.

A rejected connection fails immediately. A dropped one doesn’t fail at all; it waits. So curl sat on the IPv6 attempt until my timeout expired, and never got to the IPv4 address that would have worked instantly.

It worked from the terminal because that shell was configured differently, and because I’d been testing with a timeout long enough for the fallback to kick in. Two accidental differences, both invisible.

That’s why errno 28 is so misleading here: it reports the symptom of the last thing curl was doing, not the reason. “Timed out” reads as “the server is slow.” The server was never contacted.

The fix

One line:

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);   // <-- this one
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 5);
$res = curl_exec($ch);
if ($res === false) {
    error_log('curl ' . curl_errno($ch) . ': ' . curl_error($ch));
}
curl_close($ch);

CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4 tells curl to skip AAAA entirely. On a network where IPv6 isn’t routable, there is no downside — you’re removing an attempt that could only ever fail.

The equivalent on the command line, for testing:

curl -4 -m 10 https://api.example.com/endpoint

Set CONNECTTIMEOUT, not just TIMEOUT

Note the second timeout in that snippet. CURLOPT_TIMEOUT covers the whole transfer; CURLOPT_CONNECTTIMEOUT covers only getting connected. Without the connect timeout, a black-holed address consumes your entire budget before the real work starts.

Setting both means a dead route fails in 5 seconds instead of eating 10, and a slow-but-alive endpoint still gets its full transfer window. It also makes the two failure modes distinguishable in your logs, which is the actual value.

How to confirm which one you have

Before changing code, find out whether IPv6 is the problem at all:

# does the host even have an AAAA record?
getent ahostsv6 api.example.com

# can this machine reach anything over IPv6?
curl -6 -m 5 -o /dev/null -w '%{http_code}\n' https://api.example.com/
curl -4 -m 5 -o /dev/null -w '%{http_code}\n' https://api.example.com/

If the -6 line hangs until the timeout and the -4 line returns instantly, you’ve found it. If both hang, your problem is somewhere else and forcing IPv4 will change nothing.

That last sentence is the part I’d underline. It’s tempting to apply the fix and move on when the error goes away, but “the error stopped” and “I found the cause” aren’t the same claim. Run both lines first, so you know which one you’re making.

Comments

Loading comments…