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


Reply via email to