8000 [HttpKernel] Fix stale-if-error behavior, add tests by mpdude · Pull Request #35305 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[HttpKernel] Fix stale-if-error behavior, add tests #35305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Symfony/Component/HttpFoundation/Response.php
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ public function isImmutable()
}

/**
* Returns true if the response must be revalidated by caches.
* Returns true if the response must be revalidated by shared caches once it has become stale.
*
* This method indicates that the response must not be served stale by a
* cache in any circumstance without first revalidating with the origin.
Expand Down
30 changes: 27 additions & 3 deletions src/Symfony/Component/HttpKernel/HttpCache/HttpCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -452,13 +452,37 @@ protected function forward(Request $request, $catch = false, Response $entry = n
// always a "master" request (as the real master request can be in cache)
$response = SubRequestHandler::handle($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST, $catch);

// we don't implement the stale-if-error on Requests, which is nonetheless part of the RFC
if (null !== $entry && \in_array($response->getStatusCode(), [500, 502, 503, 504])) {
/*
* Support stale-if-error given on Responses or as a config option.
* RFC 7234 summarizes in Section 4.2.4 (but also mentions with the individual
* Cache-Control directives) that
*
* A cache MUST NOT generate a stale response if it is prohibited by an
* explicit in-protocol directive (e.g., by a "no-store" or "no-cache"
* cache directive, a "must-revalidate" cache-response-directive, or an
* applicable "s-maxage" or "proxy-revalidate" cache-response-directive;
* see Section 5.2.2).
*
* https://tools.ietf.org/html/rfc7234#section-4.2.4
*
* We deviate from this in one detail, namely that we *do* serve entries in the
* stale-if-error case even if they have a `s-maxage` Cache-Control directive.
*/
if (null !== $entry
&& \in_array($response->getStatusCode(), [500, 502, 503, 504])
&& !$entry->headers->hasCacheControlDirective('no-cache')
&& !$entry->mustRevalidate()
) {
if (null === $age = $entry->headers->getCacheControlDirective('stale-if-error')) {
$age = $this->options['stale_if_error'];
}

if (abs($entry->getTtl()) < $age) {
/*
* stale-if-error gives the (extra) time that the Response may be used *after* it has become stale.
* So we compare the time the $entry has been sitting in the cache already with the
* time it was fresh plus the allowed grace period.
*/
if ($entry->getAge() <= $entry->getMaxAge() + $age) {
$this->record($request, 'stale-if-error');

return $entry;
Expand Down
162 changes: 162 additions & 0 deletions src/Symfony/Component/HttpKernel/Tests/HttpCache/HttpCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,168 @@ public function testUsesOriginalRequestForSurrogate()
// Surrogate request
$cache->handle($request, HttpKernelInterface::SUB_REQUEST);
}

public function testStaleIfErrorMustNotResetLifetime()
{
// Make sure we don't accidentally treat the response as fresh (revalidated) again
// when stale-if-error handling kicks in.

$responses = [
[
'status' => 200,
'body' => 'OK',
// This is cacheable and can be used in stale-if-error cases:
'headers' => ['Cache-Control' => 'public, max-age=10', 'ETag' => 'some-etag'],
],
[
'status' => 500,
'body' => 'FAIL',
'headers' => [],
],
[
'status' => 500,
'body' => 'FAIL',
'headers' => [],
],
];

$this->setNextResponses($responses);
$this->cacheConfig['stale_if_error'] = 10;

$this->request('GET', '/'); // warm cache

sleep(15); // now the entry is stale, but still within the grace period (10s max-age + 10s stale-if-error)

$this->request('GET', '/'); // hit backend error
$this->assertEquals(200, $this->response->getStatusCode()); // stale-if-error saved the day
$this->assertEquals(15, $this->response->getAge());

sleep(10); // now we're outside the grace period

$this->request('GET', '/'); // hit backend error
$this->assertEquals(500, $this->response->getStatusCode()); // fail
}

/**
* @dataProvider getResponseDataThatMayBeServedStaleIfError
*/
public function testResponsesThatMayBeUsedStaleIfError($responseHeaders, $sleepBetweenRequests = null)
{
$responses = [
[
'status' => 200,
'body' => 'OK',
'headers' => $responseHeaders,
],
[
'status' => 500,
'body' => 'FAIL',
'headers' => [],
],
];

$this->setNextResponses($responses);
$this->cacheConfig['stale_if_error'] = 10; // after stale, may be served for 10s

$this->request('GET', '/'); // warm cache

if ($sleepBetweenRequests) {
sleep($sleepBetweenRequests);
}

$this->request('GET', '/'); // hit backend error

$this->assertEquals(200, $this->response->getStatusCode());
$this->assertEquals('OK', $this->response->getContent());
$this->assertTraceContains('stale-if-error');
}

public function getResponseDataThatMayBeServedStaleIfError()
{
// All data sets assume that a 10s stale-if-error grace period has been configured
yield 'public, max-age expired' => [['Cache-Control' => 'public, max-age=60'], 65];
yield 'public, validateable with ETag, no TTL' => [['Cache-Control' => 'public', 'ETag' => 'some-etag'], 5];
yield 'public, validateable with Last-Modified, no TTL' => [['Cache-Control' => 'public', 'Last-Modified' => 'yesterday'], 5];
yield 'public, s-maxage will be served stale-if-error, even if the RFC mandates otherwise' => [['Cache-Control' => 'public, s-maxage=20'], 25];
}

/**
* @dataProvider getResponseDataThatMustNotBeServedStaleIfError
*/
public function testResponsesThatMustNotBeUsedStaleIfError($responseHeaders, $sleepBetweenRequests = null)
{
$responses = [
[
'status' => 200,
'body' => 'OK',
'headers' => $responseHeaders,
],
[
'status' => 500,
'body' => 'FAIL',
'headers' => [],
],
];

$this->setNextResponses($responses);
$this->cacheConfig['stale_if_error'] = 10; // after stale, may be served for 10s
$this->cacheConfig['strict_smaxage'] = true; // full RFC compliance for this feature

$this->request('GET', '/'); // warm cache

if ($sleepBetweenRequests) {
sleep($sleepBetweenRequests);
}

$this->request('GET', '/'); // hit backend error

$this->assertEquals(500, $this->response->getStatusCode());
}

public function getResponseDataThatMustNotBeServedStaleIfError()
{
// All data sets assume that a 10s stale-if-error grace period has been configured
yield 'public, no TTL but beyond grace period' => [['Cache-Control' => 'public'], 15];
yield 'public, validateable with ETag, no TTL but beyond grace period' => [['Cache-Control' => 'public', 'ETag' => 'some-etag'], 15];
yield 'public, validateable with Last-Modified, no TTL but beyond grace period' => [['Cache-Control' => 'public', 'Last-Modified' => 'yesterday'], 15];
yield 'public, stale beyond grace period' => [['Cache-Control' => 'public, max-age=10'], 30];

// Cache-control values that prohibit serving stale responses or responses without positive validation -
// see https://tools.ietf.org/html/rfc7234#section-4.2.4 and
// https://tools.ietf.org/html/rfc7234#section-5.2.2
yield 'no-cache requires positive validation' => [['Cache-Control' => 'public, no-cache', 'ETag' => 'some-etag']];
yield 'no-cache requires positive validation, even if fresh' => [['Cache-Control' => 'public, no-cache, max-age=10']];
yield 'must-revalidate requires positive validation once stale' => [['Cache-Control' => 'public, max-age=10, must-revalidate'], 15];
yield 'proxy-revalidate requires positive validation once stale' => [['Cache-Control' => 'public, max-age=10, proxy-revalidate'], 15];
8000 }

public function testStaleIfErrorWhenStrictSmaxageDisabled()
{
$responses = [
[
'status' => 200,
'body' => 'OK',
'headers' => ['Cache-Control' => 'public, s-maxage=20'],
],
[
'status' => 500,
'body' => 'FAIL',
'headers' => [],
],
];

$this->setNextResponses($responses);
$this->cacheConfig['stale_if_error'] = 10;
$this->cacheConfig['strict_smaxage'] = false;

$this->request('GET', '/'); // warm cache
sleep(25);
$this->request('GET', '/'); // hit backend error

$this->assertEquals(200, $this->response->getStatusCode());
$this->assertEquals('OK', $this->response->getContent());
$this->assertTraceContains('stale-if-error');
}
}

class TestKernel implements HttpKernelInterface
Expand Down
0