From d73b171fc1f5c58158e0dd951fb08b833cab3072 Mon Sep 17 00:00:00 2001 From: kokotatan Date: Sat, 3 Oct 2026 22:57:51 +0900 Subject: [PATCH] Keep request max-age within response freshness lifetime --- cachecontrol/controller.py | 8 ++++---- tests/test_cache_control.py | 41 +++++++++++++++++++++++++++++++++++++ tests/test_max_age.py | 14 +++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/cachecontrol/controller.py b/cachecontrol/controller.py index 5eca8de7..a34b5e4d 100644 --- a/cachecontrol/controller.py +++ b/cachecontrol/controller.py @@ -243,13 +243,13 @@ def cached_request(self, request: PreparedRequest) -> HTTPResponse | Literal[Fal logger.debug("Returning cached permanent redirect response") return resp - # Determine if we are setting freshness limit in the - # request. Note, this overrides what was in the response. + # A request max-age can shorten, but must not extend, the response's + # freshness lifetime (RFC 9111 section 5.2.1.1). max_age = cc.get("max-age") if max_age is not None: - freshness_lifetime = max_age + freshness_lifetime = min(freshness_lifetime, max_age) logger.debug( - "Freshness lifetime from request max-age: %i", freshness_lifetime + "Freshness lifetime limited by request max-age: %i", freshness_lifetime ) min_fresh = cc.get("min-fresh") diff --git a/tests/test_cache_control.py b/tests/test_cache_control.py index 6e3f21bd..f001ab96 100644 --- a/tests/test_cache_control.py +++ b/tests/test_cache_control.py @@ -353,6 +353,47 @@ def test_cache_request_unfresh_max_age(self): r = self.req({}) assert not r + @pytest.mark.parametrize("status", [200, 301, 308]) + @pytest.mark.parametrize("expiration", ["max-age", "expires"]) + def test_request_max_age_does_not_extend_response_freshness( + self, monkeypatch, status, expiration + ): + now = 1700000000 + monkeypatch.setattr(time, "time", lambda: now) + headers = {"date": time.strftime(TIME_FMT, time.gmtime(now - 120))} + if expiration == "max-age": + headers["cache-control"] = "max-age=60" + else: + headers["expires"] = time.strftime(TIME_FMT, time.gmtime(now - 60)) + resp = Mock(headers=headers, status=status) + self.c.cache = DictCache({self.url: resp}) + + assert self.req({"cache-control": "max-age=3600"}) is False + + @pytest.mark.parametrize("request_max_age, cached", [(10, False), (3600, True)]) + def test_request_max_age_limits_fresh_response( + self, monkeypatch, request_max_age, cached + ): + now = 1700000000 + monkeypatch.setattr(time, "time", lambda: now) + date = time.strftime(TIME_FMT, time.gmtime(now - 30)) + resp = Mock(headers={"cache-control": "max-age=60", "date": date}, status=200) + self.c.cache = DictCache({self.url: resp}) + + result = self.req({"cache-control": f"max-age={request_max_age}"}) + + assert result is (resp if cached else False) + + def test_request_max_age_revalidates_etag_without_freshness(self): + date = time.strftime(TIME_FMT, time.gmtime()) + resp = Mock(headers={"date": date, "etag": '"v1"'}, status=200) + self.c.cache = DictCache({self.url: resp}) + request = Mock(url=self.url, headers={"cache-control": "max-age=3600"}) + + assert self.c.cached_request(request) is False + assert self.c.cache.get(self.url) is resp + assert self.c.conditional_headers(request) == {"If-None-Match": '"v1"'} + def test_cache_request_unfresh_permanent_redirect(self): earlier = time.time() - 3600 date = time.strftime(TIME_FMT, time.gmtime(earlier)) diff --git a/tests/test_max_age.py b/tests/test_max_age.py index 18fe18d9..d66edefb 100644 --- a/tests/test_max_age.py +++ b/tests/test_max_age.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: Apache-2.0 +import time + import pytest from requests import Session @@ -55,3 +57,15 @@ def test_client_max_age_3600(self, sess): resp.headers["date"] = "Tue, 15 Nov 1994 08:12:31 GMT" r = sess.get(self.url) assert not r.from_cache + + def test_client_max_age_does_not_extend_server_max_age(self, sess): + url = self.url + "cache_60" + sess.get(url) + cached_response = self.cache.get(url) + cached_response.headers["date"] = time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", time.gmtime(time.time() - 120) + ) + + response = sess.get(url, headers={"Cache-Control": "max-age=3600"}) + + assert not response.from_cache