From b0098fb6cf08fee30d64a1d1e828b81cb694f3d3 Mon Sep 17 00:00:00 2001 From: bneradt Date: Thu, 13 Aug 2026 14:53:38 -0500 Subject: [PATCH 1/2] Add percentage limit for stale cache age A fixed stale-age window can let short-lived cached responses remain usable for many times their original freshness lifetime. This makes serving stale content disproportionately risky for responses with a short max-age. This adds an optional percentage limit and applies the smaller of the percentage-based and absolute stale-age windows. The default keeps the existing behavior, while transaction overrides and supported scripting interfaces can opt in per use case. Fixes: #12252 --- doc/admin-guide/files/records.yaml.en.rst | 49 ++++++-- doc/admin-guide/plugins/lua.en.rst | 1 + .../functions/TSHttpOverridableConfig.en.rst | 1 + .../api/types/TSOverridableConfigKey.en.rst | 1 + include/cripts/Configs.hpp | 1 + include/proxy/http/HttpConfig.h | 1 + include/proxy/http/OverridableConfigDefs.h | 3 +- include/ts/apidefs.h.in | 1 + src/proxy/http/HttpConfig.cc | 4 +- src/proxy/http/HttpTransact.cc | 14 ++- src/records/RecordsConfig.cc | 6 +- .../cache/proxy_serve_stale.test.py | 3 + .../replay/max_stale_age_percent.replay.yaml | 117 ++++++++++++++++++ .../gold_tests/records/gold/full_records.yaml | 1 + .../records/legacy_config/full_records.config | 1 + 15 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 25c4bec22ef..12a1a4d46c2 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -2032,9 +2032,10 @@ Origin Server Connect Attempts :overridable: Specifies how long (in seconds) |TS| remembers that an origin server was unreachable. - During this window, if a stale cached response exists and its age is within the cached response's ``max-age`` plus - :ts:cv:`proxy.config.http.cache.max_stale_age`, |TS| serves the stale content directly - without attempting to contact the origin server. + During this window, if a stale cached response exists and its age is within the limits configured by + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent`, |TS| serves the stale content directly without attempting + to contact the origin server. .. ts:cv:: CONFIG proxy.config.http.uncacheable_requests_bypass_parent INT 1 :reloadable: @@ -2128,8 +2129,8 @@ Negative Response Caching current stale content is preserved and served. Note this is considered only on a revalidation of already cached content. A revalidation failure means a connection failure or a 50x response code. When considering replying with a stale response in these negative revalidating circumstances, - |TS| will respect the :ts:cv:`proxy.config.http.cache.max_stale_age` configuration and will not - use a cached response older than ``max_stale_age`` seconds plus ``max-age`` of cached content. + |TS| will respect the :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` limits. A value of ``0`` disables serving stale content and a value of ``1`` enables keeping and serving stale content if revalidation fails. @@ -2155,7 +2156,8 @@ Negative Response Caching behavior with regard to whether it considers a stale object to be fresh enough to serve out of cache when revalidation fails. As mentioned above in :ts:cv:`proxy.config.http.negative_revalidating_enabled`, - :ts:cv:`proxy.config.http.cache.max_stale_age` is used for that determination. + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` are used for that determination. This configuration defaults to 1,800 seconds (30 minutes). @@ -2654,7 +2656,26 @@ Cache Control :reloadable: :overridable: - The maximum age in seconds allowed for a stale response before it cannot be cached. + The maximum number of seconds that a stale response can be served after its ``max-age`` freshness + lifetime has passed. + + When :ts:cv:`proxy.config.http.cache.max_stale_age_percent` is enabled, the smaller of the + absolute and percentage limits is used. + +.. ts:cv:: CONFIG proxy.config.http.cache.max_stale_age_percent INT 0 + :reloadable: + :overridable: + + Limits the stale response lifetime to a percentage of its ``max-age`` freshness lifetime. The + valid range is ``0`` to ``100``. A value of ``0`` disables the percentage limit. When enabled, + |TS| uses the smaller of this limit and + :ts:cv:`proxy.config.http.cache.max_stale_age`. + + For example, a value of ``25`` allows a response with a one-hour freshness lifetime to be served + stale for up to 15 minutes, provided that the absolute stale age limit is at least 15 minutes. + + Responses without ``max-age`` are controlled by :ts:cv:`proxy.config.http.cache.max_stale_age` + alone. .. ts:cv:: CONFIG proxy.config.http.cache.guaranteed_min_lifetime INT 0 :reloadable: @@ -3241,13 +3262,15 @@ Dynamic Content & Content Negotiation ===== ====================================================================== ``0`` Default. Disable cache and go to origin server. ``1`` Return a ``502`` error on a cache miss. - ``2`` Serve stale if object's age is under ``max-age`` + - :ts:cv:`proxy.config.http.cache.max_stale_age`. Otherwise, go to - origin server. + ``2`` Serve stale if the object's age is within the + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` limits. + Otherwise, go to the origin server. ``3`` Return a ``502`` error on a cache miss or serve stale on a cache - revalidate if object's age is under ``max-age`` + - :ts:cv:`proxy.config.http.cache.max_stale_age`. Otherwise, go to - origin server. + revalidate if the object's age is within the + :ts:cv:`proxy.config.http.cache.max_stale_age` and + :ts:cv:`proxy.config.http.cache.max_stale_age_percent` limits. + Otherwise, go to the origin server. ``4`` Return a ``502`` error on either a cache miss or on a revalidation. ``5`` Retry Cache Read on a Cache Write Lock failure. This option together with :ts:cv:`proxy.config.cache.enable_read_while_writer` configuration diff --git a/doc/admin-guide/plugins/lua.en.rst b/doc/admin-guide/plugins/lua.en.rst index c5e7f784ce3..0716e89c115 100644 --- a/doc/admin-guide/plugins/lua.en.rst +++ b/doc/admin-guide/plugins/lua.en.rst @@ -4822,6 +4822,7 @@ Http config constants TS_LUA_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE TS_LUA_CONFIG_HTTP_CACHE_POST_METHOD TS_LUA_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS + TS_LUA_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT TS_LUA_CONFIG_LAST_ENTRY :ref:`TOP ` diff --git a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst index 43d98b40ad7..19e992d2599 100644 --- a/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst +++ b/doc/developer-guide/api/functions/TSHttpOverridableConfig.en.rst @@ -199,6 +199,7 @@ TSOverridableConfigKey Value Confi :enumerator:`TS_CONFIG_BODY_FACTORY_RESPONSE_SUPPRESSION_MODE` :ts:cv:`proxy.config.body_factory.response_suppression_mode` :enumerator:`TS_CONFIG_HTTP_CACHE_POST_METHOD` :ts:cv:`proxy.config.http.cache.post_method` :enumerator:`TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS` :ts:cv:`proxy.config.http.cache.targeted_cache_control_headers` +:enumerator:`TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT` :ts:cv:`proxy.config.http.cache.max_stale_age_percent` ======================================================================== ==================================================================== Examples diff --git a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst index 4ce7c1712d6..d9ae419c296 100644 --- a/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst +++ b/doc/developer-guide/api/types/TSOverridableConfigKey.en.rst @@ -166,6 +166,7 @@ Enumeration Members .. enumerator:: TS_CONFIG_HTTP_CACHE_IGNORE_QUERY .. enumerator:: TS_CONFIG_HTTP_CACHE_POST_METHOD .. enumerator:: TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS +.. enumerator:: TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT Description diff --git a/include/cripts/Configs.hpp b/include/cripts/Configs.hpp index 79460327e7d..013f84a9d92 100644 --- a/include/cripts/Configs.hpp +++ b/include/cripts/Configs.hpp @@ -101,6 +101,7 @@ class Proxy cripts::IntConfig max_open_write_retries{"proxy.config.http.cache.max_open_write_retries"}; cripts::IntConfig max_open_write_retry_timeout{"proxy.config.http.cache.max_open_write_retry_timeout"}; cripts::IntConfig max_stale_age{"proxy.config.http.cache.max_stale_age"}; + cripts::IntConfig max_stale_age_percent{"proxy.config.http.cache.max_stale_age_percent"}; cripts::IntConfig open_read_retry_time{"proxy.config.http.cache.open_read_retry_time"}; cripts::IntConfig open_write_fail_action{"proxy.config.http.cache.open_write_fail_action"}; diff --git a/include/proxy/http/HttpConfig.h b/include/proxy/http/HttpConfig.h index 5bf58f7d7d0..887ac1cb642 100644 --- a/include/proxy/http/HttpConfig.h +++ b/include/proxy/http/HttpConfig.h @@ -705,6 +705,7 @@ struct OverridableHttpConfigParams { MgmtInt cache_guaranteed_min_lifetime = 0; MgmtInt cache_guaranteed_max_lifetime = 31536000; MgmtInt cache_max_stale_age = 604800; + MgmtInt cache_max_stale_age_percent = 0; /////////////////////////////////////////////////// // connection variables. timeouts are in seconds // diff --git a/include/proxy/http/OverridableConfigDefs.h b/include/proxy/http/OverridableConfigDefs.h index bf00fc3def9..864461b1c8f 100644 --- a/include/proxy/http/OverridableConfigDefs.h +++ b/include/proxy/http/OverridableConfigDefs.h @@ -252,6 +252,7 @@ X(HTTP_NEGATIVE_REVALIDATING_LIST, negative_revalidating_list, "proxy.config.http.negative_revalidating_list", STRING, HttpStatusCodeList_Conv) \ X(HTTP_CACHE_POST_METHOD, cache_post_method, "proxy.config.http.cache.post_method", INT, GENERIC) \ X(HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS, targeted_cache_control_headers, "proxy.config.http.cache.targeted_cache_control_headers", STRING, TargetedCacheControlHeaders_Conv) \ - X(SSL_CLIENT_CA_CERT_PATH, ssl_client_ca_cert_path, "proxy.config.ssl.client.CA.cert.path", STRING, NONE) + X(SSL_CLIENT_CA_CERT_PATH, ssl_client_ca_cert_path, "proxy.config.ssl.client.CA.cert.path", STRING, NONE) \ + X(HTTP_CACHE_MAX_STALE_AGE_PERCENT, cache_max_stale_age_percent, "proxy.config.http.cache.max_stale_age_percent", INT, GENERIC) // clang-format on diff --git a/include/ts/apidefs.h.in b/include/ts/apidefs.h.in index f34ffdb1a3f..8e110942b01 100644 --- a/include/ts/apidefs.h.in +++ b/include/ts/apidefs.h.in @@ -918,6 +918,7 @@ enum TSOverridableConfigKey { TS_CONFIG_HTTP_CACHE_POST_METHOD, TS_CONFIG_HTTP_CACHE_TARGETED_CACHE_CONTROL_HEADERS, TS_CONFIG_SSL_CLIENT_CA_CERT_PATH, + TS_CONFIG_HTTP_CACHE_MAX_STALE_AGE_PERCENT, TS_CONFIG_LAST_ENTRY, }; diff --git a/src/proxy/http/HttpConfig.cc b/src/proxy/http/HttpConfig.cc index f0b1b535c32..08a3ed23633 100644 --- a/src/proxy/http/HttpConfig.cc +++ b/src/proxy/http/HttpConfig.cc @@ -1109,6 +1109,7 @@ HttpConfig::startup() HttpEstablishStaticConfigLongLong(c.oride.cache_guaranteed_max_lifetime, "proxy.config.http.cache.guaranteed_max_lifetime"); HttpEstablishStaticConfigLongLong(c.oride.cache_max_stale_age, "proxy.config.http.cache.max_stale_age"); + HttpEstablishStaticConfigLongLong(c.oride.cache_max_stale_age_percent, "proxy.config.http.cache.max_stale_age_percent"); HttpEstablishStaticConfigByte(c.oride.srv_enabled, "proxy.config.srv_enabled"); @@ -1456,7 +1457,8 @@ HttpConfig::reconfigure() params->oride.cache_guaranteed_min_lifetime = m_master.oride.cache_guaranteed_min_lifetime; params->oride.cache_guaranteed_max_lifetime = m_master.oride.cache_guaranteed_max_lifetime; - params->oride.cache_max_stale_age = m_master.oride.cache_max_stale_age; + params->oride.cache_max_stale_age = m_master.oride.cache_max_stale_age; + params->oride.cache_max_stale_age_percent = m_master.oride.cache_max_stale_age_percent; params->oride.srv_enabled = m_master.oride.srv_enabled; diff --git a/src/proxy/http/HttpTransact.cc b/src/proxy/http/HttpTransact.cc index 79e9fc5fa26..e638ed14a4c 100644 --- a/src/proxy/http/HttpTransact.cc +++ b/src/proxy/http/HttpTransact.cc @@ -6396,8 +6396,20 @@ HttpTransact::is_stale_cache_response_returnable(State *s) time_t current_age = HttpTransactCache::calculate_document_age(s->cache_info.object_read->request_sent_time_get(), s->cache_info.object_read->response_received_time_get(), cached_response, cached_response->get_date(), s->current.now); + + MgmtInt max_age = get_max_age(cached_response); + MgmtInt max_stale_age = s->txn_conf->cache_max_stale_age; + MgmtInt max_stale_percentage = + std::clamp(s->txn_conf->cache_max_stale_age_percent, static_cast(0), static_cast(100)); + + if (max_stale_percentage > 0 && max_age >= 0) { + MgmtInt percent_max_stale_age = max_age * max_stale_percentage / 100; + + max_stale_age = std::min(max_stale_age, percent_max_stale_age); + } + // Negative age is overflow - if ((current_age < 0) || (current_age > s->txn_conf->cache_max_stale_age + get_max_age(cached_response))) { + if ((current_age < 0) || (current_age > max_age + max_stale_age)) { TxnDbg(dbg_ctl_http_trans, "document age is too large %" PRId64, (int64_t)current_age); return false; } diff --git a/src/records/RecordsConfig.cc b/src/records/RecordsConfig.cc index f011c0ccd74..1eec9cce822 100644 --- a/src/records/RecordsConfig.cc +++ b/src/records/RecordsConfig.cc @@ -633,8 +633,8 @@ static constexpr RecordElement RecordsConfig[] = // # // # 0 - default. disable cache and goto origin // # 1 - return error if cache miss - // # 2 - serve stale until proxy.config.http.cache.max_stale_age, then goto origin, if revalidate - // # 3 - return error if cache miss or serve stale until proxy.config.http.cache.max_stale_age, then goto origin, if revalidate + // # 2 - serve stale within the configured stale age limits, then goto origin, if revalidate + // # 3 - return error if cache miss or serve stale within the configured stale age limits, then goto origin, if revalidate // # 4 - return error if cache miss or if revalidate // # 5 - retry cache read (read-while-writer) on write lock failure, goto origin if retries exhausted // # 6 - retry cache read on write lock failure, if retries exhausted serve stale if allowed, otherwise goto origin @@ -660,6 +660,8 @@ static constexpr RecordElement RecordsConfig[] = , {RECT_CONFIG, "proxy.config.http.cache.max_stale_age", RECD_INT, "604800", RECU_DYNAMIC, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , + {RECT_CONFIG, "proxy.config.http.cache.max_stale_age_percent", RECD_INT, "0", RECU_DYNAMIC, RR_NULL, RECC_INT, "[0-100]", RECA_NULL} + , {RECT_CONFIG, "proxy.config.http.cache.range.lookup", RECD_INT, "1", RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL} , {RECT_CONFIG, "proxy.config.http.cache.range.write", RECD_INT, "0", RECU_NULL, RR_NULL, RECC_NULL, nullptr, RECA_NULL} diff --git a/tests/gold_tests/cache/proxy_serve_stale.test.py b/tests/gold_tests/cache/proxy_serve_stale.test.py index 95e620a2046..3ff64c7af33 100644 --- a/tests/gold_tests/cache/proxy_serve_stale.test.py +++ b/tests/gold_tests/cache/proxy_serve_stale.test.py @@ -23,5 +23,8 @@ # Verify that stale content is served when the parent is down. Test.ATSReplayTest(replay_file="replay/proxy_serve_stale.replay.yaml") +# Verify that stale content respects the percentage maximum stale age. +Test.ATSReplayTest(replay_file="replay/max_stale_age_percent.replay.yaml") + # Verify that stale content is served when the origin server is down. Test.ATSReplayTest(replay_file="replay/proxy_serve_stale_origin_down.replay.yaml") diff --git a/tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml b/tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml new file mode 100644 index 00000000000..f2f0174018d --- /dev/null +++ b/tests/gold_tests/cache/replay/max_stale_age_percent.replay.yaml @@ -0,0 +1,117 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +meta: + version: "1.0" + + blocks: + + - origin_response: &origin_response + server-response: + status: 500 + reason: "Internal Server Error" + headers: + fields: + - [ Content-Length, 16 ] + - [ X-Response, should_not_see ] + +autest: + description: 'Verify the percentage maximum stale age limit' + + dns: + name: 'dns-max-stale-age-percent' + + server: + name: 'server-max-stale-age-percent' + + client: + name: 'client-max-stale-age-percent' + + ats: + name: 'ts-max-stale-age-percent' + process_config: + enable_cache: true + + records_config: + proxy.config.http.push_method_enabled: 1 + proxy.config.http.parent_proxy.fail_threshold: 1 + proxy.config.http.parent_proxy.total_connect_attempts: 1 + proxy.config.http.cache.max_stale_age: 100 + proxy.config.http.cache.max_stale_age_percent: 50 + proxy.config.http.parent_proxy.self_detect: 0 + + parent_config: + - 'dest_domain=. parent="localhost:82" round_robin=consistent_hash go_direct=false' + + remap_config: + - from: "http://example.com/" + to: "http://localhost:{SERVER_HTTP_PORT}/" + +sessions: +- transactions: + + # Populate the cache with a four-second freshness lifetime. + - client-request: + method: "PUSH" + version: "1.1" + url: /percentage + headers: + fields: + - [ Host, example.com ] + - [ uuid, push ] + - [ Content-Length, 73 ] + content: + encoding: plain + data: "HTTP/1.1 200 OK\nContent-Length: 6\nCache-Control: public,max-age=4\n\nCACHED" + + <<: *origin_response + + proxy-response: + status: 201 + + # At five seconds old, the object is stale but remains within the two-second + # percentage stale window. + - client-request: + delay: 5s + method: "GET" + version: "1.1" + url: /percentage + headers: + fields: + - [ Host, example.com ] + - [ uuid, within_percentage_limit ] + + <<: *origin_response + + proxy-response: + status: 200 + + # At eight seconds old, the object exceeds max-age (4 seconds) plus 50% + # (2 seconds), even though the absolute stale age limit is 100 seconds. + - client-request: + delay: 3s + method: "GET" + version: "1.1" + url: /percentage + headers: + fields: + - [ Host, example.com ] + - [ uuid, past_percentage_limit ] + + <<: *origin_response + + proxy-response: + status: 502 diff --git a/tests/gold_tests/records/gold/full_records.yaml b/tests/gold_tests/records/gold/full_records.yaml index 1123e2cbb12..d1502ce4b8e 100644 --- a/tests/gold_tests/records/gold/full_records.yaml +++ b/tests/gold_tests/records/gold/full_records.yaml @@ -163,6 +163,7 @@ records: max_open_read_retries: -1 max_open_write_retries: 1 max_stale_age: 604800 + max_stale_age_percent: 0 open_read_retry_time: 10 open_write_fail_action: 0 post_method: 0 diff --git a/tests/gold_tests/records/legacy_config/full_records.config b/tests/gold_tests/records/legacy_config/full_records.config index d7b640dae32..128ce0c8623 100644 --- a/tests/gold_tests/records/legacy_config/full_records.config +++ b/tests/gold_tests/records/legacy_config/full_records.config @@ -183,6 +183,7 @@ CONFIG proxy.config.http.cache.open_write_fail_action INT 0 CONFIG proxy.config.http.cache.when_to_revalidate INT 0 CONFIG proxy.config.http.cache.required_headers INT 2 CONFIG proxy.config.http.cache.max_stale_age INT 604800 +CONFIG proxy.config.http.cache.max_stale_age_percent INT 0 CONFIG proxy.config.http.cache.range.lookup INT 1 CONFIG proxy.config.http.cache.range.write INT 0 CONFIG proxy.config.http.cache.heuristic_min_lifetime INT 3600 From 1855d199c09650963508d90ecb3296ccab7f36fe Mon Sep 17 00:00:00 2001 From: bneradt Date: Thu, 13 Aug 2026 15:04:40 -0500 Subject: [PATCH 2/2] Clarify stale age percentage behavior The effects of zero and fractional-second percentage limits are not obvious. Operators could mistake zero for disabling stale responses or expect a nonzero percentage always to permit at least one stale second. This clarifies that zero leaves the absolute limit in control and that percentage windows use whole seconds rounded down, with concrete examples of both cases. --- doc/admin-guide/files/records.yaml.en.rst | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/doc/admin-guide/files/records.yaml.en.rst b/doc/admin-guide/files/records.yaml.en.rst index 12a1a4d46c2..f076dabbafb 100644 --- a/doc/admin-guide/files/records.yaml.en.rst +++ b/doc/admin-guide/files/records.yaml.en.rst @@ -2671,8 +2671,17 @@ Cache Control |TS| uses the smaller of this limit and :ts:cv:`proxy.config.http.cache.max_stale_age`. - For example, a value of ``25`` allows a response with a one-hour freshness lifetime to be served - stale for up to 15 minutes, provided that the absolute stale age limit is at least 15 minutes. + For example, a value of ``25`` allows a response with a one-hour freshness lifetime + (``Cache-Control: max-age=3600``) to be served stale for up to 15 minutes (25% of one hour), + provided that the absolute stale age limit is at least 15 minutes. + + Percentage limits are calculated in whole seconds and rounded down. If the calculated limit is + less than one second, no stale window is allowed. For example, a value of ``1`` with + ``Cache-Control: max-age=99`` produces a zero-second stale window. + + A value of ``0`` applies no percentage limit. For example, with the default + :ts:cv:`proxy.config.http.cache.max_stale_age` of seven days, a response with a one-hour + freshness lifetime can be served stale for up to seven days after that freshness lifetime ends. Responses without ``max-age`` are controlled by :ts:cv:`proxy.config.http.cache.max_stale_age` alone.