mirror of
https://github.com/guzzle/guzzle.git
synced 2025-02-25 18:43:22 +01:00
Merge pull request #251 from thewilkybarkid/stale-if-error
Add stale-if-error directive support
This commit is contained in:
commit
8d68969a8b
@ -9,6 +9,7 @@ use Guzzle\Common\Version;
|
||||
use Guzzle\Http\Message\RequestInterface;
|
||||
use Guzzle\Http\Message\Response;
|
||||
use Guzzle\Cache\DoctrineCacheAdapter;
|
||||
use Guzzle\Http\Exception\CurlException;
|
||||
use Doctrine\Common\Cache\ArrayCache;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
|
||||
@ -18,14 +19,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
*
|
||||
* This is a simple implementation of RFC 2616 and should be considered a private transparent proxy cache, meaning
|
||||
* authorization and private data can be cached.
|
||||
*
|
||||
* It also implements RFC 5861's `stale-if-error` Cache-Control extension, allowing stale cache responses to be used
|
||||
* when an error is encountered (such as a `500 Internal Server Error` or DNS failure).
|
||||
*/
|
||||
class CachePlugin implements EventSubscriberInterface
|
||||
{
|
||||
/**
|
||||
* @var array SplObjectStorage of request cache keys to hold until a response is returned
|
||||
*/
|
||||
private $cached;
|
||||
|
||||
/**
|
||||
* @var CacheKeyProviderInterface Cache key provider
|
||||
*/
|
||||
@ -127,8 +126,6 @@ class CachePlugin implements EventSubscriberInterface
|
||||
} else {
|
||||
$this->debugHeaders = (bool) $options['debug_headers'];
|
||||
}
|
||||
|
||||
$this->cached = new \SplObjectStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -138,7 +135,9 @@ class CachePlugin implements EventSubscriberInterface
|
||||
{
|
||||
return array(
|
||||
'request.before_send' => array('onRequestBeforeSend', -255),
|
||||
'request.sent' => array('onRequestSent', 255)
|
||||
'request.sent' => array('onRequestSent', 255),
|
||||
'request.error' => array('onRequestError', 0),
|
||||
'request.exception' => array('onRequestException', 0),
|
||||
);
|
||||
}
|
||||
|
||||
@ -158,27 +157,16 @@ class CachePlugin implements EventSubscriberInterface
|
||||
}
|
||||
|
||||
$hashKey = $this->keyProvider->getCacheKey($request);
|
||||
$this->cached[$request] = $hashKey;
|
||||
|
||||
// If the cached data was found, then make the request into a
|
||||
// manually set request
|
||||
if ($cachedData = $this->storage->fetch($hashKey)) {
|
||||
$request->getParams()->set('cache.lookup', true);
|
||||
$response = new Response($cachedData[0], $cachedData[1], $cachedData[2]);
|
||||
$response->setHeader('Age', time() - strtotime($response->getDate() ? : 'now'));
|
||||
|
||||
// Validate that the response satisfies the request
|
||||
if ($this->canResponseSatisfyRequest($request, $response)) {
|
||||
unset($this->cached[$request]);
|
||||
$response->setHeader('Age', time() - strtotime($response->getDate() ? : 'now'));
|
||||
if ($response->isFresh() === false) {
|
||||
$response->addHeader(
|
||||
'Warning',
|
||||
sprintf('110 GuzzleCache/%s "Response is stale"', Version::VERSION)
|
||||
);
|
||||
}
|
||||
if (!$response->hasHeader('X-Guzzle-Cache')) {
|
||||
$response->setHeader('X-Guzzle-Cache', "key={$hashKey}");
|
||||
}
|
||||
$request->getParams()->set('cache.hit', true);
|
||||
$request->setResponse($response);
|
||||
}
|
||||
@ -195,27 +183,83 @@ class CachePlugin implements EventSubscriberInterface
|
||||
$request = $event['request'];
|
||||
$response = $event['response'];
|
||||
|
||||
if (isset($this->cached[$request])) {
|
||||
$cacheKey = $this->cached[$request];
|
||||
unset($this->cached[$request]);
|
||||
if ($this->canCache->canCacheResponse($response)) {
|
||||
$this->storage->cache($cacheKey, $response, $request->getParams()->get('cache.override_ttl'));
|
||||
}
|
||||
$cacheKey = $this->keyProvider->getCacheKey($request);
|
||||
|
||||
if ($request->getParams()->get('cache.hit') === null &&
|
||||
$this->canCache->canCacheRequest($request) &&
|
||||
$this->canCache->canCacheResponse($response)
|
||||
) {
|
||||
$this->storage->cache($cacheKey, $response, $request->getParams()->get('cache.override_ttl'));
|
||||
}
|
||||
|
||||
$response->addHeader('Via', sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION));
|
||||
$this->addResponseHeaders($cacheKey, $request, $response);
|
||||
}
|
||||
|
||||
if ($this->debugHeaders) {
|
||||
if ($request->getParams()->get('cache.lookup') === true) {
|
||||
$response->addHeader('X-Cache-Lookup', 'HIT from GuzzleCache');
|
||||
} else {
|
||||
$response->addHeader('X-Cache-Lookup', 'MISS from GuzzleCache');
|
||||
/**
|
||||
* If possible, return a cache response on an error
|
||||
*
|
||||
* @param Event $event
|
||||
*/
|
||||
public function onRequestError(Event $event)
|
||||
{
|
||||
$request = $event['request'];
|
||||
|
||||
if (!$this->canCache->canCacheRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = $this->keyProvider->getCacheKey($request);
|
||||
|
||||
if ($cachedData = $this->storage->fetch($cacheKey)) {
|
||||
$response = new Response($cachedData[0], $cachedData[1], $cachedData[2]);
|
||||
$response->setRequest($request);
|
||||
|
||||
$response->setHeader('Age', time() - strtotime($response->getDate() ? : 'now'));
|
||||
|
||||
if (!$this->canResponseSatisfyFailedRequest($request, $response)) {
|
||||
return;
|
||||
}
|
||||
if ($request->getParams()->get('cache.hit') === true) {
|
||||
$response->addHeader('X-Cache', 'HIT from GuzzleCache');
|
||||
} else {
|
||||
$response->addHeader('X-Cache', 'MISS from GuzzleCache');
|
||||
|
||||
$request->getParams()->set('cache.hit', 'error');
|
||||
$this->addResponseHeaders($cacheKey, $request, $response);
|
||||
|
||||
$event['response'] = $response;
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If possible, set a cache response on a cURL exception
|
||||
*
|
||||
* @param Event $event
|
||||
*/
|
||||
public function onRequestException(Event $event)
|
||||
{
|
||||
if (!$event['exception'] instanceof CurlException) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event['request'];
|
||||
|
||||
if (!$this->canCache->canCacheRequest($request)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = $this->keyProvider->getCacheKey($request);
|
||||
|
||||
if ($cachedData = $this->storage->fetch($cacheKey)) {
|
||||
$response = new Response($cachedData[0], $cachedData[1], $cachedData[2]);
|
||||
|
||||
$response->setHeader('Age', time() - strtotime($response->getDate() ? : 'now'));
|
||||
|
||||
if (!$this->canResponseSatisfyFailedRequest($request, $response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request->getParams()->set('cache.hit', 'error');
|
||||
|
||||
$request->setResponse($response);
|
||||
$event->stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
@ -280,4 +324,79 @@ class CachePlugin implements EventSubscriberInterface
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cache response satisfies a failed request's caching constraints
|
||||
*
|
||||
* @param RequestInterface $request Request to validate
|
||||
* @param Response $response Response to validate
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function canResponseSatisfyFailedRequest(RequestInterface $request, Response $response)
|
||||
{
|
||||
$requestStaleIfError = $request->getCacheControlDirective('stale-if-error');
|
||||
$responseStaleIfError = $response->getCacheControlDirective('stale-if-error');
|
||||
|
||||
if (!$requestStaleIfError && !$responseStaleIfError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($requestStaleIfError !== true &&
|
||||
$requestStaleIfError !== null &&
|
||||
$response->getAge() - $response->getMaxAge() > $requestStaleIfError
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($responseStaleIfError !== true &&
|
||||
$responseStaleIfError !== null &&
|
||||
$response->getAge() - $response->getMaxAge() > $responseStaleIfError
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the plugin's headers to a response
|
||||
*
|
||||
* @param string $cacheKey Cache key
|
||||
* @param RequestInterface $request Request
|
||||
* @param Response $response Response to add headers to
|
||||
*/
|
||||
protected function addResponseHeaders($cacheKey, RequestInterface $request, Response $response)
|
||||
{
|
||||
if (!$response->hasHeader('X-Guzzle-Cache')) {
|
||||
$response->setHeader('X-Guzzle-Cache', "key={$cacheKey}");
|
||||
}
|
||||
|
||||
$response->addHeader('Via', sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION));
|
||||
|
||||
if ($this->debugHeaders) {
|
||||
if ($request->getParams()->get('cache.lookup') === true) {
|
||||
$response->addHeader('X-Cache-Lookup', 'HIT from GuzzleCache');
|
||||
} else {
|
||||
$response->addHeader('X-Cache-Lookup', 'MISS from GuzzleCache');
|
||||
}
|
||||
if ($request->getParams()->get('cache.hit') === true) {
|
||||
$response->addHeader('X-Cache', 'HIT from GuzzleCache');
|
||||
} elseif ($request->getParams()->get('cache.hit') === 'error') {
|
||||
$response->addHeader('X-Cache', 'HIT_ERROR from GuzzleCache');
|
||||
} else {
|
||||
$response->addHeader('X-Cache', 'MISS from GuzzleCache');
|
||||
}
|
||||
}
|
||||
|
||||
if ($response->isFresh() === false) {
|
||||
$response->addHeader('Warning', sprintf('110 GuzzleCache/%s "Response is stale"', Version::VERSION));
|
||||
if ($request->getParams()->get('cache.hit') === 'error') {
|
||||
$response->addHeader(
|
||||
'Warning',
|
||||
sprintf('111 GuzzleCache/%s "Revalidation failed"', Version::VERSION)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -148,6 +148,40 @@ class CachePluginTest extends \Guzzle\Tests\GuzzleTestCase
|
||||
$this->assertEquals($didRevalidate, $revalidates);
|
||||
}
|
||||
|
||||
public function satisfyFailedProvider()
|
||||
{
|
||||
return array(
|
||||
// Neither has stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array()), new Response(200, array('Age' => 100)), false),
|
||||
// Request has stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array('Cache-Control' => 'stale-if-error')), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50')), true),
|
||||
// Request has valid stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array('Cache-Control' => 'stale-if-error=50')), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50')), true),
|
||||
// Request has expired stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array('Cache-Control' => 'stale-if-error=20')), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50')), false),
|
||||
// Response has permanent stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array()), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50, stale-if-error', )), true),
|
||||
// Response has valid stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array()), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50, stale-if-error=50')), true),
|
||||
// Response has expired stale-if-error
|
||||
array(new Request('GET', 'http://foo.com', array()), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50, stale-if-error=20')), false),
|
||||
// Request has valid stale-if-error but response does not
|
||||
array(new Request('GET', 'http://foo.com', array('Cache-Control' => 'stale-if-error=50')), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50, stale-if-error=20')), false),
|
||||
// Response has valid stale-if-error but request does not
|
||||
array(new Request('GET', 'http://foo.com', array('Cache-Control' => 'stale-if-error=20')), new Response(200, array('Age' => 100, 'Cache-Control' => 'max-age=50, stale-if-error=50')), false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider satisfyFailedProvider
|
||||
*/
|
||||
public function testChecksIfResponseCanSatisfyFailedRequest($request, $response, $can)
|
||||
{
|
||||
$plugin = new CachePlugin();
|
||||
|
||||
$this->assertEquals($can, $plugin->canResponseSatisfyFailedRequest($request, $response));
|
||||
}
|
||||
|
||||
public function testDoesNothingWhenRequestIsNotCacheable()
|
||||
{
|
||||
$storage = $this->getMockBuilder('Guzzle\Plugin\Cache\CacheStorageInterface')
|
||||
@ -236,6 +270,193 @@ class CachePluginTest extends \Guzzle\Tests\GuzzleTestCase
|
||||
}
|
||||
}
|
||||
|
||||
public function satisfiableOnErrorProvider()
|
||||
{
|
||||
$date = new \DateTime('-10 seconds');
|
||||
|
||||
return array(
|
||||
// Adding debug headers
|
||||
array(
|
||||
true,
|
||||
array(200, array('Date' => $date->format('D, d M Y H:i:s T'), 'Cache-Control' => 'max-age=5, stale-if-error'), 'foo'),
|
||||
),
|
||||
// Not adding debug headers
|
||||
array(
|
||||
false,
|
||||
array(200, array('Date' => $date->format('D, d M Y H:i:s T'), 'Cache-Control' => 'max-age=5, stale-if-error'), 'foo'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider satisfiableOnErrorProvider
|
||||
*/
|
||||
public function testInjectsSatisfiableResponsesOnError($debugHeaders, $responseParts)
|
||||
{
|
||||
$storage = $this->getMockBuilder('Guzzle\Plugin\Cache\CacheStorageInterface')
|
||||
->setMethods(array('fetch'))
|
||||
->getMockForAbstractClass();
|
||||
$storage->expects($this->exactly(2))->method('fetch')->will($this->returnValue($responseParts));
|
||||
$plugin = new CachePlugin(array('storage' => $storage, 'debug_headers' => $debugHeaders));
|
||||
$request = new Request('GET', 'http://foo.com', array('Cache-Control' => 'max-stale'));
|
||||
$plugin->onRequestBeforeSend(new Event(array(
|
||||
'request' => $request
|
||||
)));
|
||||
$plugin->onRequestError(
|
||||
$event = new Event(array(
|
||||
'request' => $request,
|
||||
'response' => $request->getResponse(),
|
||||
))
|
||||
);
|
||||
$response = $event['response'];
|
||||
$this->assertEquals($responseParts[0], $response->getStatusCode());
|
||||
$this->assertEquals($responseParts[2], $response->getBody(true));
|
||||
$this->assertContains('key=', (string) $response->getHeader('X-Guzzle-Cache'));
|
||||
$this->assertTrue($response->hasHeader('Age'));
|
||||
if ($response->isFresh() === false) {
|
||||
$this->assertContains('110', $response->getHeader('Warning', true));
|
||||
}
|
||||
$this->assertSame(sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION), $request->getHeader('Via', true));
|
||||
$this->assertSame(sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION), $response->getHeader('Via', true));
|
||||
$this->assertTrue($request->getParams()->get('cache.lookup'));
|
||||
$this->assertSame('error', $request->getParams()->get('cache.hit'));
|
||||
|
||||
if (!$debugHeaders) {
|
||||
$this->assertFalse($response->hasHeader('X-Cache-Lookup'));
|
||||
$this->assertFalse($response->hasHeader('X-Cache'));
|
||||
} else {
|
||||
$this->assertTrue($response->hasHeader('X-Cache-Lookup'));
|
||||
$this->assertTrue($response->hasHeader('X-Cache'));
|
||||
$this->assertEquals('HIT from GuzzleCache', $response->getHeader('X-Cache-Lookup', true));
|
||||
$this->assertEquals('HIT_ERROR from GuzzleCache', $response->getHeader('X-Cache', true));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider satisfiableOnErrorProvider
|
||||
*/
|
||||
public function testInjectsSatisfiableResponsesOnException($debugHeaders, $responseParts)
|
||||
{
|
||||
$storage = $this->getMockBuilder('Guzzle\Plugin\Cache\CacheStorageInterface')
|
||||
->setMethods(array('fetch'))
|
||||
->getMockForAbstractClass();
|
||||
$storage->expects($this->exactly(2))->method('fetch')->will($this->returnValue($responseParts));
|
||||
$plugin = new CachePlugin(array('storage' => $storage, 'debug_headers' => $debugHeaders));
|
||||
$request = new Request('GET', 'http://foo.com', array('Cache-Control' => 'max-stale'));
|
||||
$plugin->onRequestBeforeSend(new Event(array(
|
||||
'request' => $request
|
||||
)));
|
||||
$plugin->onRequestException(
|
||||
new Event(array(
|
||||
'request' => $request,
|
||||
'response' => $request->getResponse(),
|
||||
'exception' => $this->getMock('Guzzle\Http\Exception\CurlException'),
|
||||
))
|
||||
);
|
||||
$plugin->onRequestSent(
|
||||
new Event(array(
|
||||
'request' => $request,
|
||||
'response' => $response = $request->getResponse(),
|
||||
))
|
||||
);
|
||||
$this->assertEquals($responseParts[0], $response->getStatusCode());
|
||||
$this->assertEquals($responseParts[2], $response->getBody(true));
|
||||
$this->assertContains('key=', (string) $response->getHeader('X-Guzzle-Cache'));
|
||||
$this->assertTrue($response->hasHeader('Age'));
|
||||
if ($response->isFresh() === false) {
|
||||
$this->assertContains('110', $response->getHeader('Warning', true));
|
||||
}
|
||||
$this->assertSame(sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION), $request->getHeader('Via', true));
|
||||
$this->assertSame(sprintf('%s GuzzleCache/%s', $request->getProtocolVersion(), Version::VERSION), $response->getHeader('Via', true));
|
||||
$this->assertTrue($request->getParams()->get('cache.lookup'));
|
||||
$this->assertSame('error', $request->getParams()->get('cache.hit'));
|
||||
|
||||
if (!$debugHeaders) {
|
||||
$this->assertFalse($response->hasHeader('X-Cache-Lookup'));
|
||||
$this->assertFalse($response->hasHeader('X-Cache'));
|
||||
} else {
|
||||
$this->assertTrue($response->hasHeader('X-Cache-Lookup'));
|
||||
$this->assertTrue($response->hasHeader('X-Cache'));
|
||||
$this->assertEquals('HIT from GuzzleCache', $response->getHeader('X-Cache-Lookup', true));
|
||||
$this->assertEquals('HIT_ERROR from GuzzleCache', $response->getHeader('X-Cache', true));
|
||||
}
|
||||
}
|
||||
|
||||
public function unsatisfiableOnErrorProvider()
|
||||
{
|
||||
$date = new \DateTime('-10 seconds');
|
||||
|
||||
return array(
|
||||
// no-store on request
|
||||
array(
|
||||
false,
|
||||
array('Cache-Control' => 'no-store'),
|
||||
array(200, array('Date' => $date->format('D, d M Y H:i:s T'), 'Cache-Control' => 'max-age=5, stale-if-error'), 'foo'),
|
||||
),
|
||||
// request expired
|
||||
array(
|
||||
true,
|
||||
array('Cache-Control' => 'stale-if-error=4'),
|
||||
array(200, array('Date' => $date->format('D, d M Y H:i:s T'), 'Cache-Control' => 'max-age=5, stale-if-error'), 'foo'),
|
||||
),
|
||||
// response expired
|
||||
array(
|
||||
true,
|
||||
array('Cache-Control' => 'stale-if-error'),
|
||||
array(200, array('Date' => $date->format('D, d M Y H:i:s T'), 'Cache-Control' => 'max-age=5, stale-if-error=4'), 'foo'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider unsatisfiableOnErrorProvider
|
||||
*/
|
||||
public function testDoesNotInjectUnsatisfiableResponsesOnError($requestCanCache, $requestHeaders, $responseParts)
|
||||
{
|
||||
$storage = $this->getMockBuilder('Guzzle\Plugin\Cache\CacheStorageInterface')
|
||||
->setMethods(array('fetch'))
|
||||
->getMockForAbstractClass();
|
||||
$storage->expects($this->exactly($requestCanCache ? 2 : 0))->method('fetch')->will($this->returnValue($responseParts));
|
||||
$plugin = new CachePlugin(array('storage' => $storage));
|
||||
$request = new Request('GET', 'http://foo.com', $requestHeaders);
|
||||
$plugin->onRequestBeforeSend(new Event(array(
|
||||
'request' => $request
|
||||
)));
|
||||
$plugin->onRequestError(
|
||||
$event = new Event(array(
|
||||
'request' => $request,
|
||||
'response' => $response = $request->getResponse(),
|
||||
))
|
||||
);
|
||||
|
||||
$this->assertSame($response, $event['response']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider unsatisfiableOnErrorProvider
|
||||
*/
|
||||
public function testDoesNotInjectUnsatisfiableResponsesOnException($requestCanCache, $requestHeaders, $responseParts)
|
||||
{
|
||||
$storage = $this->getMockBuilder('Guzzle\Plugin\Cache\CacheStorageInterface')
|
||||
->setMethods(array('fetch'))
|
||||
->getMockForAbstractClass();
|
||||
$storage->expects($this->exactly($requestCanCache ? 2 : 0))->method('fetch')->will($this->returnValue($responseParts));
|
||||
$plugin = new CachePlugin(array('storage' => $storage));
|
||||
$request = new Request('GET', 'http://foo.com', $requestHeaders);
|
||||
$plugin->onRequestBeforeSend(new Event(array(
|
||||
'request' => $request
|
||||
)));
|
||||
$plugin->onRequestException(
|
||||
$event = new Event(array(
|
||||
'request' => $request,
|
||||
'response' => $response = $request->getResponse(),
|
||||
'exception' => $this->getMock('Guzzle\Http\Exception\CurlException'),
|
||||
))
|
||||
);
|
||||
|
||||
$this->assertSame($response, $request->getResponse());
|
||||
}
|
||||
|
||||
public function testCachesResponsesWhenCacheable()
|
||||
{
|
||||
$cache = new ArrayCache();
|
||||
|
Loading…
x
Reference in New Issue
Block a user