Do not use up retries on requests which timed out during a prompt

Nothing drives the transfers while tick() is blocked, e.g. by a prompt
for credentials, so requests which are still connecting time out with
curl error 28. Since #12662 these are retried, but each retry counts
like any other, so a few prompts can use up all of them, and parallel
requests to a private host which timed out that way abort with
"Invalid credentials" once the first of them got credentials, as
AuthHelper only reuses those for a request which was not retried yet.

tick() now keeps track of how long it was blocked between two ticks,
and a request which spent most of its time in that gap is restarted
without counting towards maxRetries.

Fixes #12402
This commit is contained in:
dualfroz committed 2026-10-17 03:58:45 +02:00
1 parent 44656d3be2
commit 17d32b7184
2 files changed
+52

No files matched your search

+20
View File
@@ -69,6 +69,12 @@ class CurlDownloader
* @var array<string, array{statusCode: int, announced: bool, until: float}>
*/
private $retryAfterOrigins = [];
/**
* When tick() last waited on the transfers, they are not driven again until the next tick(), which a prompt can hold up
*
* @var float|null
*/
private $lastSelectAt = null;
/** @var IOInterface */
private $io;
/** @var Config */
@@ -397,12 +403,14 @@ class CurlDownloader
return;
}
$blockedFor = null !== $this->lastSelectAt ? microtime(true) - $this->lastSelectAt : 0.0;
$active = true;
$this->checkCurlResult(curl_multi_exec($this->multiHandle, $active));
if (-1 === curl_multi_select($this->multiHandle, $this->getSelectTimeout())) {
// sleep in case select returns -1 as it can happen on old php versions or some platforms where curl does not manage to do the select
usleep(150);
}
$this->lastSelectAt = microtime(true);
while ($progress = curl_multi_info_read($this->multiHandle)) {
$curlHandle = $progress['handle'];
@@ -437,6 +445,18 @@ class CurlDownloader
}
$progress['error_code'] = $errno;
// a request which spent most of its time blocked between two ticks, e.g. by a prompt for credentials, did not time out
// on its own, so it is retried without counting towards maxRetries, see https://github.com/composer/composer/issues/12402
if (
$errno === 28 /* CURLE_OPERATION_TIMEDOUT */
&& $blockedFor > $progress['total_time'] / 2
&& (!isset($job['options']['http']['method']) || $job['options']['http']['method'] === 'GET')
) {
$this->io->writeError('Retrying ' . Url::sanitize($job['url']) . ' due to curl error 28 while the download loop was blocked', true, IOInterface::DEBUG);
$this->restartJob($job, $job['url']);
continue;
}
if (
(!isset($job['options']['http']['method']) || $job['options']['http']['method'] === 'GET')
&& (
@@ -200,6 +200,38 @@ class CurlDownloaderTest extends TestCase
self::assertSame([], $this->readProperty($downloader, 'jobs'), 'a cancelled request must not be sent again');
}
public function testTimeoutWhileTheLoopWasBlockedDoesNotUseUpARetry(): void
{
// the kernel accepts the connection but nothing ever answers, so the request can only time out
$server = stream_socket_server('tcp://127.0.0.1:0');
self::assertIsResource($server);
$address = stream_socket_get_name($server, false);
self::assertIsString($address);
$downloader = $this->createDownloader();
$failure = null;
// a request which has no retries left, e.g. after timing out during earlier prompts
self::callPrivate($downloader, 'initDownload', static function (): void {
}, static function (\Exception $e) use (&$failure): void {
$failure = $e->getMessage();
}, $address, 'http://'.$address.'/packages.json', ['http' => ['timeout' => 1]], null, ['retries' => 3]);
$downloader->tick();
// a prompt for credentials keeps tick() from being called for longer than the request may take
usleep(1500000);
$downloader->tick();
self::assertNull($failure);
$jobs = $this->readProperty($downloader, 'jobs');
self::assertIsArray($jobs);
self::assertCount(1, $jobs, 'the request must have been sent again');
foreach (array_keys($jobs) as $id) {
$downloader->abortRequest((int) $id);
}
fclose($server);
}
public function testRetryWaitsForTheLatestRetryAfterOfItsOrigin(): void
{
$io = new BufferIO();