1
0
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:
Michael Dowling 2013-02-28 22:25:58 -08:00
commit 8d68969a8b
2 changed files with 376 additions and 36 deletions

View File

@ -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)
);
}
}
}
}

View File

@ -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();