Problem/Motivation
This issue is also being discussed in the Redirect issue queue #3016776: 301 redirects don't contain cache-control header.
TrustedRedirectResponse implements CacheableResponseInterface (by extending from SecuredRedirectResponse). When caching is enabled, and the redirect response status code is anything other than 301, a Cache-Control header is output. However, when the status code is 301 (permanent redirect), no Cache-Control header is emitted.
The lack of a cache-control header on 301 redirect responses means that intermediate CDNs and reverse proxies either don't cache the response at all (for example Akamai respects Cache-Control), or fall back to caching it for a short period (for example Acquia Varnish). Some browsers cache 301 redirects if Cache-Control is missing.
In order for a response to be cached in FinishResponseSubscriber, \Drupal\Core\EventSubscriber\FinishResponseSubscriber::isCacheControlCustomized() has to return FALSE.
/**
* Determine whether the given response has a custom Cache-Control header.
*
* Upon construction, the ResponseHeaderBag is initialized with an empty
* Cache-Control header. Consequently it is not possible to check whether the
* header was set explicitly by simply checking its presence. Instead, it is
* necessary to examine the computed Cache-Control header and compare with
* values known to be present only when Cache-Control was never set
* explicitly.
*
* When neither Cache-Control nor any of the ETag, Last-Modified, Expires
* headers are set on the response, ::get('Cache-Control') returns the value
* 'no-cache, private'. If any of ETag, Last-Modified or Expires are set but
* not Cache-Control, then 'private, must-revalidate' (in exactly this order)
* is returned.
*
* @see \Symfony\Component\HttpFoundation\ResponseHeaderBag::computeCacheControlValue()
*
* @param \Symfony\Component\HttpFoundation\Response $response
*
* @return bool
* TRUE when Cache-Control header was set explicitly on the given response.
*/
protected function isCacheControlCustomized(Response $response) {
$cache_control = $response->headers->get('Cache-Control');
return $cache_control != 'no-cache, private' && $cache_control != 'private, must-revalidate';
}
Drupal's current behavior was introduced with a change to Symfony\Component\HttpFoundation\RedirectResponse in Symfony 3.2 (Symfony issue). The constructor for RedirectResponse explicitly unsets the Cache-Control header, causing isCacheControlCustomized() to return TRUE, which renders the response not cacheable.
Proposed resolution
Emitting the Cache-Control header for 301 responses would make them cacheable downstream. It also does not make sense to have the temporary redirects (302) be cacheable, while the permanent redirects (301) are not (or at least not explicit).
Initial attempt attached to output Cache-Control on 301 responses by checking if the cache-control header was unset by Symfony.
Remaining tasks
Write tests.
User interface changes
None.
API changes
None.
Data model changes
None.
| Comment | File | Size | Author |
|---|
Issue fork drupal-3054821
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
- 3054821-include-cache-control-header
changes, plain diff MR !5963
Comments
Comment #2
wim leersThanks for all the research you did here and over at #3016776-24: 301 redirects don't contain cache-control header, @mr.baileys!
This indeed still needs test coverage. AFAICT
\Drupal\Tests\page_cache\Functional\PageCacheTestand\Drupal\Tests\system\Functional\Routing\RouterTest::testFinishResponseSubscriber()are the appropriate places to expand test coverage.Comment #3
mr.baileysTest added to
\Drupal\Tests\system\Functional\Routing\RouterTest::testFinishResponseSubscriber()that verifies the cache-control header on redirect responses.Comment #4
mr.baileysTest-only patch should fail since Drupal currently is not sending the cache-control header for 301 responses, so the test obviously needs work.
Comment #5
mr.baileysComment #6
mr.baileysWork in progress. The test is still failing, although I can see that the cache-control header *is* initially added in FinishResponseSubscriber. Somehow, between adding it in FRS and receiving the response in
PageCacheTest::testCacheableRedirectResponses, the header is removed someplace...Comment #8
mr.baileysFinally figured out why manual testing (using the Redirect module) was working, while the automated test failed. The manual test was returning a
TrustedRedirectResponse, while my test was running with theCacheableResponseRequest.Turns out that RedirectResponseSubscriber::checkRedirectUrl() transforms all RedirectResponses that are not an instance of
SecuredRedirectResponsetoLocalRedirectResponseusingLocalRedirectResponse::createFromRedirectResponse(). The latter will indirectly invoke\Symfony\Component\HttpFoundation\RedirectResponse::__construct(), passing url, status code and headers. This constructor will always clear the cache-control header on the newly created response object for 301 responses, unless a "cache-control" header is present in $headers. Unfortunately, if RedirectResponseSubscriber runs after FinishResponseSubscriber (they have the same weight, but in all my testing RRS was fired after FRS), the header is passed in as "Cache-Control", and Symfony uses the case-senstive array_key_exists to check for "cache-control", this wrongly concluding that no cache-control header is present...One option is to run RedirectResponseSubscriber right before FinishResponseSubscriber. Patch attached that takes this approach.
Comment #10
wim leersWoah, really!? If true, that'd be a bug in Symfony. Symfony stores everything in lowercase (just look at
\Symfony\Component\HttpFoundation\HeaderBag::get()and\Symfony\Component\HttpFoundation\HeaderBag::set()), so I'm surprised to hear that. But you're right, I see the case-sensitive check in\Symfony\Component\HttpFoundation\RedirectResponse::__construct(). We should file an upstream bug for that.That being said, rather than changing the event subscriber priority, I think an alternative solution is to change
\Drupal\Component\HttpFoundation\SecuredRedirectResponse::createFromRedirectResponse(), which does:Note the
->allPreserveCase()! Changing it to->all()presumably fixes this edge case too.Nit: I find this naming a bit confusing: it makes it sound like a redirect response is either cacheable or local.
But
class LocalRedirectResponse extends CacheableSecuredRedirectResponse: it is cacheable too!Still, it's worth testing both. But if we're testing both, shouldn't we also test
TrustedRedirectResponse?Comment #11
mr.baileysThanks for the feedback @Wim Leers!
I'll look into this and open an issue there.
I considered this, but this would mean we lose capitalization on *all* headers once the RedirectResponse gets converted to the SecuredRedirectResponse? Since "FinishResponseSubscriber" implies that it runs at the end of the flow, I figured that having RedirectResponseSubscriber run earlier was a cleaner solution. Happy to switch from ->allPreserveCase() to ->all() if losing capitalisation is acceptable.
Comment #12
wim leersLet's see if it causes test failures. The capitalization of headers isn't meaningful anyway (hence Symfony lowercasing everything!).
Not changing event subscriber priority will make this much easier to land.
Comment #13
mr.baileysOk, let's try. Patch attached reverts to the current subscriber priorities. Still need to address some of the comments from #10, but want to run this approach through the testbot first.
Comment #15
mr.baileysComment #16
mr.baileysAgree, but this is more an issue with how the classes are named right now, might be more confusing if we deviate from that? I did rename the functions in the controller to "respondWith[ClassName]", which is clearer and more in line with existing functions in that class.
Done!
Comment #18
mr.baileysUnrelated test failure, setting back to NR
Comment #19
wim leersComment #20
mr.baileysI have opened an issue upstream against Symfony 3.4 (https://github.com/symfony/symfony/issues/31862)
Comment #21
wiifmI have applied the patch at #16 to a Drupal 8 codebase I help to look after, and have got the latest stable redirect module enabled.
The result is perfect, I am seeing the correct cache-control header being sent, and Varnish and Cloudfare are able to now cache this for 30 days.
Thanks @mr.baileys
RTBC +1
Comment #22
alexpottI spent some time thinking about this change. Note the change here is whether or not the header keys are lowercased - the values remain untouched. I believe that the http spec doesn't care if it is
Cache-Controlorcache-control. As per the spec - https://tools.ietf.org/html/rfc7230#section-3.2 - header field names are case-insensitiveI've ummed and ahhed over this for quite a bit of time. I think this change makes it impossible for use to emit a response without a cache header. Which feels limiting but maybe in a good way. But it is definitely surprising because before if I do what Symfony's RedirectResponse does - ie. $this->headers->remove('cache-control'); - what I expect to happen happens. A response without cache headers is emitted. I think we might only want to change this behaviour if we're dealing with 301s.
Also regardless of whether we make that change the extensive docs of this method need changing to mention the NULL case.
I think this code could look like
So... I think if we limit the not set cache header behaviour to 301s then this might be a good change because out-of-box 301s cache like any other redirect. Which kinda makes sense.
Comment #23
berdir> Because 301 is a permanent redirect therefore it strictest terms varnish / cloudflare would be well within scope to cache these forever.
Hm, if you take redirect.module as an example, you can change those redirects again and on many sites, most redirects are created automatically and it's not uncommon to change it back to not being a redirect, e.g. /article/foobar => article/foobar-update => /article/foobar. Redirect adds cache tags to invalidate them internally, that works fine, but caching them externally is actually somewhat tricky.
Comment #24
alexpotthttps://stackoverflow.com/questions/9130422/how-long-do-browsers-cache-h... so chrome at least will cache the current 301s emitted by core permanently. It feels to me that we should be using 301s very sparingly. Because hint is in the name permanent redirect. In the example in #23 these should not be 301s.
Comment #25
mr.baileys#22.1
Correct. The only reason this change is required is the fact that Symfony incorrectly does a case sensitive comparison to check for the presence of the Cache-Control header in
\Symfony\Component\HttpFoundation\RedirectResponse::__construct. This has been fixed in Symfony 3.4, but for now we need to work around that bug. This drawback is that capitalization on Drupal 301 responses is not consistent with non-301 responses.There are two alternatives:
#22.2
Adjusted so that the changes to
isCacheControlCustomized()are limited to 301 responses.#22.3
IMO, the fact that currently the time-frame for which a 301 is cached is unpredictable (depends on the strategy employed by intermediate layers and browsers) makes a strong case for explicitly setting the header in Drupal, as this makes the behaviour predictable (and somewhat configurable through the page cache max age setting.)
Comment #27
pobster commentedJust a reroll for 8.8.1 (latest 8.8.x anyway...)
Comment #29
neclimdulThis is a weird issue. Had to read it half a dozen times to really grok the interactions.
This has sat around long enough its going to land in 9.x which means will be requiring 4.x+. Reading back through the comments and the Symfony issue it sounds that means this chunk isn't required anymore.
Man their logic really puts us in a sticky situation here. This docblock is a bit confusing. I tried to come up with a way of explaining how but I'm struggling. Maybe we can just add
@see Symfony\Component\HttpFoundation\RedirectResponse::__constructso the next person struggling with this can follow where the logic is we're matching.Corollary, if I make a
Responsewith a 301 status code this is going to match I think. Is that ok? I don't have the answer to that.That's weird... who wrote that? Wait what? Oh, hey look over there at that other thing that's not a hacky test.
Glad we're on versions where that hack is not needed. Sorry I didn't follow up.
Comment #32
emanuelrighetto commentedThis patch solved a rather annoying problem on my Drupal 9.3.x-dev installation.
Let me explain the characteristics of what I think is a bug: without the patch the redirection (only for anonimous user) from https://mydomain.ext/node/6 to the specifict node 6 path alias raised an exception:
On line 314 of vendor/symfony/http-foundation/Response.php there is this check:
I have only noticed this behaviour for anonymous users; if the user is logged in, the redirection from https://mydomain.ext/node/6 to https://mydomain.ext/page/this-is-a-sample-path-alias is completed without error.
Comment #36
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It either no longer applies to Drupal core, or fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
Apart from a re-roll or rebase, this issue may need more work to address feedback in the issue or MR comments. To progress an issue, incorporate this feedback as part of the process of updating the issue. This helps other contributors to know what is outstanding.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #37
anybodyThe upstream Symfony issue from #20 is fixed, so we could proceed here, but now it seems unclear, what's the status in Drupal 10. Is the issue still existing and is someone still using a patch from this issue?
MAYBE things might get worse if this is not fixed and we merge #3311406: .htaccess ExpiresDefault (2W) is much too low. Should be ~1Y?
Comment #38
bkosborneThis is indeed still an issue in Drupal 10. And indeed, this patch can be simplified as we should no longer need to deal with the different case of the cache-control header anymore.
Comment #40
simeVanilla re-roll of #27 (8.x) against 10.1.x
Comment #41
simeA vanilla re-roll of the patch for 11.x and running tests.
Comment #42
simeLint
Comment #43
simeLint, passing tests
Comment #44
simeSo that's passing, but needs reviewing feedback of #29 in #38 given upstream changes in Synfony.
Comment #45
pameeela commentedPatch applies still, setting to NR.
Comment #46
smustgrave commentedNitpicky stuff, can we add typehints and returns for new functions and parameters through.
Converting to an MR is also preferred if possibe
Comment #49
angrytoast commentedUpdated with a MR and incorporates feedback from #29 regarding no need for the case-insensitive header logic update.
Also updated with typehints for params + returns on affected / new methods as requested by #46.
Comment #50
mr.baileysComment #51
smustgrave commentedTest-only feature showed test-coverage.
Appears all feedback has been addressed.
Believe this is good.
Comment #52
quietone commentedThis needs a rebase.
Comment #53
pobster commentedIt needs a bit of rewriting now too, as we have AssertPageCacheContextsAndTagsTrait for enabling page cache and checking header(s). Might as well use it.
If I get time later on I'll do it...
Comment #54
tim@lammar.be commentedCopy of patch #40 but with correct extension (without the '.txt').
Looks like this patch would be able to work on D10.2.x
Comment #55
tim@lammar.be commentedapparently my previous patch (rename from #40) would not apply to 10.2.x
Made some changes and this one should.
Comment #56
pobster commentedAs I was saying in #53, we have AssertPageCacheContextsAndTagsTrait available here - how would you feel about changing the test from say;
To something like;
Comment #57
longwave#56 makes sense to me, we have those helpers so why not use them.
Comment #60
richardgaunt commentedPatches rolled into the MR by Sean, hiding patches.
Comment #61
richardgaunt commentedComment #62
quietone commentedI read the MR with wiifm, focusing on the comments. I noticed different casing of 'Cache-Control' and the tense of a verb that should change. Those are being looked into. The code changes read well to me and are easy to understand, however I can't RTBC. I asked if the change was specific to a version of Symfony which he did not think so.
Comment #63
wiifmComment #64
wiifmComment #65
wiifmComment #66
acbramley commentedAdding credit for contributors during the DS2024 Code sprint.
Just 1 final question left on the MR.
Comment #67
smustgrave commentedReverted change to SecuredRedirectResponse and tests still pass so don't believe it was needed. That resolved the last thread I believe.
Comment #68
wim leersLooks like a well-tested and tightly scoped change, with potentially big scalability improvements (far fewer origin requests to respond to)!
Comment #69
alexpottCommitted and pushed 2fbe22aa19 to 11.x and ec45fd5604 to 10.3.x. Thanks!