Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 3270726

Browse files
bug #37491 [HttpClient] Fix promise behavior in HttplugClient (brentybh)
This PR was squashed before being merged into the 4.4 branch. Discussion ---------- [HttpClient] Fix promise behavior in HttplugClient | Q | A | ------------- | --- | Branch? | 4.4 & up <!-- see below --> | Bug fix? | yes | New feature? | no <!-- please update src/**/CHANGELOG.md files --> | Deprecations? | no <!-- please update UPGRADE-*.md and src/**/CHANGELOG.md files --> | Tickets | Fix #37488 <!-- prefix each issue number with "Fix #", if any --> | License | MIT | Doc PR | symfony/symfony-docs#... <!-- required for new features --> <!-- Replace this notice by a short README for your feature/bugfix. This will help people understand your PR and can be used as a start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Never break backward compatibility (see https://symfony.com/bc). - Bug fixes must be submitted against the lowest maintained branch where they apply (lowest branches are regularly merged to upper ones so they get the fixes too.) - Features and deprecations must be submitted against branch master. --> ## The Problem Promises have 2 important methods: `then` and `wait`. To implement Httplug's promise interface, we built `HttplugPromise` on top of Guzzle promise. However, when an error occurred (Httplug `NetworkException` thrown) while init the request/before actually sending the request, `HttplugClient::sendAsyncRequest` will return a `Http\Promise\RejectedPromise`, which is a dummy implementation. If the `then` callable returns a promise-like object, `Http\Promise\RejectedPromise` will treat it as plain value. Guzzle promise will try to resolve the promise-like value, which is an object that has `then` method on it. https://github.com/guzzle/promises/blob/bbf3b200bc83c1e9298580a9f99b9be248543467/src/Promise.php#L116 To fix this, I edited `src/Symfony/Component/HttpClient/HttplugClient.php`. Next, let me explain why to edit `src/Symfony/Component/HttpClient/Response/HttplugPromise.php`. After the previous fix, when a Guzzle promise returned by the `then` callable, things will work. However, If I return a `HttplugPromiseInterface`, it doesn't work, because Guzzle promise `wait` the return value (result) only if it's a Guzzle promise. https://github.com/guzzle/promises/blob/bbf3b200bc83c1e9298580a9f99b9be248543467/src/Promise.php#L63 To fix this, I referenced the `wait` code of Guzzle promise and edited our `HttplugPromise`. ## How this fix make sense So, why to return a promise from the `then` callable? This let us change the promise chain according to current promise's result (fulfilled/rejected). For example, we can retry an HTTP request if it failed. Please take a look at my test code. Commits ------- 147b6ad [HttpClient] Fix promise behavior in HttplugClient
2 parents 5da1536 + 147b6ad commit 3270726

File tree

3 files changed

+124
-3
lines changed

3 files changed

+124
-3
lines changed

src/Symfony/Component/HttpClient/HttplugClient.php

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

1414
use GuzzleHttp\Promise\Promise as GuzzlePromise;
15+
use GuzzleHttp\Promise\RejectedPromise;
1516
use Http\Client\Exception\NetworkException;
1617
use Http\Client\Exception\RequestException;
1718
use Http\Client\HttpAsyncClient;
@@ -22,7 +23,6 @@
2223
use Http\Message\StreamFactory;
2324
use Http\Message\UriFactory;
2425
use Http\Promise\Promise;
25-
use Http\Promise\RejectedPromise;
2626
use Nyholm\Psr7\Factory\Psr17Factory;
2727
use Nyholm\Psr7\Request;
2828
use Nyholm\Psr7\Uri;
@@ -114,7 +114,7 @@ public function sendAsyncRequest(RequestInterface $request): Promise
114114
try {
115115
$response = $this->sendPsr7Request($request, true);
116116
} catch (NetworkException $e) {
117-
return new RejectedPromise($e);
117+
return new HttplugPromise(new RejectedPromise($e));
118118
}
119119

120120
$waitLoop = $this->waitLoop;

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ public function getState(): string
5454
*/
5555
public function wait($unwrap = true)
5656
{
57-
return $this->promise->wait($unwrap);
57+
$result = $this->promise->wait($unwrap);
58+
59+
while ($result instanceof HttplugPromiseInterface || $result instanceof GuzzlePromiseInterface) {
60+
$result = $result->wait($unwrap);
61+
}
62+
63+
return $result;
5864
}
5965
}

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

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@
1111

1212
namespace Symfony\Component\HttpClient\Tests;
1313

14+
use GuzzleHttp\Promise\FulfilledPromise as GuzzleFulfilledPromise;
1415
use Http\Client\Exception\NetworkException;
1516
use Http\Client\Exception\RequestException;
17+
use Http\Promise\FulfilledPromise;
1618
use Http\Promise\Promise;
1719
use PHPUnit\Framework\TestCase;
1820
use Psr\Http\Message\ResponseInterface;
21+
use Symfony\Component\HttpClient\Exception\TransportException;
1922
use Symfony\Component\HttpClient\HttplugClient;
23+
use Symfony\Component\HttpClient\MockHttpClient;
2024
use Symfony\Component\HttpClient\NativeHttpClient;
25+
use Symfony\Component\HttpClient\Response\MockResponse;
2126
use Symfony\Contracts\HttpClient\Test\TestHttpServer;
2227

2328
class HttplugClientTest extends TestCase
@@ -152,4 +157,114 @@ public function testRequestException()
152157
$this->expectException(RequestException::class);
153158
$client->sendRequest($client->createRequest('BAD.METHOD', 'http://localhost:8057'));
154159
}
160+
161+
public function testRetry404()
162+
{
163+
$client = new HttplugClient(new NativeHttpClient());
164+
165+
$successCallableCalled = false;
166+
$failureCallableCalled = false;
167+
168+
$promise = $client
169+
->sendAsyncRequest($client->createRequest('GET', 'http://localhost:8057/404'))
170+
->then(
171+
function (ResponseInterface $response) use (&$successCallableCalled, $client) {
172+
$this->assertSame(404, $response->getStatusCode());
173+
$successCallableCalled = true;
174+
175+
return $client->sendAsyncRequest($client->createRequest('GET', 'http://localhost:8057'));
176+
},
177+
function (\Exception $exception) use (&$failureCallableCalled) {
178+
$failureCallableCalled = true;
179+
180+
throw $exception;
181+
}
182+
)
183+
;
184+
185+
$response = $promise->wait(true);
186+
187+
$this->assertTrue($successCallableCalled);
188+
$this->assertFalse($failureCallableCalled);
189+
$this->assertSame(200, $response->getStatusCode());
190+
}
191+
192+
public function testRetryNetworkError()
193+
{
194+
$client = new HttplugClient(new NativeHttpClient());
195+
196+
$successCallableCalled = false;
197+
$failureCallableCalled = false;
198+
199+
$promise = $client
200+
->sendAsyncRequest($client->createRequest('GET', 'http://localhost:8057/chunked-broken'))
201+
->then(function (ResponseInterface $response) use (&$successCallableCalled) {
202+
$successCallableCalled = true;
203+
204+
return $response;
205+
}, function (\Exception $exception) use (&$failureCallableCalled, $client) {
206+
$this->assertSame(NetworkException::class, \get_class($exception));
207+
$this->assertSame(TransportException::class, \get_class($exception->getPrevious()));
208+
$failureCallableCalled = true;
209+
210+
return $client->sendAsyncRequest($client->createRequest('GET', 'http://localhost:8057'));
211+
})
212+
;
213+
214+
$response = $promise->wait(true);
215+
216+
$this->assertFalse($successCallableCalled);
217+
$this->assertTrue($failureCallableCalled);
218+
$this->assertSame(200, $response->getStatusCode());
219+
}
220+
221+
public function testRetryEarlierError()
222+
{
223+
$isFirstRequest = true;
224+
$errorMessage = 'Error occurred before making the actual request.';
225+
226+
$client = new HttplugClient(new MockHttpClient(function () use (&$isFirstRequest, $errorMessage) {
227+
if ($isFirstRequest) {
228+
$isFirstRequest = false;
229+
throw new TransportException($errorMessage);
230+
}
231+
232+
return new MockResponse('OK', ['http_code' => 200]);
233+
}));
234+
235+
$request = $client->createRequest('GET', 'http://test');
236+
237+
$successCallableCalled = false;
238+
$failureCallableCalled = false;
239+
240+
$promise = $client
241+
->sendAsyncRequest($request)
242+
->then(
243+
function (ResponseInterface $response) use (&$successCallableCalled) {
244+
$successCallableCalled = true;
245+
246+
return $response;
247+
},
248+
function (\Exception $exception) use ($errorMessage, &$failureCallableCalled, $client, $request) {
249+
$this->assertSame(NetworkException::class, \get_class($exception));
250+
$this->assertSame($errorMessage, $exception->getMessage());
251+
$failureCallableCalled = true;
252+
253+
// Ensure arbitrary levels of promises work.
254+
return (new FulfilledPromise(null))->then(function () use ($client, $request) {
255+
return (new GuzzleFulfilledPromise(null))->then(function () use ($client, $request) {
256+
return $client->sendAsyncRequest($request);
257+
});
258+
});
259+
}
260+
)
261+
;
262+
263+
$response = $promise->wait(true);
264+
265+
$this->assertFalse($successCallableCalled);
266+
$this->assertTrue($failureCallableCalled);
267+
$this->assertSame(200, $response->getStatusCode());
268+
$this->assertSame('OK', (string) $response->getBody());
269+
}
155270
}

0 commit comments

Comments
 (0)