Skip to content

Commit 972b421

Browse files
Merge branch '3.4' into 4.3
* 3.4: [Validator] fix access to uninitialized property when getting value [HttpKernel] Fix stale-if-error behavior, add tests Improved error message when no supported user provider is found
2 parents 896f0f6 + 4357749 commit 972b421

File tree

2 files changed

+189
-3
lines changed

2 files changed

+189
-3
lines changed

HttpCache/HttpCache.php

+27-3
Original file line numberDiff line numberDiff line change
@@ -482,13 +482,37 @@ protected function forward(Request $request, $catch = false, Response $entry = n
482482
// always a "master" request (as the real master request can be in cache)
483483
$response = SubRequestHandler::handle($this->kernel, $request, HttpKernelInterface::MASTER_REQUEST, $catch);
484484

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

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

494518
return $entry;

Tests/HttpCache/HttpCacheTest.php

+162
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,168 @@ public function testUsesOriginalRequestForSurrogate()
15241524
$cache->handle($request, HttpKernelInterface::SUB_REQUEST);
15251525
}
15261526

1527+
public function testStaleIfErrorMustNotResetLifetime()
1528+
{
1529+
// Make sure we don't accidentally treat the response as fresh (revalidated) again
1530+
// when stale-if-error handling kicks in.
1531+
1532+
$responses = [
1533+
[
1534+
'status' => 200,
1535+
'body' => 'OK',
1536+
// This is cacheable and can be used in stale-if-error cases:
1537+
'headers' => ['Cache-Control' => 'public, max-age=10', 'ETag' => 'some-etag'],
1538+
],
1539+
[
1540+
'status' => 500,
1541+
'body' => 'FAIL',
1542+
'headers' => [],
1543+
],
1544+
[
1545+
'status' => 500,
1546+
'body' => 'FAIL',
1547+
'headers' => [],
1548+
],
1549+
];
1550+
1551+
$this->setNextResponses($responses);
1552+
$this->cacheConfig['stale_if_error'] = 10;
1553+
1554+
$this->request('GET', '/'); // warm cache
1555+
1556+
sleep(15); // now the entry is stale, but still within the grace period (10s max-age + 10s stale-if-error)
1557+
1558+
$this->request('GET', '/'); // hit backend error
1559+
$this->assertEquals(200, $this->response->getStatusCode()); // stale-if-error saved the day
1560+
$this->assertEquals(15, $this->response->getAge());
1561+
1562+
sleep(10); // now we're outside the grace period
1563+
1564+
$this->request('GET', '/'); // hit backend error
1565+
$this->assertEquals(500, $this->response->getStatusCode()); // fail
1566+
}
1567+
1568+
/**
1569+
* @dataProvider getResponseDataThatMayBeServedStaleIfError
1570+
*/
1571+
public function testResponsesThatMayBeUsedStaleIfError($responseHeaders, $sleepBetweenRequests = null)
1572+
{
1573+
$responses = [
1574+
[
1575+
'status' => 200,
1576+
'body' => 'OK',
1577+
'headers' => $responseHeaders,
1578+
],
1579+
[
1580+
'status' => 500,
1581+
'body' => 'FAIL',
1582+
'headers' => [],
1583+
],
1584+
];
1585+
1586+
$this->setNextResponses($responses);
1587+
$this->cacheConfig['stale_if_error'] = 10; // after stale, may be served for 10s
1588+
1589+
$this->request('GET', '/'); // warm cache
1590+
1591+
if ($sleepBetweenRequests) {
1592+
sleep($sleepBetweenRequests);
1593+
}
1594+
1595+
$this->request('GET', '/'); // hit backend error
1596+
1597+
$this->assertEquals(200, $this->response->getStatusCode());
1598+
$this->assertEquals('OK', $this->response->getContent());
1599+
$this->assertTraceContains('stale-if-error');
1600+
}
1601+
1602+
public function getResponseDataThatMayBeServedStaleIfError()
1603+
{
1604+
// All data sets assume that a 10s stale-if-error grace period has been configured
1605+
yield 'public, max-age expired' => [['Cache-Control' => 'public, max-age=60'], 65];
1606+
yield 'public, validateable with ETag, no TTL' => [['Cache-Control' => 'public', 'ETag' => 'some-etag'], 5];
1607+
yield 'public, validateable with Last-Modified, no TTL' => [['Cache-Control' => 'public', 'Last-Modified' => 'yesterday'], 5];
1608+
yield 'public, s-maxage will be served stale-if-error, even if the RFC mandates otherwise' => [['Cache-Control' => 'public, s-maxage=20'], 25];
1609+
}
1610+
1611+
/**
1612+
* @dataProvider getResponseDataThatMustNotBeServedStaleIfError
1613+
*/
1614+
public function testResponsesThatMustNotBeUsedStaleIfError($responseHeaders, $sleepBetweenRequests = null)
1615+
{
1616+
$responses = [
1617+
[
1618+
'status' => 200,
1619+
'body' => 'OK',
1620+
'headers' => $responseHeaders,
1621+
],
1622+
[
1623+
'status' => 500,
1624+
'body' => 'FAIL',
1625+
'headers' => [],
1626+
],
1627+
];
1628+
1629+
$this->setNextResponses($responses);
1630+
$this->cacheConfig['stale_if_error'] = 10; // after stale, may be served for 10s
1631+
$this->cacheConfig['strict_smaxage'] = true; // full RFC compliance for this feature
1632+
1633+
$this->request('GET', '/'); // warm cache
1634+
1635+
if ($sleepBetweenRequests) {
1636+
sleep($sleepBetweenRequests);
1637+
}
1638+
1639+
$this->request('GET', '/'); // hit backend error
1640+
1641+
$this->assertEquals(500, $this->response->getStatusCode());
1642+
}
1643+
1644+
public function getResponseDataThatMustNotBeServedStaleIfError()
1645+
{
1646+
// All data sets assume that a 10s stale-if-error grace period has been configured
1647+
yield 'public, no TTL but beyond grace period' => [['Cache-Control' => 'public'], 15];
1648+
yield 'public, validateable with ETag, no TTL but beyond grace period' => [['Cache-Control' => 'public', 'ETag' => 'some-etag'], 15];
1649+
yield 'public, validateable with Last-Modified, no TTL but beyond grace period' => [['Cache-Control' => 'public', 'Last-Modified' => 'yesterday'], 15];
1650+
yield 'public, stale beyond grace period' => [['Cache-Control' => 'public, max-age=10'], 30];
1651+
1652+
// Cache-control values that prohibit serving stale responses or responses without positive validation -
1653+
// see https://tools.ietf.org/html/rfc7234#section-4.2.4 and
1654+
// https://tools.ietf.org/html/rfc7234#section-5.2.2
1655+
yield 'no-cache requires positive validation' => [['Cache-Control' => 'public, no-cache', 'ETag' => 'some-etag']];
1656+
yield 'no-cache requires positive validation, even if fresh' => [['Cache-Control' => 'public, no-cache, max-age=10']];
1657+
yield 'must-revalidate requires positive validation once stale' => [['Cache-Control' => 'public, max-age=10, must-revalidate'], 15];
1658+
yield 'proxy-revalidate requires positive validation once stale' => [['Cache-Control' => 'public, max-age=10, proxy-revalidate'], 15];
1659+
}
1660+
1661+
public function testStaleIfErrorWhenStrictSmaxageDisabled()
1662+
{
1663+
$responses = [
1664+
[
1665+
'status' => 200,
1666+
'body' => 'OK',
1667+
'headers' => ['Cache-Control' => 'public, s-maxage=20'],
1668+
],
1669+
[
1670+
'status' => 500,
1671+
'body' => 'FAIL',
1672+
'headers' => [],
1673+
],
1674+
];
1675+
1676+
$this->setNextResponses($responses);
1677+
$this->cacheConfig['stale_if_error'] = 10;
1678+
$this->cacheConfig['strict_smaxage'] = false;
1679+
1680+
$this->request('GET', '/'); // warm cache
1681+
sleep(25);
1682+
$this->request('GET', '/'); // hit backend error
1683+
1684+
$this->assertEquals(200, $this->response->getStatusCode());
1685+
$this->assertEquals('OK', $this->response->getContent());
1686+
$this->assertTraceContains('stale-if-error');
1687+
}
1688+
15271689
public function testTraceHeaderNameCanBeChanged()
15281690
{
15291691
$this->cacheConfig['trace_header'] = 'X-My-Header';

0 commit comments

Comments
 (0)