This is an automated email from the ASF dual-hosted git repository.
shreemaan-abhishek pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new cd5f1ed55 feat(proxy-cache): honor Vary header for memory strategy
(#13376)
cd5f1ed55 is described below
commit cd5f1ed5589e38971172c30756729b883b918a91
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Mon May 25 11:14:01 2026 +0800
feat(proxy-cache): honor Vary header for memory strategy (#13376)
---
apisix/plugins/proxy-cache/memory.lua | 18 +
apisix/plugins/proxy-cache/memory_handler.lua | 259 +++++++++++++-
docs/en/latest/plugins/proxy-cache.md | 6 +-
docs/zh/latest/plugins/proxy-cache.md | 6 +-
t/plugin/proxy-cache/memory.t | 494 ++++++++++++++++++++++++++
5 files changed, 758 insertions(+), 25 deletions(-)
diff --git a/apisix/plugins/proxy-cache/memory.lua
b/apisix/plugins/proxy-cache/memory.lua
index 6d8d8043f..17a0c4022 100644
--- a/apisix/plugins/proxy-cache/memory.lua
+++ b/apisix/plugins/proxy-cache/memory.lua
@@ -73,6 +73,24 @@ function _M:get(key)
end
+-- Like get(), but returns the decoded value even when the entry has expired,
+-- as long as shdict has not yet reclaimed the slot. Intended only for cleanup
+-- paths (purging or rebuilding the Vary index): the value is used to enumerate
+-- keys, never served to a client. Do not use this on the lookup path.
+function _M:get_stale(key)
+ if self.dict == nil then
+ return nil, "invalid cache_zone provided"
+ end
+
+ local res_json, err = self.dict:get_stale(key)
+ if not res_json then
+ return nil, err or "not found"
+ end
+
+ return core.json.decode(res_json)
+end
+
+
function _M:purge(key)
if self.dict == nil then
return nil, "invalid cache_zone provided"
diff --git a/apisix/plugins/proxy-cache/memory_handler.lua
b/apisix/plugins/proxy-cache/memory_handler.lua
index 22d59c5b0..a106d8e09 100644
--- a/apisix/plugins/proxy-cache/memory_handler.lua
+++ b/apisix/plugins/proxy-cache/memory_handler.lua
@@ -23,17 +23,188 @@ local ngx_re_gmatch = ngx.re.gmatch
local ngx_re_match = ngx.re.match
local parse_http_time = ngx.parse_http_time
local concat = table.concat
+local sort = table.sort
+local table_remove = table.remove
local lower = string.lower
local floor = math.floor
local tostring = tostring
local tonumber = tonumber
+local ipairs = ipairs
local ngx = ngx
+local md5 = ngx.md5
local type = type
local pairs = pairs
local time = ngx.now
local max = math.max
-local CACHE_VERSION = 1
+-- Bumped from 1 to 2 for the Vary variant layout.
+local CACHE_VERSION = 2
+local VARY_INDEX_SUFFIX = "::__vary"
+local MAX_VARIANTS = 64
+
+
+-- Parse the upstream Vary header into a canonical list.
+-- Returns:
+-- nil when the value contains `*` anywhere (RFC 9111 §4.1: not
+-- reusable; caller must refuse to cache).
+-- empty table when the header is absent/empty/whitespace-only.
+-- sorted list of lowercased, trimmed header names, otherwise. Sorting
+-- makes the variant signature independent of the order in
+-- which the upstream lists vary headers.
+local function parse_vary_list(vary_value)
+ if not vary_value or vary_value == "" then
+ return {}
+ end
+
+ local result = {}
+ local iter, iter_err = ngx_re_gmatch(vary_value, "([^,]+)", "oj")
+ if not iter then
+ core.log.error("failed to parse Vary header: ", iter_err)
+ return {}
+ end
+
+ for token, _ in iter do
+ local h = token[0]
+ h = h:gsub("^%s+", ""):gsub("%s+$", "")
+ if h == "*" then
+ return nil
+ end
+ if h ~= "" then
+ result[#result + 1] = lower(h)
+ end
+ end
+
+ sort(result)
+ return result
+end
+
+
+-- Hash the request's values for each header in `vary_headers` into a stable
+-- per-variant signature. Missing headers contribute an empty string so the
+-- same request always produces the same signature on store and lookup.
+local function compute_signature(vary_headers, ctx)
+ if not vary_headers or #vary_headers == 0 then
+ return ""
+ end
+
+ local values = tab_new(#vary_headers, 0)
+ for i, h in ipairs(vary_headers) do
+ local var_name = "http_" .. h:gsub("-", "_")
+ values[i] = ctx.var[var_name] or ""
+ end
+ return md5(concat(values, "\0"))
+end
+
+
+local function vary_lists_equal(a, b)
+ if #a ~= #b then
+ return false
+ end
+ for i = 1, #a do
+ if a[i] ~= b[i] then
+ return false
+ end
+ end
+ return true
+end
+
+
+-- Purge every variant entry referenced by the index, then the index itself,
+-- and finally the legacy base-key entry (which may exist if the URL ever
+-- cached a no-Vary response in the past). The index is read stale-tolerant:
+-- it can outlive or be outlived by its variants (variant TTLs diverge when
+-- cache_control derives them per response), and an expired index must still
+-- be usable to enumerate the variant keys it references.
+local function purge_all_variants(memory, base_key)
+ local index_key = base_key .. VARY_INDEX_SUFFIX
+ local index = memory:get_stale(index_key)
+ if index and type(index) == "table" and type(index.variants) == "table"
then
+ for _, sig in ipairs(index.variants) do
+ memory:purge(base_key .. "::" .. sig)
+ end
+ end
+ memory:purge(index_key)
+ memory:purge(base_key)
+end
+
+
+-- Read-modify-write the variant index. If the existing index uses a
+-- different vary header set than this response, we cannot reuse its
+-- variants (their signatures were computed over different headers), so we
+-- purge them and start fresh. Concurrent writers on the same base key may
+-- race; the loser's variant becomes invisible to PURGE but stays reachable
+-- by lookup until its own TTL expires.
+local function update_vary_index(memory, base_key, vary_headers, signature,
ttl)
+ local index_key = base_key .. VARY_INDEX_SUFFIX
+ -- Stale-tolerant: an expired index must still be visible here so its
+ -- variants are either merged into or purged, never silently orphaned.
+ local current = memory:get_stale(index_key)
+
+ local variants
+ if current and type(current) == "table"
+ and current.version == CACHE_VERSION
+ and type(current.vary) == "table"
+ and type(current.variants) == "table"
+ and vary_lists_equal(current.vary, vary_headers) then
+ variants = current.variants
+ local found = false
+ for _, s in ipairs(variants) do
+ if s == signature then
+ found = true
+ break
+ end
+ end
+ if not found then
+ -- Bound the index to MAX_VARIANTS by FIFO-evicting the oldest
+ -- signature and purging its variant entry. Without this, a Vary
+ -- on a high-cardinality header (User-Agent, Cookie) would grow
+ -- the index until it exceeds the shdict slot capacity and
+ -- writes start failing with "no memory".
+ while #variants >= MAX_VARIANTS do
+ local evicted = table_remove(variants, 1)
+ memory:purge(base_key .. "::" .. evicted)
+ end
+ variants[#variants + 1] = signature
+ end
+ else
+ if current and type(current) == "table" and type(current.variants) ==
"table" then
+ for _, sig in ipairs(current.variants) do
+ memory:purge(base_key .. "::" .. sig)
+ end
+ end
+ variants = {signature}
+ end
+
+ local ok, err = memory:set(index_key, {
+ vary = vary_headers,
+ variants = variants,
+ version = CACHE_VERSION,
+ }, ttl)
+ if not ok then
+ core.log.error("failed to update vary index for ", base_key, ", err:
", err)
+ end
+end
+
+
+-- Determine the storage key for the current request. If a valid index
+-- exists for the base key, this request must look up the variant whose
+-- signature matches the request's values for the indexed headers. Index
+-- decode failures (malformed bytes, version mismatch, missing fields) all
+-- fall through to the base key, which then misses and refetches.
+local function lookup_storage_key(memory, base_key, ctx)
+ local index = memory:get(base_key .. VARY_INDEX_SUFFIX)
+ if not index or type(index) ~= "table" then
+ return base_key
+ end
+ if index.version ~= CACHE_VERSION or type(index.vary) ~= "table" then
+ return base_key
+ end
+ if #index.vary == 0 then
+ return base_key
+ end
+ return base_key .. "::" .. compute_signature(index.vary, ctx)
+end
+
local _M = {}
@@ -156,7 +327,21 @@ local function cacheable_request(conf, ctx, cc)
end
-local function cacheable_response(conf, ctx, cc)
+-- Detect a Set-Cookie header in the final response, regardless of its source
+-- (upstream or another plugin) and regardless of casing. header_filter calls
+-- ngx.resp.get_headers with raw=true, so keys keep their original casing and a
+-- plain res_headers["set-cookie"] lookup would miss "Set-Cookie".
+local function response_has_set_cookie(res_headers)
+ for name, value in pairs(res_headers) do
+ if lower(name) == "set-cookie" and value and value ~= "" then
+ return true
+ end
+ end
+ return false
+end
+
+
+local function cacheable_response(conf, ctx, cc, res_headers)
if not util.match_status(conf, ctx) then
return false
end
@@ -183,10 +368,20 @@ local function cacheable_response(conf, ctx, cc)
end
-- Set-Cookie is per-recipient and not safe for a shared cache to store by
- -- default; require explicit opt-in via cache_set_cookie.
- if not conf.cache_set_cookie then
- local set_cookie = ctx.var.upstream_http_set_cookie
- if set_cookie and set_cookie ~= "" then
+ -- default; require explicit opt-in via cache_set_cookie. Inspect the final
+ -- response headers rather than ctx.var.upstream_http_set_cookie so that a
+ -- Set-Cookie injected by another plugin (e.g. api-breaker's
+ -- break_response_headers, workflow) is caught too, not only one emitted by
+ -- the upstream.
+ if not conf.cache_set_cookie and response_has_set_cookie(res_headers) then
+ return false
+ end
+
+ -- Vary: * (RFC 9111 §4.1) means the response is not reusable; refuse to
+ -- cache. parse_vary_list returns nil for that case.
+ if ctx.var.upstream_http_vary then
+ local vary_headers = parse_vary_list(ctx.var.upstream_http_vary)
+ if vary_headers == nil then
return false
end
end
@@ -214,17 +409,27 @@ function _M.access(conf, ctx)
}
end
- local res, err = ctx.cache.memory:get(ctx.var.upstream_cache_key)
+ local base_key = ctx.var.upstream_cache_key
if ctx.var.request_method == "PURGE" then
- if err == "not found" then
+ -- A URL with Vary support has no base-key entry, only variants
+ -- under an index. Treat any of those as a purgeable hit. An
+ -- expired entry (err == "expired") is not a miss: shdict still
+ -- holds the stale slot, so PURGE should clear it and return 200,
+ -- matching the pre-Vary behavior.
+ local _, base_err = ctx.cache.memory:get(base_key)
+ local _, index_err = ctx.cache.memory:get(base_key ..
VARY_INDEX_SUFFIX)
+ if base_err == "not found" and index_err == "not found" then
return 404
end
- ctx.cache.memory:purge(ctx.var.upstream_cache_key)
+ purge_all_variants(ctx.cache.memory, base_key)
ctx.cache = nil
return 200
end
+ local storage_key = lookup_storage_key(ctx.cache.memory, base_key, ctx)
+ local res, err = ctx.cache.memory:get(storage_key)
+
if err then
if err == "expired" then
core.response.set_header("Apisix-Cache-Status", "EXPIRED")
@@ -244,9 +449,9 @@ function _M.access(conf, ctx)
end
if res.version ~= CACHE_VERSION then
- core.log.warn("cache format mismatch, purging ",
ctx.var.upstream_cache_key)
+ core.log.warn("cache format mismatch, purging ", base_key)
core.response.set_header("Apisix-Cache-Status", "BYPASS")
- ctx.cache.memory:purge(ctx.var.upstream_cache_key)
+ purge_all_variants(ctx.cache.memory, base_key)
return
end
@@ -305,7 +510,7 @@ function _M.header_filter(conf, ctx)
local cc = parse_directive_header(ctx.var.upstream_http_cache_control)
- if cacheable_response(conf, ctx, cc) then
+ if cacheable_response(conf, ctx, cc, res_headers) then
cache.res_headers = res_headers
cache.ttl = conf.cache_control and parse_resource_ttl(ctx, cc) or
conf.cache_ttl
else
@@ -325,7 +530,7 @@ function _M.body_filter(conf, ctx)
return
end
- local res = {
+ local entry = {
status = ngx.status,
body = res_body,
body_len = #res_body,
@@ -335,8 +540,32 @@ function _M.body_filter(conf, ctx)
version = CACHE_VERSION,
}
- local res, err = cache.memory:set(ctx.var.upstream_cache_key, res,
cache.ttl)
- if not res then
+ local base_key = ctx.var.upstream_cache_key
+ -- cacheable_response has already filtered out Vary: *, so parse_vary_list
+ -- returns either an empty list (no vary) or the sorted header list.
+ local vary_headers = parse_vary_list(ctx.var.upstream_http_vary) or {}
+ local storage_key
+
+ if #vary_headers > 0 then
+ local signature = compute_signature(vary_headers, ctx)
+ storage_key = base_key .. "::" .. signature
+ update_vary_index(cache.memory, base_key, vary_headers, signature,
cache.ttl)
+ -- Drop any pre-Vary entry stored directly at the base key so future
+ -- lookups never bypass the variant logic.
+ cache.memory:purge(base_key)
+ else
+ -- This response has no Vary, but the URL may have cached a Vary
+ -- response earlier; flush the prior index and its variants to
+ -- prevent stale cross-variant matches.
+ local prior = cache.memory:get(base_key .. VARY_INDEX_SUFFIX)
+ if prior then
+ purge_all_variants(cache.memory, base_key)
+ end
+ storage_key = base_key
+ end
+
+ local ok, err = cache.memory:set(storage_key, entry, cache.ttl)
+ if not ok then
core.log.error("failed to set cache, err: ", err)
end
end
diff --git a/docs/en/latest/plugins/proxy-cache.md
b/docs/en/latest/plugins/proxy-cache.md
index 263fd9ee4..c44e07a3b 100644
--- a/docs/en/latest/plugins/proxy-cache.md
+++ b/docs/en/latest/plugins/proxy-cache.md
@@ -58,11 +58,7 @@ Responses can be conditionally cached based on request HTTP
methods, response st
The plugin always honors upstream `Cache-Control: private`, `no-store`, and
`no-cache` directives — responses carrying any of these are not cached,
regardless of the `cache_control` flag. The `cache_control` flag governs
request-side semantics (client `Cache-Control` request directives such as
`max-age` and `min-fresh`) and TTL derivation from `max-age` / `s-maxage`; it
does not control whether upstream non-cacheability directives are respected.
-:::note
-
-The in-memory caching strategy does not honor the `Vary` response header on
cache lookup. If your upstream emits `Vary: X` and you want APISIX to partition
cache entries by `X`, include `$http_x` in `cache_key` explicitly, or use
`cache_strategy: disk` (NGINX's native cache honors `Vary` correctly).
-
-:::
+Both caching strategies honor the upstream `Vary` response header (RFC 9111
§4.1). Cache entries are partitioned by the request's values for each header
listed in `Vary`, so a response served with `Vary: Accept-Encoding` will not be
served to a request with a different `Accept-Encoding`. Responses with `Vary:
*` are treated as not reusable and are not cached.
## Static Configurations
diff --git a/docs/zh/latest/plugins/proxy-cache.md
b/docs/zh/latest/plugins/proxy-cache.md
index 3ddf230c2..a0ff0111f 100644
--- a/docs/zh/latest/plugins/proxy-cache.md
+++ b/docs/zh/latest/plugins/proxy-cache.md
@@ -58,11 +58,7 @@ import TabItem from '@theme/TabItem';
无论 `cache_control` 标志如何,本插件始终遵循上游响应中的 `Cache-Control: private`、`no-store` 与
`no-cache` 指令——携带其中任一指令的响应不会被缓存。`cache_control` 标志只控制请求侧语义(客户端的
`max-age`、`min-fresh` 等请求指令)以及基于 `max-age` / `s-maxage` 的 TTL
推导,不控制是否遵守上游的不可缓存指令。
-:::note
-
-内存缓存策略在缓存查找时不会处理 `Vary` 响应头。如果上游返回 `Vary: X` 并希望 APISIX 按 `X` 对缓存条目进行分区,请在
`cache_key` 中显式包含 `$http_x`,或使用 `cache_strategy: disk`(NGINX 原生缓存可以正确处理 `Vary`)。
-
-:::
+两种缓存策略都会遵循上游响应中的 `Vary` 响应头(RFC 9111 §4.1)。缓存条目会按请求中 `Vary`
列出的各个头部的值进行分区,因此一个带有 `Vary: Accept-Encoding` 的响应不会被返回给具有不同 `Accept-Encoding`
的请求。`Vary: *` 的响应被视为不可复用,不会被缓存。
## 静态配置
diff --git a/t/plugin/proxy-cache/memory.t b/t/plugin/proxy-cache/memory.t
index d9460ebce..1148d5b82 100644
--- a/t/plugin/proxy-cache/memory.t
+++ b/t/plugin/proxy-cache/memory.t
@@ -62,6 +62,41 @@ add_block_preprocessor(sub {
}
}
+ location = /vary-encoding {
+ content_by_lua_block {
+ local enc = ngx.var.http_accept_encoding or "none"
+ ngx.header["Vary"] = "Accept-Encoding"
+ ngx.say("encoding=", enc)
+ }
+ }
+
+ location = /vary-multi {
+ content_by_lua_block {
+ local enc = ngx.var.http_accept_encoding or "none"
+ local lang = ngx.var.http_accept_language or "none"
+ ngx.header["Vary"] = "Accept-Encoding, Accept-Language"
+ ngx.say("enc=", enc, ";lang=", lang)
+ }
+ }
+
+ location = /vary-star {
+ content_by_lua_block {
+ ngx.header["Vary"] = "*"
+ ngx.update_time()
+ ngx.say("starred=", ngx.now())
+ }
+ }
+
+ location = /vary-ttl {
+ content_by_lua_block {
+ local maxage = ngx.var.arg_maxage or "60"
+ ngx.header["Vary"] = "Accept-Encoding"
+ ngx.header["Cache-Control"] = "max-age=" .. maxage
+ local enc = ngx.var.http_accept_encoding or "none"
+ ngx.say("ttl-enc=", enc)
+ }
+ }
+
location / {
expires 60s;
@@ -80,6 +115,10 @@ add_block_preprocessor(sub {
location /hello-not-found {
return 404;
}
+
+ location = /server-error {
+ return 500;
+ }
}
_EOC_
@@ -1060,3 +1099,458 @@ alice_1=MISS
alice_2=HIT
bob_1=MISS
bob_2=HIT
+
+
+
+=== TEST 41: Vary: Accept-Encoding partitions cache entries
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body = t('/apisix/admin/routes/proxy-cache-vary-enc',
ngx.HTTP_PUT, [[{
+ "uri": "/vary-encoding",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/vary-encoding"
+
+ local function fetch(enc)
+ local res, err = http.new():request_uri(uri, {
+ headers = { ["Accept-Encoding"] = enc },
+ })
+ if not res then return nil, err end
+ local body = res.body and res.body:gsub("%s+$", "") or ""
+ return res.headers["Apisix-Cache-Status"], body
+ end
+
+ local gzip_1, gzip_body_1 = fetch("gzip")
+ local gzip_2, gzip_body_2 = fetch("gzip")
+ local id_1, id_body_1 = fetch("identity")
+ local id_2, id_body_2 = fetch("identity")
+
+ ngx.say("gzip_1=", gzip_1, " body=", gzip_body_1)
+ ngx.say("gzip_2=", gzip_2, " body=", gzip_body_2)
+ ngx.say("id_1=", id_1, " body=", id_body_1)
+ ngx.say("id_2=", id_2, " body=", id_body_2)
+ }
+ }
+--- request
+GET /t
+--- response_body
+gzip_1=MISS body=encoding=gzip
+gzip_2=HIT body=encoding=gzip
+id_1=MISS body=encoding=identity
+id_2=HIT body=encoding=identity
+
+
+
+=== TEST 42: Vary: * refuses to cache
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body = t('/apisix/admin/routes/proxy-cache-vary-star',
ngx.HTTP_PUT, [[{
+ "uri": "/vary-star",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/vary-star"
+
+ local first = http.new():request_uri(uri)
+ ngx.sleep(0.01)
+ local second = http.new():request_uri(uri)
+ ngx.say("first=", first.headers["Apisix-Cache-Status"])
+ ngx.say("second=", second.headers["Apisix-Cache-Status"])
+ ngx.say("differ=", tostring(first.body ~= second.body))
+ }
+ }
+--- request
+GET /t
+--- response_body
+first=MISS
+second=MISS
+differ=true
+
+
+
+=== TEST 43: Vary list with multiple headers (order-independent signature)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body =
t('/apisix/admin/routes/proxy-cache-vary-multi', ngx.HTTP_PUT, [[{
+ "uri": "/vary-multi",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/vary-multi"
+
+ local function fetch(enc, lang)
+ local res = http.new():request_uri(uri, {
+ headers = {
+ ["Accept-Encoding"] = enc,
+ ["Accept-Language"] = lang,
+ },
+ })
+ local body = res.body and res.body:gsub("%s+$", "") or ""
+ return res.headers["Apisix-Cache-Status"], body
+ end
+
+ local a1, ab1 = fetch("gzip", "en")
+ local a2, ab2 = fetch("gzip", "en")
+ local b1, bb1 = fetch("gzip", "fr")
+ local c1, cb1 = fetch("br", "en")
+
+ ngx.say("a1=", a1, " body=", ab1)
+ ngx.say("a2=", a2, " body=", ab2)
+ ngx.say("b1=", b1, " body=", bb1)
+ ngx.say("c1=", c1, " body=", cb1)
+ }
+ }
+--- request
+GET /t
+--- response_body
+a1=MISS body=enc=gzip;lang=en
+a2=HIT body=enc=gzip;lang=en
+b1=MISS body=enc=gzip;lang=fr
+c1=MISS body=enc=br;lang=en
+
+
+
+=== TEST 44: PURGE clears every variant under the base key
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body =
t('/apisix/admin/routes/proxy-cache-vary-purge', ngx.HTTP_PUT, [[{
+ "uri": "/vary-encoding",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/vary-encoding"
+
+ local function fetch(enc)
+ local res = http.new():request_uri(uri, {
+ headers = { ["Accept-Encoding"] = enc },
+ })
+ return res.headers["Apisix-Cache-Status"]
+ end
+
+ -- prime two variants
+ fetch("gzip")
+ fetch("identity")
+
+ -- both warm
+ local hot_gzip = fetch("gzip")
+ local hot_id = fetch("identity")
+
+ -- purge once should wipe all variants
+ local purge = http.new():request_uri(uri, { method = "PURGE" })
+
+ local cold_gzip = fetch("gzip")
+ local cold_id = fetch("identity")
+
+ ngx.say("hot_gzip=", hot_gzip)
+ ngx.say("hot_id=", hot_id)
+ ngx.say("purge=", purge.status)
+ ngx.say("cold_gzip=", cold_gzip)
+ ngx.say("cold_id=", cold_id)
+ }
+ }
+--- request
+GET /t
+--- response_body
+hot_gzip=HIT
+hot_id=HIT
+purge=200
+cold_gzip=MISS
+cold_id=MISS
+
+
+
+=== TEST 45: PURGE deletes an expired entry and returns 200, not 404
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body =
t('/apisix/admin/routes/proxy-cache-purge-expired', ngx.HTTP_PUT, [[{
+ "uri": "/hello",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 1
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+
+ local miss = http.new():request_uri(uri)
+ local hit = http.new():request_uri(uri)
+ ngx.sleep(1.5)
+ local purge = http.new():request_uri(uri, { method = "PURGE" })
+ local after = http.new():request_uri(uri)
+
+ ngx.say("miss=", miss.headers["Apisix-Cache-Status"])
+ ngx.say("hit=", hit.headers["Apisix-Cache-Status"])
+ ngx.say("purge=", purge.status)
+ ngx.say("after=", after.headers["Apisix-Cache-Status"])
+ }
+ }
+--- request
+GET /t
+--- response_body
+miss=MISS
+hit=HIT
+purge=200
+after=MISS
+
+
+
+=== TEST 46: proxy-cache refuses to cache a plugin-generated Set-Cookie
(api-breaker break response)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body =
t('/apisix/admin/routes/proxy-cache-breaker-cookie', ngx.HTTP_PUT, [[{
+ "uri": "/server-error",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_ttl": 300
+ },
+ "api-breaker": {
+ "break_response_code": 200,
+ "break_response_body": "breaker-open",
+ "break_response_headers": [
+ {"key": "Set-Cookie", "value": "poisoned=attacker;
Path=/"}
+ ],
+ "max_breaker_sec": 60,
+ "unhealthy": {"http_statuses": [500], "failures": 1},
+ "healthy": {"http_statuses": [200], "successes": 3}
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port ..
"/server-error"
+
+ -- First request hits the upstream 500 and trips the breaker.
+ http.new():request_uri(uri)
+
+ -- Breaker is now open: api-breaker short-circuits in the access
+ -- phase with a 200 carrying a plugin-generated Set-Cookie. That
+ -- cookie did not come from the upstream, so ctx.var
+ -- .upstream_http_set_cookie is empty; the plugin must still refuse
+ -- to cache it. The victim request must therefore be a MISS.
+ local poison = http.new():request_uri(uri)
+ local victim = http.new():request_uri(uri)
+
+ ngx.say("poison_status=", poison.status)
+ ngx.say("poison_cache=", poison.headers["Apisix-Cache-Status"])
+ ngx.say("poison_cookie=", poison.headers["Set-Cookie"])
+ ngx.say("victim_status=", victim.status)
+ ngx.say("victim_cache=", victim.headers["Apisix-Cache-Status"])
+ ngx.say("victim_cookie=", victim.headers["Set-Cookie"])
+ }
+ }
+--- request
+GET /t
+--- response_body
+poison_status=200
+poison_cache=MISS
+poison_cookie=poisoned=attacker; Path=/
+victim_status=200
+victim_cache=MISS
+victim_cookie=poisoned=attacker; Path=/
+
+
+
+=== TEST 47: PURGE clears variants even after the Vary index has expired
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+
+ local code, body =
t('/apisix/admin/routes/proxy-cache-vary-expired-index', ngx.HTTP_PUT, [[{
+ "uri": "/vary-ttl",
+ "plugins": {
+ "proxy-cache": {
+ "cache_strategy": "memory",
+ "cache_key": ["$host", "$uri"],
+ "cache_zone": "memory_cache",
+ "cache_method": ["GET"],
+ "cache_http_status": [200],
+ "cache_control": true
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1986": 1
+ },
+ "type": "roundrobin"
+ }
+ }]])
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+
+ local base = "http://127.0.0.1:" .. ngx.var.server_port ..
"/vary-ttl"
+
+ -- variant A: long max-age, stays alive well past the test
+ http.new():request_uri(base .. "?maxage=100", {
+ headers = { ["Accept-Encoding"] = "gzip" },
+ })
+ -- variant B: short max-age; update_vary_index rewrites the index
+ -- with this 1s TTL, so the index now expires before variant A.
+ http.new():request_uri(base .. "?maxage=1", {
+ headers = { ["Accept-Encoding"] = "identity" },
+ })
+
+ -- let the index (and variant B) expire while variant A lives on
+ ngx.sleep(1.5)
+
+ local purge = http.new():request_uri(base, { method = "PURGE" })
+
+ -- with a stale-blind index read, variant A's entry would survive
+ -- this PURGE as an orphan; count anything left under the base key
+ local leftover = 0
+ for _, k in ipairs(ngx.shared.memory_cache:get_keys(0)) do
+ if k:find("/vary-ttl", 1, true) then
+ leftover = leftover + 1
+ end
+ end
+
+ ngx.say("purge=", purge.status)
+ ngx.say("leftover=", leftover)
+ }
+ }
+--- request
+GET /t
+--- response_body
+purge=200
+leftover=0