This is an automated email from the ASF dual-hosted git repository.
AlinsRan 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 7c7c8f9f4 perf(limit-count): cache parsed templates, local limiters
and header names (#14141)
7c7c8f9f4 is described below
commit 7c7c8f9f4dc3af86aa62f8ce64119908a6366e41
Author: AlinsRan <[email protected]>
AuthorDate: Thu Nov 5 17:20:38 2026 +0800
perf(limit-count): cache parsed templates, local limiters and header names
(#14141)
---
apisix/core/utils.lua | 112 +++++++++----
apisix/plugins/limit-count/init.lua | 60 +++++--
apisix/plugins/proxy-rewrite.lua | 3 +-
t/core/utils.t | 219 ++++++++++++++++++++++++
t/plugin/limit-count-rules.t | 325 ++++++++++++++++++++++++++++++++++++
t/plugin/proxy-rewrite3.t | 61 +++++++
6 files changed, 732 insertions(+), 48 deletions(-)
diff --git a/apisix/core/utils.lua b/apisix/core/utils.lua
index fd488047c..6df41b921 100644
--- a/apisix/core/utils.lua
+++ b/apisix/core/utils.lua
@@ -28,6 +28,8 @@ local json = require("apisix.core.json")
local string = require("apisix.core.string")
local dns_client = require("apisix.core.dns.client")
local ngx_re = require("ngx.re")
+local lrucache = require("resty.lrucache.pureffi")
+local tablepool = require("tablepool")
local ipmatcher = require("resty.ipmatcher")
local ffi = require("ffi")
local base = require("resty.core.base")
@@ -49,6 +51,7 @@ local exiting = ngx.worker.exiting
local ngx_sleep = ngx.sleep
local ipairs = ipairs
local str_format = string.format
+local tab_concat = table.concat
local ngx_now = ngx.now
local ngx_update_time = ngx.update_time
local math_random = math.random
@@ -312,36 +315,59 @@ _M.sleep = sleep
local resolve_var
do
- local _ctx
- local n_resolved
local pat = [[(?<!\\)\$(\{\s*([^}]+?)\s*\}|([\w\.]+))]]
- local _escaper
+ -- pureffi: once full, resty.lrucache rehashes its key table on most
+ -- evictions, so a configuration with more templates than fit would pay
+ -- for a rehash on most calls
+ local tpl_cache = lrucache.new(1024)
+
+ -- Split a template into literal strings and variables once, so resolving
it
+ -- is a table walk instead of a regex gsub with a callback.
+ local function parse(tpl)
+ local segs = {}
+ local m_ctx = {pos = 1}
+ local last = 1
+ while true do
+ local m, err = re_match(tpl, pat, "jo", m_ctx)
+ if err then
+ return nil, err
+ end
+ if not m then
+ break
+ end
- local function resolve(m)
- local variable = m[2] or m[3]
- -- handle the default value with ?? operator
- local segs, err = ngx_re.split(variable, [[\s*\?\?\s*]])
- if not segs then
- log.error("failed to split variable ", variable, ": ", err)
- return ""
- end
- local v = _ctx[segs[1]]
+ local from = m_ctx.pos - #m[0]
+ if from > last then
+ segs[#segs + 1] = sub_str(tpl, last, from - 1)
+ end
- if v == nil then
- if #segs == 1 then
- return ""
+ local variable = m[2] or m[3]
+ local seg = {name = variable}
+ -- handle the default value with ?? operator
+ if core_str.find(variable, "??") then
+ local parts, err = ngx_re.split(variable, [[\s*\?\?\s*]], "jo")
+ if not parts then
+ return nil, err
+ end
+ seg.name = parts[1]
+ -- "${a??}" splits into {"a"}, which means no default
+ seg.has_default = #parts ~= 1
+ seg.default = parts[2]
end
- v = segs[2]
+ segs[#segs + 1] = seg
+ last = m_ctx.pos
end
- n_resolved = n_resolved + 1
- if _escaper then
- return _escaper(tostring(v))
+
+ if last <= #tpl then
+ segs[#segs + 1] = sub_str(tpl, last)
end
- return tostring(v)
+ return segs
end
- function resolve_var(tpl, ctx, escaper)
- n_resolved = 0
+ -- no_cache: the template is built per request, so caching it would only
+ -- evict the configured templates
+ function resolve_var(tpl, ctx, escaper, no_cache)
+ local n_resolved = 0
if not tpl then
return tpl, nil, n_resolved
end
@@ -351,16 +377,42 @@ do
return tpl, nil, n_resolved
end
- -- avoid creating temporary function
- _ctx = ctx
- _escaper = escaper
- local res, _, err = re_gsub(tpl, pat, resolve, "jo")
- _ctx = nil
- _escaper = nil
- if not res then
- return nil, err
+ local segs = not no_cache and tpl_cache:get(tpl)
+ if not segs then
+ local err
+ segs, err = parse(tpl)
+ if not segs then
+ return nil, err
+ end
+ if not no_cache then
+ tpl_cache:set(tpl, segs)
+ end
+ end
+
+ local n = #segs
+ local buf = tablepool.fetch("resolve_var", n, 0)
+ for i = 1, n do
+ local seg = segs[i]
+ if type(seg) == "table" then
+ local v = ctx[seg.name]
+ if v == nil and not seg.has_default then
+ seg = ""
+ else
+ if v == nil then
+ v = seg.default
+ end
+ n_resolved = n_resolved + 1
+ seg = tostring(v)
+ if escaper then
+ seg = tostring(escaper(seg))
+ end
+ end
+ end
+ buf[i] = seg
end
+ local res = tab_concat(buf, "", 1, n)
+ tablepool.release("resolve_var", buf)
return res, nil, n_resolved
end
end
diff --git a/apisix/plugins/limit-count/init.lua
b/apisix/plugins/limit-count/init.lua
index 721586252..111ccc77f 100644
--- a/apisix/plugins/limit-count/init.lua
+++ b/apisix/plugins/limit-count/init.lua
@@ -27,6 +27,7 @@ local get_phase = ngx.get_phase
local ngx_now = ngx.now
local math_floor = math.floor
local str_format = string.format
+local setmetatable = setmetatable
local NO_DELAYED_SYNC = -1
-- Redis counter storage-format version, passed into the Redis backends and
@@ -58,6 +59,9 @@ local group_conf_lru = core.lrucache.new({
})
local group_limit_lru = core.lrucache.new({type = 'plugin'})
local lrucache = core.lrucache.new({type = 'plugin', serial_creating = true})
+-- pureffi: once full, resty.lrucache rehashes its key table on most evictions
+local limit_headers_lru = require("resty.lrucache.pureffi").new(1024)
+local limit_key_prefix = setmetatable({}, {__mode = "k"})
policy_to_additional_properties["redis-sentinel"] = {
properties = {
@@ -372,8 +376,12 @@ local function gen_limit_key(conf, ctx, key)
return nil
end
- local new_key = parent.resource_key .. ':' ..
apisix_plugin.conf_version(conf)
- .. ':' .. key
+ local prefix = limit_key_prefix[conf]
+ if not prefix then
+ prefix = parent.resource_key .. ':' ..
apisix_plugin.conf_version(conf) .. ':'
+ limit_key_prefix[conf] = prefix
+ end
+ local new_key = prefix .. key
if conf._vid then
-- conf has _vid means it's from workflow plugin, add _vid to the key
-- so that the counter is unique per action.
@@ -467,28 +475,46 @@ end
-local function construct_rate_limiting_headers(conf, rule, metadata)
- if rule.header_prefix then
- local base_limit = conf.limit_header or metadata.limit_header
- local base_remaining = conf.remaining_header or
metadata.remaining_header
- local base_reset = conf.reset_header or metadata.reset_header
- -- Insert rule prefix before "RateLimit-" to preserve any custom
header base
- -- e.g. "X-AI-RateLimit-Limit" + prefix "1" -> "X-AI-1-RateLimit-Limit"
- local prefix = tostring(rule.header_prefix)
+local function gen_limit_headers(prefix, base_limit, base_remaining,
base_reset)
+ if not prefix then
return {
- limit_header = base_limit:gsub("RateLimit%-", prefix ..
"-RateLimit-", 1),
- remaining_header = base_remaining:gsub("RateLimit%-", prefix ..
"-RateLimit-", 1),
- reset_header = base_reset:gsub("RateLimit%-", prefix ..
"-RateLimit-", 1),
+ limit_header = base_limit,
+ remaining_header = base_remaining,
+ reset_header = base_reset,
}
end
- return {
- limit_header = conf.limit_header or metadata.limit_header,
- remaining_header = conf.remaining_header or metadata.remaining_header,
- reset_header = conf.reset_header or metadata.reset_header,
+
+ -- Insert rule prefix before "RateLimit-" to preserve any custom header
base
+ -- e.g. "X-AI-RateLimit-Limit" + prefix "1" -> "X-AI-1-RateLimit-Limit"
+ local repl = prefix .. "-RateLimit-"
+ return {
+ limit_header = base_limit:gsub("RateLimit%-", repl, 1),
+ remaining_header = base_remaining:gsub("RateLimit%-", repl, 1),
+ reset_header = base_reset:gsub("RateLimit%-", repl, 1),
}
end
+local function construct_rate_limiting_headers(conf, rule, metadata)
+ local base_limit = conf.limit_header or metadata.limit_header
+ local base_remaining = conf.remaining_header or metadata.remaining_header
+ local base_reset = conf.reset_header or metadata.reset_header
+ local key = base_limit .. "\0" .. base_remaining .. "\0" .. base_reset
+ local prefix
+ if rule.header_prefix then
+ prefix = tostring(rule.header_prefix)
+ key = prefix .. "\0" .. key
+ end
+
+ local headers = limit_headers_lru:get(key)
+ if not headers then
+ headers = gen_limit_headers(prefix, base_limit, base_remaining,
base_reset)
+ limit_headers_lru:set(key, headers)
+ end
+ return headers
+end
+
+
-- Collects what $rate_limiting_info reports about the window this request was
-- counted in. `info` is what the limiter returned next to the decision; the
-- request's own figures are added to it.
diff --git a/apisix/plugins/proxy-rewrite.lua b/apisix/plugins/proxy-rewrite.lua
index 29e8f5f02..90e662b3e 100644
--- a/apisix/plugins/proxy-rewrite.lua
+++ b/apisix/plugins/proxy-rewrite.lua
@@ -302,7 +302,8 @@ do
local function resolve_header_value(value, ctx)
local val = core.utils.resolve_var_with_captures(value,
ctx.proxy_rewrite_regex_uri_captures)
- return core.utils.resolve_var(val, ctx.var)
+ -- a value with captures substituted is a new template per request
+ return core.utils.resolve_var(val, ctx.var, nil, val ~= value)
end
diff --git a/t/core/utils.t b/t/core/utils.t
index 638643ff1..b0897ad8f 100644
--- a/t/core/utils.t
+++ b/t/core/utils.t
@@ -453,3 +453,222 @@ hostname cached: true
hostname valid: true
system hostname: .+
hostname match: true
+
+
+
+=== TEST 14: resolve_var reports how many variables it resolved
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local ctx = {you = "John", me = "David", num = 42, f = false}
+ local cases = {
+ "$you and $me",
+ "${nobody}",
+ "${nobody??Rose}",
+ "${nobody??}",
+ "${ nobody ?? 0 }",
+ "$you$nobody$me",
+ "\\$me",
+ "no vars",
+ "$num",
+ "$f",
+ }
+ for _, case in ipairs(cases) do
+ local res, err, n = resolve_var(case, ctx)
+ ngx.say(case, " => ", res, " ", n, " ", tostring(err))
+ end
+ }
+ }
+--- request
+GET /t
+--- response_body
+$you and $me => John and David 2 nil
+${nobody} => 0 nil
+${nobody??Rose} => Rose 1 nil
+${nobody??} => 0 nil
+${ nobody ?? 0 } => 0 1 nil
+$you$nobody$me => JohnDavid 2 nil
+\$me => \$me 0 nil
+no vars => no vars 0 nil
+$num => 42 1 nil
+$f => false 1 nil
+
+
+
+=== TEST 15: resolve_var applies the escaper to every resolved value
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local ctx = {sp = "x y", sl = "/"}
+ local res, err, n = resolve_var("$sp-${sl}-${none??a b}", ctx,
ngx.escape_uri)
+ ngx.say(res, " ", n, " ", tostring(err))
+ res, err, n = resolve_var("$sp-$sl", ctx, function(v)
+ return v == "/" and 1 or nil
+ end)
+ ngx.say(res, " ", n, " ", tostring(err))
+ }
+ }
+--- request
+GET /t
+--- response_body
+x%20y-%2F-a%20b 3 nil
+nil-1 2 nil
+
+
+
+=== TEST 16: resolve_var reuses a template with different values on every call
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local tpl = "${host}:${uri}"
+ for i = 1, 3 do
+ local res, _, n = resolve_var(tpl, {host = "h" .. i, uri =
"/u" .. i})
+ ngx.say(res, " ", n)
+ end
+ local res, _, n = resolve_var(tpl, {host = "h4"})
+ ngx.say(res, " ", n)
+ }
+ }
+--- request
+GET /t
+--- response_body
+h1:/u1 2
+h2:/u2 2
+h3:/u3 2
+h4: 1
+
+
+
+=== TEST 17: a variable getter may resolve another template while it is called
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local ctx = setmetatable({}, {__index = function(_, key)
+ if key == "outer" then
+ return (resolve_var("in-$inner", {inner = "x"}))
+ end
+ if key == "b" then
+ return "B"
+ end
+ end})
+ local res, err, n = resolve_var("$outer/$b", ctx)
+ ngx.say(res, " ", n, " ", tostring(err))
+ }
+ }
+--- request
+GET /t
+--- response_body
+in-x/B 2 nil
+
+
+
+=== TEST 18: resolve_var keeps working after a variable getter raises an error
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local ctx = setmetatable({}, {__index = function(_, key)
+ if key == "boom" then
+ error("getter failed")
+ end
+ if key == "outer" then
+ return (resolve_var("in-$inner", {inner = "x"}))
+ end
+ return key == "b" and "B" or nil
+ end})
+ local ok = pcall(resolve_var, "keep-$b/$boom", ctx)
+ ngx.say("raised: ", not ok)
+ for _ = 1, 2 do
+ local res, err, n = resolve_var("$outer/$b", ctx)
+ ngx.say(res, " ", n, " ", tostring(err))
+ end
+ }
+ }
+--- request
+GET /t
+--- response_body
+raised: true
+in-x/B 2 nil
+in-x/B 2 nil
+
+
+
+=== TEST 19: resolve_var with no_cache resolves the template without caching it
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local tpl_cache
+ for i = 1, math.huge do
+ local name, value = debug.getupvalue(resolve_var, i)
+ if not name then
+ break
+ end
+ if name == "tpl_cache" then
+ tpl_cache = value
+ break
+ end
+ end
+
+ local res, err, n = resolve_var("once-$a", {a = "x"}, nil, true)
+ ngx.say(res, " ", n, " ", tostring(err))
+ ngx.say("cached: ", tpl_cache:get("once-$a") ~= nil)
+
+ res = resolve_var("kept-$a", {a = "x"})
+ ngx.say(res)
+ ngx.say("cached: ", tpl_cache:get("kept-$a") ~= nil)
+ }
+ }
+--- request
+GET /t
+--- response_body
+once-x 1 nil
+cached: false
+kept-x
+cached: true
+
+
+
+=== TEST 20: resolve_var stays correct with more templates than its cache holds
+--- config
+ location /t {
+ content_by_lua_block {
+ local resolve_var = require("apisix.core.utils").resolve_var
+ local tpl_cache
+ for i = 1, math.huge do
+ local name, value = debug.getupvalue(resolve_var, i)
+ if not name then
+ break
+ end
+ if name == "tpl_cache" then
+ tpl_cache = value
+ break
+ end
+ end
+
+ -- the second pass resolves templates the first one evicted
+ local bad = 0
+ for pass = 1, 2 do
+ local ctx = {uri = "/p" .. pass}
+ for i = 1, 3000 do
+ local res, err, n = resolve_var("/svc-" .. i .. "${uri}",
ctx)
+ if res ~= "/svc-" .. i .. "/p" .. pass or err or n ~= 1
then
+ bad = bad + 1
+ end
+ end
+ end
+ ngx.say("mismatches: ", bad)
+ ngx.say("first template evicted: ", tpl_cache:get("/svc-1${uri}")
== nil)
+ ngx.say("last template cached: ", tpl_cache:get("/svc-3000${uri}")
~= nil)
+ }
+ }
+--- request
+GET /t
+--- response_body
+mismatches: 0
+first template evicted: true
+last template cached: true
diff --git a/t/plugin/limit-count-rules.t b/t/plugin/limit-count-rules.t
index a2bee51f0..9d30d07e3 100644
--- a/t/plugin/limit-count-rules.t
+++ b/t/plugin/limit-count-rules.t
@@ -649,3 +649,328 @@ GET /hello
x-user: testuser
x-project: myproject
--- error_code: 200
+
+
+
+=== TEST 23: setup routes whose rule resolves its count per request
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local conf = [[{
+ "methods": ["GET"],
+ "plugins": {
+ "limit-count": {
+ "rejected_code": 503,
+ "rules": [
+ {
+ "count": "${http_count ?? 3}",
+ "time_window": 60,
+ "key": "${http_x_user}"
+ }
+ ]
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "%s"
+ }]]
+ for id, uri in pairs({["1"] = "/hello", ["2"] = "/hello1"}) do
+ local code, body = t('/apisix/admin/routes/' .. id,
ngx.HTTP_PUT,
+ string.format(conf, uri))
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 24: the same key limited under counts that change per request
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local base = "http://127.0.0.1:" .. ngx.var.server_port
+ local function hit(uri, user, count)
+ local httpc = http.new()
+ local res, err = httpc:request_uri(base .. uri, {
+ headers = {["X-User"] = user, ["Count"] = count},
+ })
+ if not res then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say(uri, " ", user, " ", count or "-", ": ", res.status, "
",
+ res.headers["X-1-RateLimit-Limit"], " ",
+ res.headers["X-1-RateLimit-Remaining"])
+ end
+ hit("/hello", "u24", "3")
+ hit("/hello", "u24", "5")
+ hit("/hello", "u24", "3")
+ hit("/hello", "u24", nil)
+ hit("/hello", "u24", "5")
+ hit("/hello", "u24", "5")
+ hit("/hello", "other24", "3")
+ }
+ }
+--- response_body
+/hello u24 3: 200 3 2
+/hello u24 5: 200 5 3
+/hello u24 3: 200 3 0
+/hello u24 -: 503 3 0
+/hello u24 5: 200 5 0
+/hello u24 5: 503 5 0
+/hello other24 3: 200 3 2
+
+
+
+=== TEST 25: a count that fails to resolve rejects only that request
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local base = "http://127.0.0.1:" .. ngx.var.server_port
+ for _, count in ipairs({"abc", "2", "-1", "2", "2"}) do
+ local httpc = http.new()
+ local res, err = httpc:request_uri(base .. "/hello", {
+ headers = {["X-User"] = "u25", ["Count"] = count},
+ })
+ if not res then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say(count, ": ", res.status, " ",
+ res.headers["X-1-RateLimit-Remaining"])
+ end
+ }
+ }
+--- response_body
+abc: 500 nil
+2: 200 1
+-1: 500 nil
+2: 200 0
+2: 503 0
+--- error_log
+failed to get rate limit rules: resolved value is not a number
+failed to get rate limit rules: resolved value must be a positive number
+
+
+
+=== TEST 26: routes with the same rule shape keep separate counters
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local base = "http://127.0.0.1:" .. ngx.var.server_port
+ for _, uri in ipairs({"/hello", "/hello1", "/hello", "/hello1",
"/hello"}) do
+ local httpc = http.new()
+ local res, err = httpc:request_uri(base .. uri, {
+ headers = {["X-User"] = "u26", ["Count"] = "2"},
+ })
+ if not res then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say(uri, ": ", res.status, " ",
res.headers["X-1-RateLimit-Remaining"])
+ end
+ }
+ }
+--- response_body
+/hello: 200 1
+/hello1: 200 1
+/hello: 200 0
+/hello1: 200 0
+/hello: 503 0
+
+
+
+=== TEST 27: changing the plugin configuration starts a new counter
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local http = require("resty.http")
+ local base = "http://127.0.0.1:" .. ngx.var.server_port
+ local function hit()
+ local httpc = http.new()
+ local res, err = httpc:request_uri(base .. "/hello", {
+ headers = {["X-User"] = "u27", ["Count"] = "1"},
+ })
+ if not res then
+ return "failed: " .. err
+ end
+ return res.status
+ end
+ ngx.say(hit(), " ", hit())
+
+ local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PATCH, [[{
+ "plugins": {
+ "limit-count": {
+ "rejected_code": 429,
+ "rules": [
+ {
+ "count": "${http_count ?? 3}",
+ "time_window": 60,
+ "key": "${http_x_user}"
+ }
+ ]
+ }
+ }
+ }]])
+ if code >= 300 then
+ ngx.say(body)
+ return
+ end
+ ngx.sleep(0.5)
+ ngx.say(hit(), " ", hit())
+ }
+ }
+--- response_body
+200 503
+200 429
+
+
+
+=== TEST 28: rule header names follow a plugin metadata change
+--- 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/1', ngx.HTTP_PUT, [[{
+ "plugins": {
+ "limit-count": {
+ "rejected_code": 503,
+ "rules": [
+ {
+ "count": 5,
+ "time_window": 60,
+ "key": "${http_x_user}",
+ "header_prefix": "jack"
+ }
+ ]
+ }
+ },
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "uri": "/hello"
+ }]])
+ if code >= 300 then
+ ngx.say(body)
+ return
+ end
+ ngx.sleep(0.5)
+
+ local function hit()
+ local httpc = http.new()
+ local res, err = httpc:request_uri(
+ "http://127.0.0.1:" .. ngx.var.server_port .. "/hello",
+ {headers = {["X-User"] = "u28"}})
+ if not res then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say(res.headers["X-jack-RateLimit-Remaining"], " ",
+ res.headers["APISIX-jack-RateLimit-Remaining"])
+ end
+ hit()
+
+ code, body = t('/apisix/admin/plugin_metadata/limit-count',
+ ngx.HTTP_PUT, [[{
+ "limit_header": "APISIX-RateLimit-Limit",
+ "remaining_header": "APISIX-RateLimit-Remaining",
+ "reset_header": "APISIX-RateLimit-Reset"
+ }]])
+ if code >= 300 then
+ ngx.say(body)
+ return
+ end
+ ngx.sleep(0.5)
+ hit()
+
+ code, body = t('/apisix/admin/plugin_metadata/limit-count',
+ ngx.HTTP_DELETE)
+ if code >= 300 then
+ ngx.say(body)
+ return
+ end
+ ngx.sleep(0.5)
+ hit()
+ }
+ }
+--- response_body
+4 nil
+nil 3
+2 nil
+
+
+
+=== TEST 29: a count resolved per request without rules is enforced per value
+--- 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/1', ngx.HTTP_PUT, [[{
+ "plugins": {
+ "limit-count": {
+ "count": "${http_count ?? 3}",
+ "time_window": 60,
+ "rejected_code": 503,
+ "key_type": "var",
+ "key": "http_x_user"
+ }
+ },
+ "upstream": {
+ "nodes": {"127.0.0.1:1980": 1},
+ "type": "roundrobin"
+ },
+ "uri": "/hello"
+ }]])
+ if code >= 300 then
+ ngx.say(body)
+ return
+ end
+ ngx.sleep(0.5)
+
+ local function hit(user, count)
+ local httpc = http.new()
+ local res, err = httpc:request_uri(
+ "http://127.0.0.1:" .. ngx.var.server_port .. "/hello",
+ {headers = {["X-User"] = user, ["Count"] = count}})
+ if not res then
+ ngx.say("failed: ", err)
+ return
+ end
+ ngx.say(user, " ", count or "-", ": ", res.status, " ",
+ res.headers["X-RateLimit-Limit"], " ",
+ res.headers["X-RateLimit-Remaining"])
+ end
+ hit("u29a", "1")
+ hit("u29a", "1")
+ hit("u29b", "2")
+ hit("u29b", "2")
+ hit("u29b", "2")
+ hit("u29c")
+ }
+ }
+--- response_body
+u29a 1: 200 1 0
+u29a 1: 503 1 0
+u29b 2: 200 2 1
+u29b 2: 200 2 0
+u29b 2: 503 2 0
+u29c -: 200 3 2
diff --git a/t/plugin/proxy-rewrite3.t b/t/plugin/proxy-rewrite3.t
index f16a03810..5e073ca29 100644
--- a/t/plugin/proxy-rewrite3.t
+++ b/t/plugin/proxy-rewrite3.t
@@ -1043,3 +1043,64 @@ GET /print%5Furi%5Fdetailed HTTP/1.1
--- response_body
ngx.var.uri: /print_uri_detailed
ngx.var.request_uri: /print_uri_detailed
+
+
+
+=== TEST 44: use both a regex_uri capture and a variable in a header
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/test/*",
+ "plugins": {
+ "proxy-rewrite": {
+ "regex_uri": ["^/test/(.*)", "/echo"],
+ "headers": {
+ "add": {
+ "X-Request-ID": "$1-$http_x_tag"
+ }
+ }
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 45: hit with one capture and variable
+--- request
+GET /test/a
+--- more_headers
+X-Tag: t1
+--- response_headers
+X-Request-ID: a-t1
+
+
+
+=== TEST 46: hit with another capture and variable
+--- request
+GET /test/b
+--- more_headers
+X-Tag: t2
+--- response_headers
+X-Request-ID: b-t2