[PATCH] Upstream: prioritise Cache-Control over Expires

Sergey Kandaurov pluknet at nginx.com
Fri Jun 10 11:37:34 UTC 2022


> On 8 Jun 2022, at 04:57, Maxim Dounin <mdounin at mdounin.ru> wrote:
> 
> Hello!
> 
> On Tue, Jun 07, 2022 at 07:58:36PM +0400, Sergey Kandaurov wrote:
> 
>>> On 7 Jun 2022, at 01:09, Maxim Dounin <mdounin at mdounin.ru> wrote:
>>> 
>>> On Mon, Jun 06, 2022 at 05:42:20PM +0400, Sergey Kandaurov wrote:
>>> 
>>>> On Sun, Apr 24, 2022 at 06:42:47PM +0300, Maxim Dounin wrote:
>>>>> Hello!
>>>>> 
>>>>> On Sun, Apr 24, 2022 at 07:55:17AM +0300, Maxim Dounin wrote:
>>>>> 
>>>>> [...]
>>>>> 
>>>>>> As far as I can tell, proper behaviour, assuming we parse cache 
>>>>>> control extensions independently of X-Accel-Expires, can be 
>>>>>> implemented by using just one flag.
>>>>> 
>>>>> No, that's wrong, as with just one flag it wouldn't be possible to 
>>>>> correctly disable caching of responses with:
>>>>> 
>>>>> Cache-Control: private
>>>>> Cache-Control: max-age=10
>>>>> 
>>>>> So it needs at least two flags.  Updated patch below, review and 
>>>>> testing appreciated.
>>>>> 
>>>>> # HG changeset patch
>>>>> # User Maxim Dounin <mdounin at mdounin.ru>
>>>>> # Date 1650814681 -10800
>>>>> #      Sun Apr 24 18:38:01 2022 +0300
>>>>> # Node ID 940ba4317a97c72d1ee6700cbf58a543fee04c7a
>>>>> # Parent  a736a7a613ea6e182ff86fbadcb98bb0f8891c0b
>>>>> Upstream: fixed X-Accel-Expires/Cache-Control/Expires handling.
>>>>> [..]
>>>> [..]
>>>> As the above note covers a corner case of X-Accel-Expires,
>>>> I believe it's fine to commit as is.
>>> 
>>> This patch doesn't try to change handling of X-Accel-Expires in 
>>> the past.
>>> 
>>> And, honestly, the only documented way to _disable_ caching is to 
>>> use 'X-Accel-Expires: 0'.  The 'X-Accel-Expires: @0' only 
>>> documented to set the time up to which the response may be cached 
>>> to 0 seconds the Epoch.  Expires and Cache-Control handling used 
>>> to disable caching in similar situations, yet this behaviour was 
>>> questioned more than once.  X-Accel-Expires never disabled caching 
>>> for absolute times in the past, and this is known to be used as a 
>>> workaround to cache a pre-expires response[1][2]. 
>>> 
>>> Pushed to http://mdounin.ru/hg/nginx.
>>> 
>>> [1] https://trac.nginx.org/nginx/ticket/1182
>>> [2] https://mailman.nginx.org/pipermail/nginx-ru/2013-November/052614.html
>>> 
>> 
>> Tests:
>> 
>> 
>> # HG changeset patch
>> # User Sergey Kandaurov <pluknet at nginx.com>
>> # Date 1654617432 -14400
>> #      Tue Jun 07 19:57:12 2022 +0400
>> # Node ID ed5fe35807f6c4853be1eb8ac429524e04ac5d67
>> # Parent  4f238efded81e00c635c146df6ecb585fa0907ec
>> Tests: added X-Accel-Expires tests.
> 
> This probably needs to be adjusted.
> 
>> 
>> diff --git a/proxy_xae.t b/proxy_xae.t
>> new file mode 100644
>> --- /dev/null
>> +++ b/proxy_xae.t
> 
> Should be "proxy_cache_*.t", probably "proxy_cache_control.t" 
> would be a good enough name.
> 
> Related existing tests are in proxy_cache_valid.t, though a 
> separate file is probably more readable given the number of tests.

I didn't concentrate on Expires and Cache-Control originally,
but rather on the preference order vs. X-Accel-Expires.
Extending it with generic tests may be a good idea, though.

> 
>> @@ -0,0 +1,160 @@
>> +#!/usr/bin/perl
>> +
>> +# (C) Sergey Kandaurov
>> +# (C) Nginx, Inc.
>> +
>> +# Tests for proxy Expires / Cache-Control / X-Accel-Expires preference.
>> +
>> +###############################################################################
>> +
>> +use warnings;
>> +use strict;
>> +
>> +use Test::More;
>> +
>> +BEGIN { use FindBin; chdir($FindBin::Bin); }
>> +
>> +use lib 'lib';
>> +use Test::Nginx;
>> +
>> +###############################################################################
>> +
>> +select STDERR; $| = 1;
>> +select STDOUT; $| = 1;
>> +
>> +my $t = Test::Nginx->new()->has(qw/http proxy cache rewrite map/)->plan(19);
>> +
>> +$t->write_file_expand('nginx.conf', <<'EOF');
>> +
>> +%%TEST_GLOBALS%%
>> +
>> +daemon off;
>> +
>> +events {
>> +}
>> +
>> +http {
>> +    %%TEST_GLOBALS_HTTP%%
>> +
>> +    proxy_cache_path   %%TESTDIR%%/cache  levels=1:2
>> +                       keys_zone=NAME:1m;
>> +
>> +    map $arg_e $e {
>> +        epoch  "Thu, 01 Jan 1970 00:00:01 GMT";
>> +        max    "Thu, 31 Dec 2037 23:55:55 GMT";
>> +    }
>> +
>> +    server {
>> +        listen       127.0.0.1:8080;
>> +        server_name  localhost;
>> +
>> +        location / {
>> +            proxy_pass   http://127.0.0.1:8081;
>> +            proxy_cache  NAME;
>> +
>> +            proxy_cache_background_update  on;
>> +
>> +            add_header  X-Cache-Status  $upstream_cache_status;
> 
> This probably can be moved to server level.
> 
>> +        }
>> +
>> +        location /ignore {
>> +            proxy_pass   http://127.0.0.1:8081/return-xae;
> 
> Any reasons for "/return-xae"?

Adjusted all the above, thnx.

> 
>> +            proxy_cache  NAME;
>> +
>> +            proxy_ignore_headers  X-Accel-Expires;
>> +
>> +            add_header  X-Cache-Status  $upstream_cache_status;
>> +        }
>> +    }
>> +
>> +    server {
>> +        listen       127.0.0.1:8081;
>> +        server_name  localhost;
>> +
>> +        location / {
>> +            add_header  Expires          $e;
>> +            add_header  Cache-Control    $arg_c;
>> +            add_header  X-Accel-Expires  $arg_x;
>> +            add_header  X-Accel-Expires  $arg_xx;
>> +
>> +            return 204;
>> +        }
>> +
>> +        location /rev {
>> +            add_header  X-Accel-Expires  $arg_x;
>> +            add_header  Cache-Control    $arg_c;
>> +            add_header  Expires          $e;
>> +
>> +            return 204;
>> +        }
>> +    }
>> +}
>> +
>> +EOF
>> +
>> +$t->run();
>> +
>> +###############################################################################
>> +
>> +# Cache-Control is preferred over Expires, and
>> +# X-Accel-Expires is preferred over both Cache-Control and Expires,
>> +
>> +like(get('/?e=max'), qr/HIT/, 'Expires');
>> +like(get('/?c=max-age=60'), qr/HIT/, 'Cache-Control');
>> +like(get('/?x=60'), qr/HIT/, 'X-Accel-Expires');
>> +like(get('/?x=@2145902155'), qr/HIT/, 'X-Accel-Expires at');
> 
> It would be great to have test names in lowercase unless there are 
> specific reasons to write them differently.  Header names are 
> usually written in lowercase.

It's based on proxy_xar.t where headers start with capital letter.
I don't mind to replace them with lowercase though and think
it's generally good to have a simple description in lowercase.

> 
>> +
>> +like(get('/ignore?x=60&ign=1'), qr/MISS/, 'X-Accel-Expires ignored');
>> +
>> +TODO: {
>> +local $TODO = 'not yet' unless $t->has_version('1.23.0');
>> +
>> +like(get('/?x=60&xx=0'), qr/HIT/, 'X-Accel-Expires duplicate');
>> +
>> +}
>> +
> 
> It might be better to use explicitly returned headers in 
> appropriately named locations, at least for some specific tests 
> like the one with duplicate X-Accel-Expires, e.g.:
> 
> location /duplicate-accel-expires {
>    add_header X-Accel-Expires 60;
>    add_header X-Accel-Expires 0;
>    return 204;
> }
> 

Explicit headers will require rather large configuration,
but let's try and see.

> Another test with multiple headers to consider, given that this 
> specific case requires two flags instead of just one:
> 
> location /cache-control-no-cache-multi {
>     add_header Cache-Control no-cache;
>     add_header Cache-Control max-age=60;
>     return 204;
> }
> 
> Probably along with the simple variant,
> 
> location /cache-control-no-cache-one {
>     add_header Cache-Control "no-cache, max-age=60";
>     return 204;
> }
> 
> which is expected to be exactly equivalent.

Added.

> 
> Also, it might be a good idea to make sure that various 
> other factors to disable cache, such as Set-Cookie, are not 
> ignored when a resource is returned with Expires in the past and 
> valid Cache-Control max-age.  There were multiple attempts to 
> submit patches which failed to handle this properly.  E.g.:
> 
> location /set-cookie {
>    add_header Set-Cookie foo;
>    add_header Cache-Control max-age=60;
>    return 204;
> }

If I got you right, it refers to earlier patches in January
with erroneously missed Set-Cookie handling.
I reproduced this with Expires in the past and X-Accel-Expires:

location /set-cookie {
    add_header Set-Cookie foo;
    add_header Expires "Thu, 01 Jan 1970 00:00:01 GMT";
    add_header X-Accel-Expires 60;
    return 204;
}

> 
>> +# handling of caching parameters in Expires / Cache-Control / X-Accel-Expires
>> +
>> +like(get('/?e=max&c=no-cache'), qr/MISS/, 'Expires on CC off');
> 
> I would really like to see Cache-Control non-abbreviated, even if 
> it requires some line wrapping.

Sure, don't like it as well.

> 
>> +like(get('/?e=max&x=0'), qr/MISS/, 'Expires on X-Accel-Expires off');
>> +like(get('/?c=max-age=60&x=0'), qr/MISS/, 'CC on X-Accel-Expires off');
>> +
>> +TODO: {
>> +local $TODO = 'not yet' unless $t->has_version('1.23.0');
>> +
>> +like(get('/?e=epoch&c=max-age=60'), qr/HIT/, 'Expires off CC on');
>> +like(get('/?e=epoch&x=60'), qr/HIT/, 'Expires off X-Accel-Expires on');
>> +like(get('/?c=no-cache&x=60'), qr/HIT/, 'CC off X-Accel-Expires on');
>> +
>> +}
> 
> Any tests with all three headers?
> 
> Overall, we need to test the following basic things:
> 
> - Expires is ignored when Cache-Control is present.  This implies 
>  that "Cache-Control: max-age=60" enables caching even if the 
>  response has Expires header in the past, regardless of the 
>  order, i.e.:
> 
> location /cache-control-after-expires {
>    add_header Expires "Thu, 01 Jan 1970 00:00:01 GMT";
>    add_header Cache-Control max-age=60;
>    return 204;
> }
> 
> location /cache-control-before-expires {
>    add_header Cache-Control max-age=60;
>    add_header Expires "Thu, 01 Jan 1970 00:00:01 GMT";
>    return 204;
> }
> 
> location /cache-control-no-cache-after-expires {
>    add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT";
>    add_header Cache-Control no-cache;
>    return 204;
> }
> 
> location /cache-control-no-cache-before-expires {
>    add_header Cache-Control no-cache;
>    add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT";
>    return 204;
> }
> 
> - Expires and Cache-Control are ignored when X-Accel-Expires is 
>  present.  Quite similar to the above, but with Expires / 
>  Cache-Control vs. X-Accel-Expires.
> 
> location /x-accel-expires-after {
>    add_header Expires "Thu, 01 Jan 1970 00:00:01 GMT";
>    add_header Cache-Control no-cache;
>    add_header X-Accel-Expires 60;
>    return 204;
> }
> 
> location /x-accel-expires-before {
>    add_header X-Accel-Expires 60;
>    add_header Expires "Thu, 01 Jan 1970 00:00:01 GMT";
>    add_header Cache-Control no-cache;
>    return 204;
> }
> 
> location /x-accel-expires-0-after {
>    add_header Cache-Control max-age=60;
>    add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT";
>    add_header X-Accel-Expires 0;
>    return 204;
> }
> 
> location /x-accel-expires-0-before {
>    add_header X-Accel-Expires 0;
>    add_header Cache-Control max-age=60;
>    add_header Expires "Thu, 31 Dec 2037 23:55:55 GMT";
>    return 204;
> }

Indeed, this results in pretty the same number of tests,
even though the configuration is expanded quite a bit
compared to request based configuration with variables,
so it looks promising to implement that way.

Updated patch is at the end.

> 
>> +
>> +# and in the reversed order: X-Accel-Expires / Cache-Control / Expires
>> +
>> +like(get('/rev/?e=max&c=no-cache'), qr/MISS/, 'CC off Expires on');
>> +like(get('/rev?e=max&x=0'), qr/MISS/, 'X-Accel-Expires off Expires on');
>> +like(get('/rev?c=max-age=60&x=0'), qr/MISS/, 'X-Accel-Expires off CC on');
>> +
>> +like(get('/rev?e=epoch&c=max-age=60'), qr/HIT/, 'CC on Expires off');
>> +like(get('/rev?e=epoch&x=60'), qr/HIT/, 'X-Accel-Expires on Expires off');
>> +like(get('/rev?c=no-cache&x=60'), qr/HIT/, 'X-Accel-Expires on CC off');
>> +
>> +# Cache-Control caching extensions preserved after X-Accel-Expires
>> +
>> +TODO: {
>> +local $TODO = 'not yet' unless $t->has_version('1.23.0');
>> +
>> +my $time = time() - 1;
>> +like(get("/rev?c=max-age=60,stale-while-revalidate=60&x=\@$time"),
>> +	qr/STALE/, 'Cache-Control extensions after X-Accel-Expires');
> 
> Something like:
> 
> X-Accel-Expires: @1
> Cache-Control: stale-while-revalidate=2145902155
> 
> shouldn't require any calculations (and can be placed in an 
> explicit location).  And the opposite order probably worth 
> checking too.

While it's better to avoid math, this one is somewhat synthetic.
Though, I have no objection in principle, good enough for tests.

> 
> Not sure if "max-age=60" is at all needed here, though probably 
> it can catch some potential bugs.
> 

Since "stale-while-revalidate" is an extension, I assume that
it should be given with some base parameter, such as "max-age".
The assumption is supported by examples in RFC 5861.

This brought me a thought that we don't respect Cache-Control
preference over Expires if given with only extensions:

Expires: Thu, 31 Dec 2037 23:55:55 GMT
Cache-Control: stale-while-revalidate=1

In the above, Cache-Control doesn't set/override valid_sec,
and as such, it is set with Expires on its own.


# HG changeset patch
# User Sergey Kandaurov <pluknet at nginx.com>
# Date 1654860778 -14400
#      Fri Jun 10 15:32:58 2022 +0400
# Node ID f8e5ab69e6edd379b2ea9e0e8f1ebbab01fd9075
# Parent  4f238efded81e00c635c146df6ecb585fa0907ec
Tests: added proxy cache tests with upstream cache headers.

diff --git a/proxy_cache_control.t b/proxy_cache_control.t
new file mode 100644
--- /dev/null
+++ b/proxy_cache_control.t
@@ -0,0 +1,276 @@
+#!/usr/bin/perl
+
+# (C) Sergey Kandaurov
+# (C) Nginx, Inc.
+
+# Tests for proxy Expires / Cache-Control / X-Accel-Expires.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+my $t = Test::Nginx->new()->has(qw/http proxy cache rewrite/)->plan(19);
+
+$t->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    proxy_cache_path   %%TESTDIR%%/cache  keys_zone=NAME:1m;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        add_header  X-Cache-Status  $upstream_cache_status;
+
+        location / {
+            proxy_pass   http://127.0.0.1:8081;
+            proxy_cache  NAME;
+
+            proxy_cache_background_update  on;
+        }
+
+        location /ignore {
+            proxy_pass   http://127.0.0.1:8081;
+            proxy_cache  NAME;
+
+            proxy_ignore_headers  Cache-Control Expires;
+            proxy_ignore_headers  X-Accel-Expires;
+        }
+    }
+
+    server {
+        listen       127.0.0.1:8081;
+        server_name  localhost;
+
+        location /expires {
+            add_header  Expires          "Thu, 31 Dec 2037 23:55:55 GMT";
+            return 204;
+        }
+
+        location /cache-control {
+            add_header  Cache-Control    max-age=60;
+            return 204;
+        }
+
+        location /x-accel-expires {
+            add_header  X-Accel-Expires  60;
+            return 204;
+        }
+
+        location /x-accel-expires-at {
+            add_header  X-Accel-Expires  @60;
+            return 204;
+        }
+
+        location /x-accel-expires-duplicate {
+            add_header  X-Accel-Expires  60;
+            add_header  X-Accel-Expires  0;
+            return 204;
+        }
+
+        location /ignore {
+            add_header  Expires          "Thu, 31 Dec 2037 23:55:55 GMT";
+            add_header  Cache-Control    max-age=60;
+            add_header  X-Accel-Expires  60;
+            return 204;
+        }
+
+        location /cache-control-after-expires {
+            add_header  Expires          "Thu, 01 Jan 1970 00:00:01 GMT";
+            add_header  Cache-Control    max-age=60;
+            return 204;
+        }
+
+        location /cache-control-before-expires {
+            add_header  Cache-Control    max-age=60;
+            add_header  Expires          "Thu, 01 Jan 1970 00:00:01 GMT";
+            return 204;
+        }
+
+        location /cache-control-no-cache-after-expires {
+            add_header  Expires          "Thu, 31 Dec 2037 23:55:55 GMT";
+            add_header  Cache-Control    no-cache;
+            return 204;
+        }
+
+        location /cache-control-no-cache-before-expires {
+            add_header  Cache-Control    no-cache;
+            add_header  Expires          "Thu, 31 Dec 2037 23:55:55 GMT";
+            return 204;
+        }
+
+        location /x-accel-expires-after {
+            add_header  Expires          "Thu, 01 Jan 1970 00:00:01 GMT";
+            add_header  Cache-Control    no-cache;
+            add_header  X-Accel-Expires  60;
+            return 204;
+        }
+
+        location /x-accel-expires-before {
+            add_header  X-Accel-Expires  60;
+            add_header  Expires          "Thu, 01 Jan 1970 00:00:01 GMT";
+            add_header  Cache-Control    no-cache;
+            return 204;
+        }
+
+        location /x-accel-expires-0-after {
+            add_header  Cache-Control    max-age=60;
+            add_header  Expires          "Thu, 31 Dec 2037 23:55:55 GMT";
+            add_header  X-Accel-Expires  0;
+            return 204;
+        }
+
+        location /x-accel-expires-0-before {
+            add_header  X-Accel-Expires  0;
+            add_header  Cache-Control    max-age=60;
+            add_header  Expires          "Thu, 31 Dec 2037 23:55:55 GMT";
+            return 204;
+        }
+
+        location /cache-control-no-cache-one {
+            add_header  Cache-Control    "no-cache, max-age=60";
+            return 204;
+        }
+
+        location /cache-control-no-cache-multi {
+            add_header  Cache-Control    no-cache;
+            add_header  Cache-Control    max-age=60;
+            return 204;
+        }
+
+        location /extension-before-x-accel-expires {
+            add_header  Cache-Control
+                        "max-age=60, stale-while-revalidate=2145902155";
+            add_header  X-Accel-Expires  @1;
+            return 204;
+        }
+
+        location /extension-after-x-accel-expires {
+            add_header  X-Accel-Expires  @1;
+            add_header  Cache-Control
+                        "max-age=60, stale-while-revalidate=2145902155";
+            return 204;
+        }
+
+        location /set-cookie {
+            add_header  Set-Cookie       foo;
+            add_header  Expires          "Thu, 01 Jan 1970 00:00:01 GMT";
+            add_header  X-Accel-Expires  60;
+            return 204;
+        }
+    }
+}
+
+EOF
+
+$t->run();
+
+###############################################################################
+
+# upstream cache headers work
+
+like(get('/expires'), qr/HIT/, 'expires');
+like(get('/cache-control'), qr/HIT/, 'cache-control');
+like(get('/x-accel-expires'), qr/HIT/, 'x-accel-expires');
+like(get('/x-accel-expires-at'), qr/EXPIRED/, 'x-accel-expires at');
+
+TODO: {
+local $TODO = 'not yet' unless $t->has_version('1.23.0');
+
+# the second header to disable cache is duplicate and ignored
+
+like(get('/x-accel-expires-duplicate'), qr/HIT/, 'x-accel-expires duplicate');
+
+}
+
+# with cache headers ignored, the response will be fresh
+
+like(get('/ignore'), qr/MISS/, 'cache headers ignored');
+
+# Cache-Control is preferred over Expires
+
+TODO: {
+local $TODO = 'not yet' unless $t->has_version('1.23.0');
+
+like(get('/cache-control-after-expires'), qr/HIT/,
+	'cache-control after expires');
+
+}
+
+like(get('/cache-control-before-expires'), qr/HIT/,
+	'cache-control before expires');
+
+like(get('/cache-control-no-cache-after-expires'), qr/MISS/,
+	'cache-control no-cache after expires');
+like(get('/cache-control-no-cache-before-expires'), qr/MISS/,
+	'cache-control no-cache before expires');
+
+# X-Accel-Expires is preferred over both Cache-Control and Expires
+
+TODO: {
+local $TODO = 'not yet' unless $t->has_version('1.23.0');
+
+like(get('/x-accel-expires-after'), qr/HIT/, 'x-accel-expires after');
+
+}
+
+like(get('/x-accel-expires-before'), qr/HIT/, 'x-accel-expires before');
+
+like(get('/x-accel-expires-0-after'), qr/MISS/, 'x-accel-expires 0 after');
+like(get('/x-accel-expires-0-before'), qr/MISS/, 'x-accel-expires 0 before');
+
+# "Cache-Control: no-cache" disables caching, no matter of "max-age".
+
+like(get('/cache-control-no-cache-one'), qr/MISS/,
+	'cache-control no-cache');
+like(get('/cache-control-no-cache-multi'), qr/MISS/,
+	'cache-control no-cache multi line');
+
+# Set-Cookie is considered when caching with X-Accel-Expires
+
+like(get('/set-cookie'), qr/MISS/, 'set-cookie not cached');
+
+# Cache-Control extensions are preserved with X-Accel-Expires
+
+like(get('/extension-before-x-accel-expires'),
+	qr/STALE/, 'cache-control extensions before x-accel-expires');
+
+TODO: {
+local $TODO = 'not yet' unless $t->has_version('1.23.0');
+
+like(get('/extension-after-x-accel-expires'),
+	qr/STALE/, 'cache-control extensions after x-accel-expires');
+
+}
+
+###############################################################################
+
+sub get {
+	my ($uri) = @_;
+	http_get($uri);
+	http_get($uri);
+}
+
+###############################################################################

-- 
Sergey Kandaurov



More information about the nginx-devel mailing list