Skip to content

Commit 6519e8e

Browse files
[HttpClient] Retry safe requests when then fail before the body arrives
1 parent 9aea44c commit 6519e8e

File tree

6 files changed

+80
-32
lines changed

6 files changed

+80
-32
lines changed

Chunk/ErrorChunk.php

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,19 @@ class ErrorChunk implements ChunkInterface
2626
private $errorMessage;
2727
private $error;
2828

29-
public function __construct(int $offset, \Throwable $error = null)
29+
/**
30+
* @param \Throwable|string $error
31+
*/
32+
public function __construct(int $offset, $error)
3033
{
3134
$this->offset = $offset;
32-
$this->error = $error;
33-
$this->errorMessage = null !== $error ? $error->getMessage() : 'Reading from the response stream reached the idle timeout.';
35+
36+
if (\is_string($error)) {
37+
$this->errorMessage = $error;
38+
} else {
39+
$this->error = $error;
40+
$this->errorMessage = $error->getMessage();
41+
}
3442
}
3543

3644
/**

CurlHttpClient.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ final class CurlHttpClient implements HttpClientInterface, LoggerAwareInterface
4848
*/
4949
private $multi;
5050

51+
private static $curlVersion;
52+
5153
/**
5254
* @param array $defaultOptions Default requests' options
5355
* @param int $maxHostConnections The maximum number of connections to a single host
@@ -66,6 +68,7 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections
6668
}
6769

6870
$this->multi = $multi = new CurlClientState();
71+
self::$curlVersion = self::$curlVersion ?? curl_version();
6972

7073
// Don't enable HTTP/1.1 pipelining: it forces responses to be sent in order
7174
if (\defined('CURLPIPE_MULTIPLEX')) {
@@ -84,7 +87,7 @@ public function __construct(array $defaultOptions = [], int $maxHostConnections
8487
}
8588

8689
// HTTP/2 push crashes before curl 7.61
87-
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > ($v = curl_version())['version_number'] || !(CURL_VERSION_HTTP2 & $v['features'])) {
90+
if (!\defined('CURLMOPT_PUSHFUNCTION') || 0x073d00 > self::$curlVersion['version_number'] || !(CURL_VERSION_HTTP2 & self::$curlVersion['features'])) {
8891
return;
8992
}
9093

@@ -170,7 +173,7 @@ public function request(string $method, string $url, array $options = []): Respo
170173
$this->multi->dnsCache->evictions = [];
171174
$port = parse_url($authority, PHP_URL_PORT) ?: ('http:' === $scheme ? 80 : 443);
172175

173-
if ($resolve && 0x072a00 > curl_version()['version_number']) {
176+
if ($resolve && 0x072a00 > self::$curlVersion['version_number']) {
174177
// DNS cache removals require curl 7.42 or higher
175178
// On lower versions, we have to create a new multi handle
176179
curl_multi_close($this->multi->handle);
@@ -190,7 +193,7 @@ public function request(string $method, string $url, array $options = []): Respo
190193
$curlopts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
191194
} elseif (1.1 === (float) $options['http_version'] || 'https:' !== $scheme) {
192195
$curlopts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
193-
} elseif (\defined('CURL_VERSION_HTTP2') && CURL_VERSION_HTTP2 & curl_version()['features']) {
196+
} elseif (\defined('CURL_VERSION_HTTP2') && CURL_VERSION_HTTP2 & self::$curlVersion['features']) {
194197
$curlopts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
195198
}
196199

Response/CurlResponse.php

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
*/
2626
final class CurlResponse implements ResponseInterface
2727
{
28-
use ResponseTrait;
28+
use ResponseTrait {
29+
getContent as private doGetContent;
30+
}
2931

3032
private static $performing = false;
3133
private $multi;
@@ -60,7 +62,7 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
6062

6163
if (!$info['response_headers']) {
6264
// Used to keep track of what we're waiting for
63-
curl_setopt($ch, CURLOPT_PRIVATE, 'headers');
65+
curl_setopt($ch, CURLOPT_PRIVATE, \in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true) && 1.0 < (float) ($options['http_version'] ?? 1.1) ? 'H2' : 'H0'); // H = headers + retry counter
6466
}
6567

6668
if (null === $content = &$this->content) {
@@ -119,7 +121,7 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
119121

120122
$waitFor = curl_getinfo($ch = $response->handle, CURLINFO_PRIVATE);
121123

122-
if (\in_array($waitFor, ['headers', 'destruct'], true)) {
124+
if ('H' === $waitFor[0] || 'D' === $waitFor[0]) {
123125
try {
124126
foreach (self::stream([$response]) as $chunk) {
125127
if ($chunk->isFirst()) {
@@ -133,10 +135,6 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
133135
throw $e;
134136
}
135137
}
136-
137-
curl_setopt($ch, CURLOPT_HEADERFUNCTION, null);
138-
curl_setopt($ch, CURLOPT_READFUNCTION, null);
139-
curl_setopt($ch, CURLOPT_INFILE, null);
140138
};
141139

142140
// Schedule the request in a non-blocking way
@@ -150,8 +148,6 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
150148
public function getInfo(string $type = null)
151149
{
152150
if (!$info = $this->finalInfo) {
153-
self::perform($this->multi);
154-
155151
$info = array_merge($this->info, curl_getinfo($this->handle));
156152
$info['url'] = $this->info['url'] ?? $info['url'];
157153
$info['redirect_url'] = $this->info['redirect_url'] ?? null;
@@ -164,8 +160,9 @@ public function getInfo(string $type = null)
164160

165161
rewind($this->debugBuffer);
166162
$info['debug'] = stream_get_contents($this->debugBuffer);
163+
$waitFor = curl_getinfo($this->handle, CURLINFO_PRIVATE);
167164

168-
if (!\in_array(curl_getinfo($this->handle, CURLINFO_PRIVATE), ['headers', 'content'], true)) {
165+
if ('H' !== $waitFor[0] && 'C' !== $waitFor[0]) {
169166
curl_setopt($this->handle, CURLOPT_VERBOSE, false);
170167
rewind($this->debugBuffer);
171168
ftruncate($this->debugBuffer, 0);
@@ -176,17 +173,35 @@ public function getInfo(string $type = null)
176173
return null !== $type ? $info[$type] ?? null : $info;
177174
}
178175

176+
/**
177+
* {@inheritdoc}
178+
*/
179+
public function getContent(bool $throw = true): string
180+
{
181+
$performing = self::$performing;
182+
self::$performing = $performing || '_0' === curl_getinfo($this->handle, CURLINFO_PRIVATE);
183+
184+
try {
185+
return $this->doGetContent($throw);
186+
} finally {
187+
self::$performing = $performing;
188+
}
189+
}
190+
179191
public function __destruct()
180192
{
181193
try {
182194
if (null === $this->timeout) {
183195
return; // Unused pushed response
184196
}
185197

186-
if ('content' === $waitFor = curl_getinfo($this->handle, CURLINFO_PRIVATE)) {
198+
$waitFor = curl_getinfo($this->handle, CURLINFO_PRIVATE);
199+
200+
if ('C' === $waitFor[0] || '_' === $waitFor[0]) {
187201
$this->close();
188-
} elseif ('headers' === $waitFor) {
189-
curl_setopt($this->handle, CURLOPT_PRIVATE, 'destruct');
202+
} elseif ('H' === $waitFor[0]) {
203+
$waitFor[0] = 'D'; // D = destruct
204+
curl_setopt($this->handle, CURLOPT_PRIVATE, $waitFor);
190205
}
191206

192207
$this->doDestruct();
@@ -217,7 +232,7 @@ private function close(): void
217232
unset($this->multi->openHandles[$this->id], $this->multi->handlesActivity[$this->id]);
218233
curl_multi_remove_handle($this->multi->handle, $this->handle);
219234
curl_setopt_array($this->handle, [
220-
CURLOPT_PRIVATE => '',
235+
CURLOPT_PRIVATE => '_0',
221236
CURLOPT_NOPROGRESS => true,
222237
CURLOPT_PROGRESSFUNCTION => null,
223238
CURLOPT_HEADERFUNCTION => null,
@@ -238,7 +253,7 @@ private static function schedule(self $response, array &$runningResponses): void
238253
$runningResponses[$i] = [$response->multi, [$response->id => $response]];
239254
}
240255

241-
if ('' === curl_getinfo($ch = $response->handle, CURLINFO_PRIVATE)) {
256+
if ('_0' === curl_getinfo($ch = $response->handle, CURLINFO_PRIVATE)) {
242257
// Response already completed
243258
$response->multi->handlesActivity[$response->id][] = null;
244259
$response->multi->handlesActivity[$response->id][] = null !== $response->info['error'] ? new TransportException($response->info['error']) : null;
@@ -260,8 +275,26 @@ private static function perform(CurlClientState $multi, array &$responses = null
260275
while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($multi->handle, $active));
261276

262277
while ($info = curl_multi_info_read($multi->handle)) {
263-
$multi->handlesActivity[(int) $info['handle']][] = null;
264-
$multi->handlesActivity[(int) $info['handle']][] = \in_array($info['result'], [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || (\CURLE_WRITE_ERROR === $info['result'] && 'destruct' === @curl_getinfo($info['handle'], CURLINFO_PRIVATE)) ? null : new TransportException(sprintf('%s for "%s".', curl_strerror($info['result']), curl_getinfo($info['handle'], CURLINFO_EFFECTIVE_URL)));
278+
$result = $info['result'];
279+
$id = (int) $ch = $info['handle'];
280+
$waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE) ?: '_0';
281+
282+
if (\in_array($result, [\CURLE_SEND_ERROR, \CURLE_RECV_ERROR, /*CURLE_HTTP2*/ 16, /*CURLE_HTTP2_STREAM*/ 92], true) && $waitFor[1] && 'C' !== $waitFor[0]) {
283+
curl_multi_remove_handle($multi->handle, $ch);
284+
$waitFor[1] = (string) ((int) $waitFor[1] - 1); // decrement the retry counter
285+
curl_setopt($ch, CURLOPT_PRIVATE, $waitFor);
286+
287+
if ('1' === $waitFor[1]) {
288+
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
289+
}
290+
291+
if (0 === curl_multi_add_handle($multi->handle, $ch)) {
292+
continue;
293+
}
294+
}
295+
296+
$multi->handlesActivity[$id][] = null;
297+
$multi->handlesActivity[$id][] = \in_array($result, [\CURLE_OK, \CURLE_TOO_MANY_REDIRECTS], true) || '_0' === $waitFor || curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD) === curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD) ? null : new TransportException(sprintf('%s for "%s".', curl_strerror($result), curl_getinfo($ch, CURLINFO_EFFECTIVE_URL)));
265298
}
266299
} finally {
267300
self::$performing = false;
@@ -286,7 +319,9 @@ private static function select(CurlClientState $multi, float $timeout): int
286319
*/
287320
private static function parseHeaderLine($ch, string $data, array &$info, array &$headers, ?array $options, CurlClientState $multi, int $id, ?string &$location, ?callable $resolveRedirect, ?LoggerInterface $logger): int
288321
{
289-
if (!\in_array($waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE), ['headers', 'destruct'], true)) {
322+
$waitFor = @curl_getinfo($ch, CURLINFO_PRIVATE) ?: '_0';
323+
324+
if ('H' !== $waitFor[0] && 'D' !== $waitFor[0]) {
290325
return \strlen($data); // Ignore HTTP trailers
291326
}
292327

@@ -347,14 +382,18 @@ private static function parseHeaderLine($ch, string $data, array &$info, array &
347382
}
348383

349384
if ($statusCode < 300 || 400 <= $statusCode || null === $location || curl_getinfo($ch, CURLINFO_REDIRECT_COUNT) === $options['max_redirects']) {
350-
// Headers and redirects completed, time to get the response's body
385+
// Headers and redirects completed, time to get the response's content
351386
$multi->handlesActivity[$id][] = new FirstChunk();
352387

353-
if ('destruct' === $waitFor) {
354-
return 0;
388+
if ('D' === $waitFor[0] || 'HEAD' === $info['http_method'] || \in_array($statusCode, [204, 304], true)) {
389+
$waitFor = '_0'; // no content expected
390+
$multi->handlesActivity[$id][] = null;
391+
$multi->handlesActivity[$id][] = null;
392+
} else {
393+
$waitFor[0] = 'C'; // C = content
355394
}
356395

357-
curl_setopt($ch, CURLOPT_PRIVATE, 'content');
396+
curl_setopt($ch, CURLOPT_PRIVATE, $waitFor);
358397
} elseif (null !== $info['redirect_url'] && $logger) {
359398
$logger->info(sprintf('Redirecting: "%s %s"', $info['http_code'], $info['redirect_url']));
360399
}

Response/MockResponse.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ private static function readResponse(self $response, array $options, ResponseInt
279279
foreach ($body as $chunk) {
280280
if ('' === $chunk = (string) $chunk) {
281281
// simulate an idle timeout
282-
$response->body[] = new ErrorChunk($offset);
282+
$response->body[] = new ErrorChunk($offset, sprintf('Idle timeout reached for "%s".', $response->info['url']));
283283
} else {
284284
$response->body[] = $chunk;
285285
$offset += \strlen($chunk);

Response/NativeResponse.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,6 @@ public function __construct(NativeClientState $multi, $context, string $url, $op
8080
public function getInfo(string $type = null)
8181
{
8282
if (!$info = $this->finalInfo) {
83-
self::perform($this->multi);
84-
8583
$info = $this->info;
8684
$info['url'] = implode('', $info['url']);
8785
unset($info['size_body'], $info['request_header']);

Response/ResponseTrait.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ public static function stream(iterable $responses, float $timeout = null): \Gene
289289
unset($responses[$j]);
290290
continue;
291291
} elseif ($isTimeout) {
292-
$multi->handlesActivity[$j] = [new ErrorChunk($response->offset)];
292+
$multi->handlesActivity[$j] = [new ErrorChunk($response->offset, sprintf('Idle timeout reached for "%s".', $response->getInfo('url')))];
293293
} else {
294294
continue;
295295
}

0 commit comments

Comments
 (0)