Skip to content

Commit 749fe74

Browse files
committed
Add withResponseBuffer() method to limit maximum response buffer size
1 parent d1fcb44 commit 749fe74

File tree

7 files changed

+182
-3
lines changed

7 files changed

+182
-3
lines changed

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ mess with most of the low-level details.
6666
* [withRejectErrorResponse()](#withrejecterrorresponse)
6767
* [withBase()](#withbase)
6868
* [withProtocolVersion()](#withprotocolversion)
69+
* [withResponseBuffer()](#withresponsebuffer)
6970
* [~~withOptions()~~](#withoptions)
7071
* [~~withoutBase()~~](#withoutbase)
7172
* [ResponseInterface](#responseinterface)
@@ -1196,6 +1197,43 @@ Notice that the [`Browser`](#browser) is an immutable object, i.e. this
11961197
method actually returns a *new* [`Browser`](#browser) instance with the
11971198
new protocol version applied.
11981199

1200+
#### withResponseBuffer()
1201+
1202+
The `withRespomseBuffer(int $maximumSize): Browser` method can be used to
1203+
change the maximum size for buffering a response body.
1204+
1205+
The preferred way to send an HTTP request is by using the above
1206+
[request methods](#request-methods), for example the [`get()`](#get)
1207+
method to send an HTTP `GET` request. Each of these methods will buffer
1208+
the whole response body in memory by default. This is easy to get started
1209+
and works reasonably well for smaller responses.
1210+
1211+
By default, the response body buffer will be limited to 16 MiB. If the
1212+
response body exceeds this maximum size, the request will be rejected.
1213+
1214+
You can pass in the maximum number of bytes to buffer:
1215+
1216+
```php
1217+
$browser = $browser->withResponseBuffer(1024 * 1024);
1218+
1219+
$browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) {
1220+
// response body will not exceed 1 MiB
1221+
var_dump($response->getHeaders(), (string) $response->getBody());
1222+
});
1223+
```
1224+
1225+
Note that the response body buffer has to be kept in memory for each
1226+
pending request until its transfer is completed and it will only be freed
1227+
after a pending request is fulfilled. As such, increasing this maximum
1228+
buffer size to allow larger response bodies is usually not recommended.
1229+
Instead, you can use the [`requestStreaming()` method](#requeststreaming)
1230+
to receive responses with arbitrary sizes without buffering. Accordingly,
1231+
this maximum buffer size setting has no effect on streaming responses.
1232+
1233+
Notice that the [`Browser`](#browser) is an immutable object, i.e. this
1234+
method actually returns a *new* [`Browser`](#browser) instance with the
1235+
given setting applied.
1236+
11991237
#### ~~withOptions()~~
12001238

12011239
> Deprecated since v2.9.0, see [`withTimeout()`](#withtimeout), [`withFollowRedirects()`](#withfollowredirects)

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"react/event-loop": "^1.0 || ^0.5",
2323
"react/http-client": "^0.5.10",
2424
"react/promise": "^2.2.1 || ^1.2.1",
25-
"react/promise-stream": "^1.0 || ^0.1.1",
25+
"react/promise-stream": "^1.0 || ^0.1.2",
2626
"react/socket": "^1.1",
2727
"react/stream": "^1.0 || ^0.7",
2828
"ringcentral/psr7": "^1.2"

src/Browser.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,52 @@ public function withProtocolVersion($protocolVersion)
744744
return $browser;
745745
}
746746

747+
/**
748+
* Changes the maximum size for buffering a response body.
749+
*
750+
* The preferred way to send an HTTP request is by using the above
751+
* [request methods](#request-methods), for example the [`get()`](#get)
752+
* method to send an HTTP `GET` request. Each of these methods will buffer
753+
* the whole response body in memory by default. This is easy to get started
754+
* and works reasonably well for smaller responses.
755+
*
756+
* By default, the response body buffer will be limited to 16 MiB. If the
757+
* response body exceeds this maximum size, the request will be rejected.
758+
*
759+
* You can pass in the maximum number of bytes to buffer:
760+
*
761+
* ```php
762+
* $browser = $browser->withResponseBuffer(1024 * 1024);
763+
*
764+
* $browser->get($url)->then(function (Psr\Http\Message\ResponseInterface $response) {
765+
* // response body will not exceed 1 MiB
766+
* var_dump($response->getHeaders(), (string) $response->getBody());
767+
* });
768+
* ```
769+
*
770+
* Note that the response body buffer has to be kept in memory for each
771+
* pending request until its transfer is completed and it will only be freed
772+
* after a pending request is fulfilled. As such, increasing this maximum
773+
* buffer size to allow larger response bodies is usually not recommended.
774+
* Instead, you can use the [`requestStreaming()` method](#requeststreaming)
775+
* to receive responses with arbitrary sizes without buffering. Accordingly,
776+
* this maximum buffer size setting has no effect on streaming responses.
777+
*
778+
* Notice that the [`Browser`](#browser) is an immutable object, i.e. this
779+
* method actually returns a *new* [`Browser`](#browser) instance with the
780+
* given setting applied.
781+
*
782+
* @param int $maximumSize
783+
* @return self
784+
* @see self::requestStreaming()
785+
*/
786+
public function withResponseBuffer($maximumSize)
787+
{
788+
return $this->withOptions(array(
789+
'maximumSize' => $maximumSize
790+
));
791+
}
792+
747793
/**
748794
* [Deprecated] Changes the [options](#options) to use:
749795
*

src/Io/Transaction.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ class Transaction
3535

3636
private $streaming = false;
3737

38+
private $maximumSize = 16777216; // 16 MiB = 2^24 bytes
39+
3840
public function __construct(Sender $sender, MessageFactory $messageFactory, LoopInterface $loop)
3941
{
4042
$this->sender = $sender;
@@ -169,21 +171,38 @@ public function bufferResponse(ResponseInterface $response, $deferred)
169171
{
170172
$stream = $response->getBody();
171173

174+
$size = $stream->getSize();
175+
if ($size !== null && $size > $this->maximumSize) {
176+
$stream->close();
177+
return \React\Promise\reject(new \OverflowException(
178+
'Response body size of ' . $size . ' bytes exceeds maximum of ' . $this->maximumSize . ' bytes',
179+
\defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 0
180+
));
181+
}
182+
172183
// body is not streaming => already buffered
173184
if (!$stream instanceof ReadableStreamInterface) {
174185
return \React\Promise\resolve($response);
175186
}
176187

177188
// buffer stream and resolve with buffered body
178189
$messageFactory = $this->messageFactory;
179-
$promise = \React\Promise\Stream\buffer($stream)->then(
190+
$maximumSize = $this->maximumSize;
191+
$promise = \React\Promise\Stream\buffer($stream, $maximumSize)->then(
180192
function ($body) use ($response, $messageFactory) {
181193
return $response->withBody($messageFactory->body($body));
182194
},
183-
function ($e) use ($stream) {
195+
function ($e) use ($stream, $maximumSize) {
184196
// try to close stream if buffering fails (or is cancelled)
185197
$stream->close();
186198

199+
if ($e instanceof \OverflowException) {
200+
$e = new \OverflowException(
201+
'Response body size exceeds maximum of ' . $this->maximumSize . ' bytes',
202+
\defined('SOCKET_EMSGSIZE') ? \SOCKET_EMSGSIZE : 0
203+
);
204+
}
205+
187206
throw $e;
188207
}
189208
);

tests/BrowserTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ public function testWithRejectErrorResponseFalseSetsSenderOption()
203203
$this->browser->withRejectErrorResponse(false);
204204
}
205205

206+
public function testWithResponseBufferThousandSetsSenderOption()
207+
{
208+
$this->sender->expects($this->once())->method('withOptions')->with(array('maximumSize' => 1000))->willReturnSelf();
209+
210+
$this->browser->withResponseBuffer(1000);
211+
}
212+
206213
public function testWithBase()
207214
{
208215
$browser = $this->browser->withBase('http://example.com/root');

tests/FunctionalBrowserTest.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,40 @@ public function testResponseStatus300WithoutLocationShouldResolveWithoutFollowin
315315
Block\await($this->browser->get($this->base . 'status/300'), $this->loop);
316316
}
317317

318+
/**
319+
* @doesNotPerformAssertions
320+
*/
321+
public function testGetRequestWithResponseBufferMatchedExactlyResolves()
322+
{
323+
$promise = $this->browser->withResponseBuffer(5)->get($this->base . 'get');
324+
325+
Block\await($promise, $this->loop);
326+
}
327+
328+
public function testGetRequestWithResponseBufferExceededRejects()
329+
{
330+
$promise = $this->browser->withResponseBuffer(4)->get($this->base . 'get');
331+
332+
$this->setExpectedException(
333+
'OverflowException',
334+
'Response body size of 5 bytes exceeds maximum of 4 bytes',
335+
defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0
336+
);
337+
Block\await($promise, $this->loop);
338+
}
339+
340+
public function testGetRequestWithResponseBufferExceededDuringStreamingRejects()
341+
{
342+
$promise = $this->browser->withResponseBuffer(4)->get($this->base . 'stream/1');
343+
344+
$this->setExpectedException(
345+
'OverflowException',
346+
'Response body size exceeds maximum of 4 bytes',
347+
defined('SOCKET_EMSGSIZE') ? SOCKET_EMSGSIZE : 0
348+
);
349+
Block\await($promise, $this->loop);
350+
}
351+
318352
/**
319353
* @group online
320354
* @doesNotPerformAssertions
@@ -595,4 +629,16 @@ public function testRequestStreamingGetReceivesStreamingResponseEvenWhenStreamin
595629
$this->assertInstanceOf('React\Stream\ReadableStreamInterface', $response->getBody());
596630
$this->assertEquals('', (string)$response->getBody());
597631
}
632+
633+
public function testRequestStreamingGetReceivesStreamingResponseBodyEvenWhenResponseBufferExceeded()
634+
{
635+
$buffer = Block\await(
636+
$this->browser->withResponseBuffer(4)->requestStreaming('GET', $this->base . 'get')->then(function (ResponseInterface $response) {
637+
return Stream\buffer($response->getBody());
638+
}),
639+
$this->loop
640+
);
641+
642+
$this->assertEquals('hello', $buffer);
643+
}
598644
}

tests/Io/TransactionTest.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,29 @@ public function testReceivingStreamingBodyWillResolveWithBufferedResponseByDefau
433433
$this->assertEquals('hello world', (string)$response->getBody());
434434
}
435435

436+
public function testReceivingStreamingBodyWithSizeExceedingMaximumResponseBufferWillRejectAndCloseResponseStream()
437+
{
438+
$messageFactory = new MessageFactory();
439+
$loop = Factory::create();
440+
441+
$stream = new ThroughStream();
442+
$stream->on('close', $this->expectCallableOnce());
443+
444+
$request = $this->getMockBuilder('Psr\Http\Message\RequestInterface')->getMock();
445+
446+
$response = $messageFactory->response(1.0, 200, 'OK', array('Content-Length' => '100000000'), $stream);
447+
448+
// mock sender to resolve promise with the given $response in response to the given $request
449+
$sender = $this->makeSenderMock();
450+
$sender->expects($this->once())->method('send')->with($this->equalTo($request))->willReturn(Promise\resolve($response));
451+
452+
$transaction = new Transaction($sender, $messageFactory, $loop);
453+
$promise = $transaction->send($request);
454+
455+
$this->setExpectedException('OverflowException');
456+
Block\await($promise, $loop, 0.001);
457+
}
458+
436459
public function testCancelBufferingResponseWillCloseStreamAndReject()
437460
{
438461
$messageFactory = new MessageFactory();

0 commit comments

Comments
 (0)