Skip to content

Commit

Permalink
[HttpClient] improve handling of HTTP/2 PUSH
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolas-grekas committed Sep 3, 2019
1 parent d26a656 commit f5b9d3a
Show file tree
Hide file tree
Showing 4 changed files with 52 additions and 45 deletions.
64 changes: 37 additions & 27 deletions src/Symfony/Component/HttpClient/CurlHttpClient.php
Expand Up @@ -105,35 +105,29 @@ public function request(string $method, string $url, array $options = []): Respo
$host = parse_url($authority, PHP_URL_HOST);
$url = implode('', $url);

if (!isset($options['normalized_headers']['user-agent'])) {
$options['headers'][] = 'User-Agent: '.$options['normalized_headers']['user-agent'][] = 'Symfony HttpClient/Curl';
}

if ($pushedResponse = $this->multi->pushedResponses[$url] ?? null) {
unset($this->multi->pushedResponses[$url]);
// Accept pushed responses only if their headers related to authentication match the request
$expectedHeaders = ['authorization', 'cookie', 'x-requested-with', 'range'];
foreach ($expectedHeaders as $k => $v) {
$expectedHeaders[$k] = null;

foreach ($options['normalized_headers'][$v] ?? [] as $h) {
$expectedHeaders[$k][] = substr($h, 2 + \strlen($v));
}
}

if ('GET' === $method && $expectedHeaders === $pushedResponse->headers && !$options['body']) {
$this->logger && $this->logger->debug(sprintf('Connecting request to pushed response: "%s %s"', $method, $url));
if (self::acceptPushForRequest($method, $options, $pushedResponse)) {
$this->logger && $this->logger->debug(sprintf('Accepting pushed response: "%s %s"', $method, $url));

// Reinitialize the pushed response with request's options
$pushedResponse->response->__construct($this->multi, $url, $options, $this->logger);

return $pushedResponse->response;
}

$this->logger && $this->logger->debug(sprintf('Rejecting pushed response for "%s": authorization headers don\'t match the request', $url));
$this->logger && $this->logger->debug(sprintf('Rejecting pushed response: "%s".', $url));
}

$this->logger && $this->logger->info(sprintf('Request: "%s %s"', $method, $url));

$curlopts = [
CURLOPT_URL => $url,
CURLOPT_USERAGENT => 'Symfony HttpClient/Curl',
CURLOPT_TCP_NODELAY => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
Expand Down Expand Up @@ -306,7 +300,7 @@ public function __destruct()
$active = 0;
while (CURLM_CALL_MULTI_PERFORM === curl_multi_exec($this->multi->handle, $active));

foreach ($this->multi->openHandles as $ch) {
foreach ($this->multi->openHandles as [$ch]) {
curl_setopt($ch, CURLOPT_VERBOSE, false);
}
}
Expand All @@ -318,17 +312,17 @@ private static function handlePush($parent, $pushed, array $requestHeaders, Curl

foreach ($requestHeaders as $h) {
if (false !== $i = strpos($h, ':', 1)) {
$headers[substr($h, 0, $i)] = substr($h, 1 + $i);
$headers[substr($h, 0, $i)][] = substr($h, 1 + $i);
}
}

if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path']) || 'GET' !== $headers[':method'] || isset($headers['range'])) {
if (!isset($headers[':method']) || !isset($headers[':scheme']) || !isset($headers[':authority']) || !isset($headers[':path'])) {
$logger && $logger->debug(sprintf('Rejecting pushed response from "%s": pushed headers are invalid', $origin));

return CURL_PUSH_DENY;
}

$url = $headers[':scheme'].'://'.$headers[':authority'];
$url = $headers[':scheme'][0].'://'.$headers[':authority'][0];

if ($maxPendingPushes <= \count($multi->pushedResponses)) {
$logger && $logger->debug(sprintf('Rejecting pushed response from "%s" for "%s": the queue is full', $origin, $url));
Expand All @@ -345,22 +339,38 @@ private static function handlePush($parent, $pushed, array $requestHeaders, Curl
return CURL_PUSH_DENY;
}

$url .= $headers[':path'];
$url .= $headers[':path'][0];
$logger && $logger->debug(sprintf('Queueing pushed response: "%s"', $url));

$multi->pushedResponses[$url] = new PushedResponse(
new CurlResponse($multi, $pushed),
[
$headers['authorization'] ?? null,
$headers['cookie'] ?? null,
$headers['x-requested-with'] ?? null,
null,
]
);
$multi->pushedResponses[$url] = new PushedResponse(new CurlResponse($multi, $pushed), $headers, $multi->openHandles[(int) $parent][1] ?? []);

return CURL_PUSH_OK;
}

/**
* Accepts pushed responses only if their headers related to authentication match the request.
*/
private static function acceptPushForRequest(string $method, array $options, PushedResponse $pushedResponse): bool
{
if ($options['body'] || $method !== $pushedResponse->requestHeaders[':method'][0]) {
return false;
}

foreach (['proxy', 'no_proxy', 'bindto'] as $k) {
if ($options[$k] !== $pushedResponse->parentOptions[$k]) {
return false;
}
}

foreach (['authorization', 'cookie', 'range', 'proxy-authorization'] as $k) {
if (($pushedResponse->requestHeaders[$k] ?? null) !== ($options['normalized_headers'][$k] ?? null)) {
return false;
}
}

return true;
}

/**
* Wraps the request's body callback to allow it to return strings longer than curl requested.
*/
Expand Down
12 changes: 7 additions & 5 deletions src/Symfony/Component/HttpClient/Internal/PushedResponse.php
Expand Up @@ -14,23 +14,25 @@
use Symfony\Component\HttpClient\Response\CurlResponse;

/**
* A pushed response with headers.
* A pushed response with its request headers.
*
* @author Alexander M. Turek <me@derrabus.de>
*
* @internal
*/
final class PushedResponse
{
/** @var CurlResponse */
public $response;

/** @var string[] */
public $headers;
public $requestHeaders;

public function __construct(CurlResponse $response, array $headers)
public $parentOptions = [];

public function __construct(CurlResponse $response, array $requestHeaders, array $parentOptions)
{
$this->response = $response;
$this->headers = $headers;
$this->requestHeaders = $requestHeaders;
$this->parentOptions = $parentOptions;
}
}
2 changes: 1 addition & 1 deletion src/Symfony/Component/HttpClient/Response/CurlResponse.php
Expand Up @@ -140,7 +140,7 @@ public function __construct(CurlClientState $multi, $ch, array $options = null,
};

// Schedule the request in a non-blocking way
$multi->openHandles[$id] = $ch;
$multi->openHandles[$id] = [$ch, $options];
curl_multi_add_handle($multi->handle, $ch);
self::perform($multi);
}
Expand Down
19 changes: 7 additions & 12 deletions src/Symfony/Component/HttpClient/Tests/CurlHttpClientTest.php
Expand Up @@ -50,24 +50,19 @@ public function log($level, $message, array $context = [])
$client = new CurlHttpClient();
$client->setLogger($logger);

$index = $client->request('GET', 'https://http2-push.io');
$index = $client->request('GET', 'https://http2.akamai.com/');
$index->getContent();

$css = $client->request('GET', 'https://http2-push.io/css/style.css');
$js = $client->request('GET', 'https://http2-push.io/js/http2-push.js');
$css = $client->request('GET', 'https://http2.akamai.com/resources/push.css');

$css->getHeaders();
$js->getHeaders();

$expected = [
'Request: "GET https://http2-push.io/"',
'Queueing pushed response: "https://http2-push.io/css/style.css"',
'Queueing pushed response: "https://http2-push.io/js/http2-push.js"',
'Response: "200 https://http2-push.io/"',
'Connecting request to pushed response: "GET https://http2-push.io/css/style.css"',
'Connecting request to pushed response: "GET https://http2-push.io/js/http2-push.js"',
'Response: "200 https://http2-push.io/css/style.css"',
'Response: "200 https://http2-push.io/js/http2-push.js"',
'Request: "GET https://http2.akamai.com/"',
'Queueing pushed response: "https://http2.akamai.com/resources/push.css"',
'Response: "200 https://http2.akamai.com/"',
'Accepting pushed response: "GET https://http2.akamai.com/resources/push.css"',
'Response: "200 https://http2.akamai.com/resources/push.css"',
];
$this->assertSame($expected, $logger->logs);
}
Expand Down

0 comments on commit f5b9d3a

Please sign in to comment.