8000 [HttpClient] Fix handling timeouts when responses are destructed · symfony/symfony@1f8cd37 · GitHub
[go: up one dir, main page]

Skip to content

Commit 1f8cd37

Browse files
[HttpClient] Fix handling timeouts when responses are destructed
1 parent 7b54c60 commit 1f8cd37

12 files changed

+88
-10
lines changed

src/Symfony/Component/HttpClient/Internal/ClientState.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,5 @@ class ClientState
2222
{
2323
public $handlesActivity = [];
2424
public $openHandles = [];
25+
public $lastTimeout;
2526
}

src/Symfony/Component/HttpClient/Response/AmpResponse.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ final class AmpResponse implements ResponseInterface, StreamableInterface
4545

4646
private static $nextId = 'a';
4747

48-
private $multi;
4948
private $options;
5049
private $canceller;
5150
private $onProgress;

src/Symfony/Component/HttpClient/Response/AsyncResponse.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ public function __construct(HttpClientInterface $client, string $method, string
5757
}
5858
$this->response = $client->request($method, $url, ['buffer' => false] + $options);
5959
$this->passthru = $passthru;
60-
$this->initializer = static function (self $response) {
60+
$this->initializer = static function (self $response, float $timeout = null) {
6161
if (null === $response->shouldBuffer) {
6262
return false;
6363
}
6464

6565
while (true) {
66-
foreach (self::stream([$response]) as $chunk) {
66+
foreach (self::stream([$response], $timeout) as $chunk) {
6767
if ($chunk->isTimeout() && $response->passthru) {
6868
foreach (self::passthru($response->client, $response, new ErrorChunk($response->offset, new TransportException($chunk->getError()))) as $chunk) {
6969
if ($chunk->isFirst()) {
@@ -179,6 +179,7 @@ public function __destruct()
179179

180180
if ($this->initializer && null === $this->getInfo('error')) {
181181
try {
182+
self::initialize($this, -0.0);
182183
$this->getHeaders(true);
183184
} catch (HttpExceptionInterface $httpException) {
184185
// no-op

src/Symfony/Component/HttpClient/Response/CommonResponseTrait.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,15 +145,15 @@ public function __wakeup()
145145
*/
146146
abstract protected function close(): void;
147147

148-
private static function initialize(self $response): void
148+
private static function initialize(self $response, float $timeout = null): void
149149
{
150150
if (null !== $response->getInfo('error')) {
151151
throw new TransportException($response->getInfo('error'));
152152
}
153153

154154
try {
155-
if (($response->initializer)($response)) {
156-
foreach (self::stream([$response]) as $chunk) {
155+
if (($response->initializer)($response, $timeout)) {
156+
foreach (self::stream([$response], $timeout) as $chunk) {
157157
if ($chunk->isFirst()) {
158158
break;
159159
}

src/Symfony/Component/HttpClient/Response/CurlResponse.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ final class CurlResponse implements ResponseInterface, StreamableInterface
3333
use TransportResponseTrait;
3434

3535
private static $performing = false;
36-
private $multi;
3736
private $debugBuffer;
3837

3938
/**

src/Symfony/Component/HttpClient/Response/NativeResponse.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ final class NativeResponse implements ResponseInterface, StreamableInterface
3535
private $onProgress;
3636
private $remaining;
3737
private $buffer;
38-
private $multi;
3938
private $debugBuffer;
4039
private $shouldBuffer;
4140
private $pauseExpiry = 0;

src/Symfony/Component/HttpClient/Response/TransportResponseTrait.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ trait TransportResponseTrait
4343
private $finalInfo;
4444
private $canary;
4545
private $logger;
46+
/** @var ClientState|null */
47+
private $multi = null;
4648

4749
/**
4850
* {@inheritdoc}
@@ -138,7 +140,7 @@ private function doDestruct()
138140
$this->shouldBuffer = true;
139141

140142
if ($this->initializer && null === $this->info['error']) {
141-
self::initialize($this);
143+
self::initialize($this, -0.0);
142144
$this->checkStatusCode();
143145
}
144146
}
@@ -159,6 +161,12 @@ public static function stream(iterable $responses, float $timeout = null): \Gene
159161
$lastActivity = microtime(true);
160162
$elapsedTimeout = 0;
161163

164+
if ($fromLastTimeout = 0.0 === $timeout && '-0' === (string) $timeout) {
165+
$timeout = null;
166+
} elseif ($fromLastTimeout = 0 > $timeout) {
167+
$timeout = -$timeout;
168+
}
169+
162170
while (true) {
163171
$hasActivity = false;
164172
$timeoutMax = 0;
@@ -172,15 +180,21 @@ public static function stream(iterable $responses, float $timeout = null): \Gene
172180
foreach ($responses as $j => $response) {
173181
$timeoutMax = $timeout ?? max($timeoutMax, $response->timeout);
174182
$timeoutMin = min($timeoutMin, $response->timeout, 1);
183+
184+
if ($fromLastTimeout && null !== $multi->lastTimeout) {
185+
$elapsedTimeout = microtime(true) - $multi->lastTimeout;
186+
}
187+
175188
$chunk = false;
176189

177190
if (isset($multi->handlesActivity[$j])) {
178-
// no-op
191+
$multi->lastTimeout = null;
179192
} elseif (!isset($multi->openHandles[$j])) {
180193
unset($responses[$j]);
181194
continue;
182195
} elseif ($elapsedTimeout >= $timeoutMax) {
183196
$multi->handlesActivity[$j] = [new ErrorChunk($response->offset, sprintf('Idle timeout reached for "%s".', $response->getInfo('url')))];
197+
$multi->lastTimeout ?? $multi->lastTimeout = $lastActivity;
184198
} else {
185199
continue;
186200
}

src/Symfony/Component/HttpClient/Tests/AsyncDecoratorTraitTest.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
namespace Symfony\Component\HttpClient\Tests;
1313

1414
use Symfony\Component\HttpClient\AsyncDecoratorTrait;
15+
use Symfony\Component\HttpClient\CurlHttpClient;
1516
use Symfony\Component\HttpClient\DecoratorTrait;
17+
use Symfony\Component\HttpClient\HttpClient;
1618
use Symfony\Component\HttpClient\Response\AsyncContext;
1719
use Symfony\Component\HttpClient\Response\AsyncResponse;
1820
use Symfony\Contracts\HttpClient\ChunkInterface;
@@ -29,6 +31,10 @@ protected function getHttpClient(string $testCase, \Closure $chunkFilter = null,
2931
$this->markTestSkipped("AsyncDecoratorTrait doesn't cache handles");
3032
}
3133

34+
if ('testTimeoutOnDestruct' === $testCase) {
35+
return new CurlHttpClient();
36+
}
37+
3238
$chunkFilter = $chunkFilter ?? static function (ChunkInterface $chunk, AsyncContext $context) { yield $chunk; };
3339

3440
return new class($decoratedClient ?? parent::getHttpClient($testCase), $chunkFilter) implements HttpClientInterface {
@@ -49,6 +55,15 @@ public function request(string $method, string $url, array $options = []): Respo
4955
};
5056
}
5157

58+
public function testTimeoutOnDestruct()
59+
{
60+
if (HttpClient::create() instanceof NativeHttpClient) {
61+
parent::testTimeoutOnDestruct();
62+
} else {
63+
HttpClientTestCase::testTimeoutOnDestruct();
64+
}
65+
}
66+
5267
public function testRetry404()
5368
{
5469
$client = $this->getHttpClient(__FUNCTION__, function (ChunkInterface $chunk, AsyncContext $context) {

src/Symfony/Component/HttpClient/Tests/HttpClientTestCase.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ abstract class HttpClientTestCase extends BaseHttpClientTestCase
3232
{
3333
private static $vulcainStarted = false;
3434

35+
public function testTimeoutOnDestruct()
36+
{
37+
if (!method_exists(parent::class, 'testTimeoutOnDestruct')) {
38+
$this->markTestSkipped('BaseHttpClientTestCase doesn\'t have testTimeoutOnDestruct().');
39+
}
40+
41+
parent::testTimeoutOnDestruct();
42+
}
43+
3544
public function testAcceptHeader()
3645
{
3746
$client = $this->getHttpClient(__FUNCTION__);

src/Symfony/Component/HttpClient/Tests/MockHttpClientTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ protected function getHttpClient(string $testCase): HttpClientInterface
236236
$this->markTestSkipped('Real transport required');
237237
break;
238238

239+
case 'testTimeoutOnDestruct':
240+
$this->markTestSkipped('Real transport required');
241+
break;
242+
239243
case 'testDestruct':
240244
$this->markTestSkipped("MockHttpClient doesn't timeout on destruct");
241245
break;

src/Symfony/Component/HttpClient/Tests/NativeHttpClientTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ public function testInformationalResponseStream()
2626
$this->markTestSkipped('NativeHttpClient doesn\'t support informational status codes.');
2727
}
2828

29+
public function testTimeoutOnDestruct()
30+
{
31+
$this->markTestSkipped('NativeHttpClient doesn\'t support opening concurrent requests.');
32+
}
33+
2934
public function testHttp2PushVulcain()
3035
{
3136
$this->markTestSkipped('NativeHttpClient doesn\'t support HTTP/2.');

src/Symfony/Contracts/HttpClient/Test/HttpClientTestCase.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,38 @@ public function testTimeoutWithActiveConcurrentStream()
835835
}
836836
}
837837

838+
public function testTimeoutOnDestruct()
839+
{
840+
$p1 = TestHttpServer::start(8067);
841+
$p2 = TestHttpServer::start(8077);
842+
843+
$client = $this->getHttpClient(__FUNCTION__);
844+
$start = microtime(true);
845+
$responses = [];
846+
847+
$responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]);
848+
$responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]);
849+
$responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]);
850+
$responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]);
851+
852+
try {
853+
while ($response = array_shift($responses)) {
854+
try {
855+
unset($response);
856+
$this->fail(TimeoutExceptionInterface::class.' expected');
857+
} catch (TimeoutExceptionInterface $e) {
858+
}
859+
}
860+
861+
$duration = microtime(true) - $start;
862+
863+
$this->assertLessThan(0.75, $duration);
864+
} finally {
865+
$p1->stop();
866+
$p2->stop();
867+
}
868+
}
869+
838870
public function testDestruct()
839871
{
840872
$client = $this->getHttpClient(__FUNCTION__);

0 commit comments

Comments
 (0)
0