Skip to content

Commit 0b316c9

Browse files
authored
Merge pull request #114 from clue-labs/timeouts
Add HTTP timeout option
2 parents ce29538 + 32de457 commit 0b316c9

6 files changed

Lines changed: 223 additions & 31 deletions

File tree

README.md

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ mess with most of the low-level details.
3535
* [Methods](#methods)
3636
* [Promises](#promises)
3737
* [Cancellation](#cancellation)
38+
* [Timeouts](#timeouts)
3839
* [Authentication](#authentication)
3940
* [Redirects](#redirects)
4041
* [Blocking](#blocking)
@@ -172,6 +173,42 @@ $loop->addTimer(2.0, function () use ($promise) {
172173
});
173174
```
174175

176+
#### Timeouts
177+
178+
This library uses a very efficient HTTP implementation, so most HTTP requests
179+
should usually be completed in mere milliseconds. However, when sending HTTP
180+
requests over an unreliable network (the internet), there are a number of things
181+
that can go wrong and may cause the request to fail after a time. As such, this
182+
library respects PHP's `default_socket_timeout` setting (default 60s) as a timeout
183+
for sending the outgoing HTTP request and waiting for a successful response and
184+
will otherwise cancel the pending request and reject its value with an Exception.
185+
186+
Note that this timeout value covers creating the underlying transport connection,
187+
sending the HTTP request, receiving the HTTP response headers and its full
188+
response body and following any eventual [redirects](#redirects). See also
189+
[redirects](#redirects) below to configure the number of redirects to follow (or
190+
disable following redirects altogether) and also [streaming](#streaming) below
191+
to not take receiving large response bodies into account for this timeout.
192+
193+
You can use the [`timeout` option](#withoptions) to pass a custom timeout value
194+
in seconds like this:
195+
196+
```php
197+
$browser = $browser->withOptions(array(
198+
'timeout' => 10.0
199+
));
200+
201+
$browser->get($uri)->then(function (ResponseInterface $response) {
202+
// response received within 10 seconds maximum
203+
var_dump($response->getHeaders());
204+
});
205+
```
206+
207+
Similarly, you can use a negative timeout value to not apply a timeout at all
208+
or use a `null` value to restore the default handling. Note that the underlying
209+
connection may still impose a different timeout value. See also
210+
[`Browser`](#browser) above and [`withOptions()`](#withoptions) for more details.
211+
175212
#### Authentication
176213

177214
This library supports [HTTP Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication)
@@ -381,6 +418,12 @@ $body->read(); // throws BadMethodCallException
381418
$body->getContents(); // throws BadMethodCallException
382419
```
383420

421+
Note how [timeouts](#timeouts) apply slightly differently when using streaming.
422+
In streaming mode, the timeout value covers creating the underlying transport
423+
connection, sending the HTTP request, receiving the HTTP response headers and
424+
following any eventual [redirects](#redirects). In particular, the timeout value
425+
does not take receiving (possibly large) response bodies into account.
426+
384427
If you want to integrate the streaming response into a higher level API, then
385428
working with Promise objects that resolve with Stream objects is often inconvenient.
386429
Consider looking into also using [react/promise-stream](https://github.com/reactphp/promise-stream).
@@ -451,15 +494,16 @@ can be controlled via the following API (and their defaults):
451494

452495
```php
453496
$newBrowser = $browser->withOptions(array(
497+
'timeout' => null,
454498
'followRedirects' => true,
455499
'maxRedirects' => 10,
456500
'obeySuccessCode' => true,
457501
'streaming' => false,
458502
));
459503
```
460504

461-
See also [redirects](#redirects) and [streaming](#streaming) for more
462-
details.
505+
See also [timeouts](#timeouts), [redirects](#redirects) and
506+
[streaming](#streaming) for more details.
463507

464508
Notice that the [`Browser`](#browser) is an immutable object, i.e. this
465509
method actually returns a *new* [`Browser`](#browser) instance with the

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"react/http-client": "^0.5.8",
2121
"react/promise": "^2.2.1 || ^1.2.1",
2222
"react/promise-stream": "^1.0 || ^0.1.1",
23+
"react/promise-timer": "^1.2",
2324
"react/socket": "^1.1",
2425
"react/stream": "^1.0 || ^0.7",
2526
"ringcentral/psr7": "^1.2"

src/Browser.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class Browser
1919
private $messageFactory;
2020
private $baseUri = null;
2121

22+
/** @var LoopInterface $loop */
23+
private $loop;
24+
2225
/**
2326
* The `Browser` is responsible for sending HTTP requests to your HTTP server
2427
* and keeps track of pending incoming HTTP responses.
@@ -58,7 +61,8 @@ public function __construct(LoopInterface $loop, ConnectorInterface $connector =
5861
$this->messageFactory = new MessageFactory();
5962
$this->transaction = new Transaction(
6063
Sender::createFromLoop($loop, $connector, $this->messageFactory),
61-
$this->messageFactory
64+
$this->messageFactory,
65+
$loop
6266
);
6367
}
6468

@@ -249,15 +253,16 @@ public function withoutBase()
249253
*
250254
* ```php
251255
* $newBrowser = $browser->withOptions(array(
256+
* 'timeout' => null,
252257
* 'followRedirects' => true,
253258
* 'maxRedirects' => 10,
254259
* 'obeySuccessCode' => true,
255260
* 'streaming' => false,
256261
* ));
257262
* ```
258263
*
259-
* See also [redirects](#redirects) and [streaming](#streaming) for more
260-
* details.
264+
* See also [timeouts](#timeouts), [redirects](#redirects) and
265+
* [streaming](#streaming) for more details.
261266
*
262267
* Notice that the [`Browser`](#browser) is an immutable object, i.e. this
263268
* method actually returns a *new* [`Browser`](#browser) instance with the

src/Io/Transaction.php

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
use Psr\Http\Message\RequestInterface;
88
use Psr\Http\Message\ResponseInterface;
99
use Psr\Http\Message\UriInterface;
10+
use React\EventLoop\LoopInterface;
1011
use React\Promise\Deferred;
1112
use React\Promise\PromiseInterface;
13+
use React\Promise\Timer\TimeoutException;
1214
use React\Stream\ReadableStreamInterface;
1315

1416
/**
@@ -18,22 +20,27 @@ class Transaction
1820
{
1921
private $sender;
2022
private $messageFactory;
23+
private $loop;
2124

22-
// context: http.follow_location
25+
// context: http.timeout (ini_get('default_socket_timeout'): 60)
26+
private $timeout;
27+
28+
// context: http.follow_location (true)
2329
private $followRedirects = true;
2430

25-
// context: http.max_redirects
31+
// context: http.max_redirects (10)
2632
private $maxRedirects = 10;
2733

28-
// context: http.ignore_errors
34+
// context: http.ignore_errors (false)
2935
private $obeySuccessCode = true;
3036

3137
private $streaming = false;
3238

33-
public function __construct(Sender $sender, MessageFactory $messageFactory)
39+
public function __construct(Sender $sender, MessageFactory $messageFactory, LoopInterface $loop)
3440
{
3541
$this->sender = $sender;
3642
$this->messageFactory = $messageFactory;
43+
$this->loop = $loop;
3744
}
3845

3946
/**
@@ -47,7 +54,7 @@ public function withOptions(array $options)
4754
if (property_exists($transaction, $name)) {
4855
// restore default value if null is given
4956
if ($value === null) {
50-
$default = new self($this->sender, $this->messageFactory);
57+
$default = new self($this->sender, $this->messageFactory, $this->loop);
5158
$value = $default->$name;
5259
}
5360

@@ -74,7 +81,20 @@ public function send(RequestInterface $request)
7481
array($deferred, 'reject')
7582
);
7683

77-
return $deferred->promise();
84+
// use timeout from options or default to PHP's default_socket_timeout (60)
85+
$timeout = (float)($this->timeout !== null ? $this->timeout : ini_get("default_socket_timeout"));
86+
if ($timeout < 0) {
87+
return $deferred->promise();
88+
}
89+
90+
return \React\Promise\Timer\timeout($deferred->promise(), $timeout, $this->loop)->then(null, function ($e) {
91+
if ($e instanceof TimeoutException) {
92+
throw new \RuntimeException(
93+
'Request timed out after ' . $e->getTimeout() . ' seconds'
94+
);
95+
}
96+
throw $e;
97+
});
7898
}
7999

80100
private function next(RequestInterface $request, Deferred $deferred)

tests/FunctionalBrowserTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ public function testCancelRedirectedRequestShouldReject()
114114
Block\await($promise, $this->loop);
115115
}
116116

117+
/**
118+
* @expectedException RuntimeException
119+
* @expectedExceptionMessage Request timed out after 0.1 seconds
120+
* @group online
121+
*/
122+
public function testTimeoutDelayedResponseShouldReject()
123+
{
124+
$promise = $this->browser->withOptions(array('timeout' => 0.1))->get($this->base . 'delay/10');
125+
126+
Block\await($promise, $this->loop);
127+
}
128+
129+
/**
130+
* @group online
131+
* @doesNotPerformAssertions
132+
*/
133+
public function testTimeoutNegativeShouldResolveSuccessfully()
134+
{
135+
Block\await($this->browser->withOptions(array('timeout' => -1))->get($this->base . 'get'), $this->loop);
136+
}
137+
117138
/**
118139
* @group online
119140
* @doesNotPerformAssertions

0 commit comments

Comments
 (0)