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 6e877eb44 fix(limit): atomic redis commits and resolved-var validation
(#13467)
6e877eb44 is described below
commit 6e877eb44f68d0a34385cf42fcff403d10acc97e
Author: Shreemaan Abhishek <[email protected]>
AuthorDate: Mon Jun 8 18:52:38 2026 +0800
fix(limit): atomic redis commits and resolved-var validation (#13467)
---
apisix/plugins/limit-conn/init.lua | 33 ++++-
apisix/plugins/limit-req/util.lua | 120 +++++++++++-----
t/plugin/limit-conn-variable.t | 117 ++++++++++++++++
t/plugin/limit-req-redis.t | 272 +++++++++++++++++++++++++++++++++++++
4 files changed, 506 insertions(+), 36 deletions(-)
diff --git a/apisix/plugins/limit-conn/init.lua
b/apisix/plugins/limit-conn/init.lua
index b45ff567c..0d45bd290 100644
--- a/apisix/plugins/limit-conn/init.lua
+++ b/apisix/plugins/limit-conn/init.lua
@@ -22,6 +22,7 @@ local tonumber = tonumber
local type = type
local tostring = tostring
local ipairs = ipairs
+local floor = math.floor
local shdict_name = "plugin-limit-conn"
if ngx.config.subsystem == "stream" then
shdict_name = shdict_name .. "-stream"
@@ -41,16 +42,38 @@ end
local _M = {}
-local function resolve_var(ctx, value)
+local function resolve_var(ctx, value, allow_zero)
if type(value) == "string" then
+ local original = value
local err, _
value, err, _ = core.utils.resolve_var(value, ctx.var)
if err then
- return nil, "could not resolve var for value: " .. value .. ",
err: " .. err
+ return nil, "could not resolve var for value: " .. original .. ",
err: " .. err
end
+ local resolved = value
value = tonumber(value)
if not value then
- return nil, "resolved value is not a number: " .. tostring(value)
+ return nil, "resolved value is not a number: " ..
tostring(resolved)
+ end
+ -- conn must be positive; burst may be zero, mirroring the schema where
+ -- conn has exclusiveMinimum 0 and burst has minimum 0
+ if allow_zero then
+ if value < 0 then
+ return nil, "resolved value must be a non-negative number,
got: "
+ .. tostring(value)
+ end
+ elseif value <= 0 then
+ return nil, "resolved value must be a positive number, got: " ..
tostring(value)
+ end
+ -- conn/burst are used as integer operands in floor((conn - 1) / max);
+ -- fractional values produce wrong delay calculations
+ if value ~= floor(value) then
+ return nil, "resolved value must be an integer, got: " ..
tostring(value)
+ end
+ -- LuaJIT doubles lose integer precision above 2^53 (9007199254740992)
+ if value > 9007199254740991 then
+ return nil, "resolved value exceeds safe integer range (2^53-1),
got: "
+ .. tostring(value)
end
end
return value
@@ -63,7 +86,7 @@ local function get_rules(ctx, conf)
if err then
return nil, err
end
- local burst, err2 = resolve_var(ctx, conf.burst)
+ local burst, err2 = resolve_var(ctx, conf.burst, true)
if err2 then
return nil, err2
end
@@ -83,7 +106,7 @@ local function get_rules(ctx, conf)
if err then
goto CONTINUE
end
- local burst, err2 = resolve_var(ctx, rule.burst)
+ local burst, err2 = resolve_var(ctx, rule.burst, true)
if err2 then
goto CONTINUE
end
diff --git a/apisix/plugins/limit-req/util.lua
b/apisix/plugins/limit-req/util.lua
index 6724889f1..e12660453 100644
--- a/apisix/plugins/limit-req/util.lua
+++ b/apisix/plugins/limit-req/util.lua
@@ -20,55 +20,113 @@ local max = math.max
local ngx_now = ngx.now
local ngx_null = ngx.null
local tonumber = tonumber
+local core = require("apisix.core")
local _M = {version = 0.1}
--- the "commit" argument controls whether should we record the event in shm.
-function _M.incoming(self, red, key, commit)
- local rate = self.rate
- local now = ngx_now() * 1000
-
- key = "limit_req" .. ":" .. key
- local excess_key = key .. "excess"
- local last_key = key .. "last"
+-- Redis Lua script that reads, decays and persists the leaky-bucket state in a
+-- single atomic step. With separate GET/GET/SET/SET commands, concurrent
+-- requests can each read the same stale excess value, all conclude they are
+-- within limits, and all get admitted, exceeding the configured rate.
+--
+-- The whole state lives in a single Redis hash (fields "excess" and "last")
+-- so the script only needs one key. lua-resty-rediscluster refuses to run
+-- EVAL with more than one key, and a single key is trivially slot-safe in
+-- Redis Cluster.
+--
+-- KEYS[1] = state key (hash with fields "excess" and "last")
+-- ARGV[1] = rate (req/s * 1000), ARGV[2] = burst (* 1000), ARGV[3] = now (ms),
+-- ARGV[4] = ttl (seconds)
+-- Returns {1, excess} on allow (state already stored)
+-- {0, excess} on reject (nothing stored)
+-- excess is returned as a string to keep its fractional part: Redis truncates
+-- Lua numbers to integers when converting them to a reply.
+local redis_commit_script = core.string.compress_script([=[
+ local state_key = KEYS[1]
+ local rate = tonumber(ARGV[1])
+ local burst = tonumber(ARGV[2])
+ local now = tonumber(ARGV[3])
+ local ttl = tonumber(ARGV[4])
- local excess, err = red:get(excess_key)
- if err then
- return nil, err
- end
- local last, err = red:get(last_key)
- if err then
- return nil, err
- end
+ local state = redis.call('hmget', state_key, 'excess', 'last')
+ local excess_raw = state[1]
+ local last_raw = state[2]
- if excess ~= ngx_null and last ~= ngx_null then
- excess = tonumber(excess)
- last = tonumber(last)
- local elapsed = now - last
- excess = max(excess - rate * abs(elapsed) / 1000 + 1000, 0)
+ local excess
+ if excess_raw and last_raw then
+ -- state exists: apply leaky-bucket decay then add one request-unit
(1000)
+ local elapsed = now - tonumber(last_raw)
+ excess = math.max(tonumber(excess_raw) - rate * math.abs(elapsed) /
1000 + 1000, 0)
- if excess > self.burst then
- return nil, "rejected"
+ if excess > burst then
+ return {0, tostring(excess)}
end
else
+ -- no prior state: mirror the original behaviour, which skips the
+ -- leaky-bucket formula and starts with excess = 0 so the very first
+ -- request is always admitted
excess = 0
end
- if commit then
- local ttl = math.ceil(self.burst / self.rate) + 1
- local ok, err
+ redis.call('hset', state_key, 'excess', excess, 'last', now)
+ redis.call('expire', state_key, ttl)
+ return {1, tostring(excess)}
+]=])
- ok, err = red:set(excess_key, excess, "EX", ttl)
- if not ok then
+
+-- the "commit" argument controls whether should we record the event in shm.
+function _M.incoming(self, red, key, commit)
+ local rate = self.rate
+ local now = ngx_now() * 1000
+
+ -- all leaky-bucket state lives in a single Redis hash so that the commit
+ -- script can run with one key on both Redis and Redis Cluster
+ local state_key = "limit_req" .. ":" .. key
+
+ if not commit then
+ -- read-only path: a plain HMGET is fine here because nothing is
+ -- written back, so a stale read only affects this advisory check
+ local state, err = red:hmget(state_key, "excess", "last")
+ if not state then
return nil, err
end
- ok, err = red:set(last_key, now, "EX", ttl)
- if not ok then
- return nil, err
+ local excess = state[1]
+ local last = state[2]
+ local excess_val
+ if excess ~= ngx_null and last ~= ngx_null then
+ excess_val = tonumber(excess)
+ local last_val = tonumber(last)
+ local elapsed = now - last_val
+ excess_val = max(excess_val - rate * abs(elapsed) / 1000 + 1000, 0)
+
+ if excess_val > self.burst then
+ return nil, "rejected"
+ end
+ else
+ excess_val = 0
end
+
+ return excess_val / rate, excess_val / 1000
+ end
+
+ -- commit path: run the read-compute-write cycle atomically in Redis so
+ -- that concurrent requests cannot be admitted based on the same stale
+ -- state
+ local ttl = math.ceil(self.burst / self.rate) + 1
+
+ local res, err = red:eval(redis_commit_script, 1, state_key,
+ rate, self.burst, now, ttl)
+ if not res then
+ return nil, err
+ end
+
+ local allowed = res[1]
+ local excess = tonumber(res[2])
+ if allowed == 0 then
+ return nil, "rejected"
end
-- return the delay in seconds, as well as excess
diff --git a/t/plugin/limit-conn-variable.t b/t/plugin/limit-conn-variable.t
index e1db32c1e..0b6dd2287 100644
--- a/t/plugin/limit-conn-variable.t
+++ b/t/plugin/limit-conn-variable.t
@@ -351,3 +351,120 @@ GET /test_concurrency
500
--- error_log
failed to get limit conn rules
+
+
+
+=== TEST 11: restore route for invalid-variable tests (conn/burst as dynamic
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,
+ [[{
+ "plugins": {
+ "limit-conn": {
+ "conn": "${http_conn ?? 5}",
+ "burst": "${http_burst ?? 2}",
+ "default_conn_delay": 0.1,
+ "rejected_code": 503,
+ "key": "remote_addr"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/limit_conn"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 12: zero conn header value is rejected with 500 and error log
+--- request
+GET /limit_conn
+--- more_headers
+conn: 0
+--- error_code: 500
+--- error_log
+resolved value must be a positive number
+
+
+
+=== TEST 13: negative conn header value is rejected with 500 and error log
+--- request
+GET /limit_conn
+--- more_headers
+conn: -1
+--- error_code: 500
+--- error_log
+resolved value must be a positive number
+
+
+
+=== TEST 14: fractional conn header value is rejected with 500 and error log
+--- request
+GET /limit_conn
+--- more_headers
+conn: 1.5
+--- error_code: 500
+--- error_log
+resolved value must be an integer
+
+
+
+=== TEST 15: fractional burst header value is rejected with 500 and error log
+--- request
+GET /limit_conn
+--- more_headers
+burst: 1.5
+--- error_code: 500
+--- error_log
+resolved value must be an integer
+
+
+
+=== TEST 16: conn header value above 2^53-1 is rejected with 500 and error log
+--- request
+GET /limit_conn
+--- more_headers
+conn: 99007199254740993
+--- error_code: 500
+--- error_log
+resolved value exceeds safe integer range
+
+
+
+=== TEST 17: dynamic burst resolving to 0 is accepted (schema allows burst
minimum 0)
+--- request
+GET /limit_conn
+--- more_headers
+burst: 0
+--- error_code: 200
+--- no_error_log
+resolved value must be
+
+
+
+=== TEST 18: negative burst header value is rejected with 500 and error log
+--- request
+GET /limit_conn
+--- more_headers
+burst: -1
+--- error_code: 500
+--- error_log
+resolved value must be a non-negative number
diff --git a/t/plugin/limit-req-redis.t b/t/plugin/limit-req-redis.t
index fd67468f2..ad5468a4b 100644
--- a/t/plugin/limit-req-redis.t
+++ b/t/plugin/limit-req-redis.t
@@ -667,3 +667,275 @@ passed
GET /t
--- response_body eval
qr/property \"rate\" validation failed: expected 0 to be greater than 0/
+
+
+
+=== TEST 25: set route for hash-tag key-format tests (rate=100, burst=0)
+--- 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,
+ [[{
+ "plugins": {
+ "limit-req": {
+ "rate": 100,
+ "burst": 0,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis",
+ "redis_host": "127.0.0.1"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/hello"
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 26: atomic script stores state in a single hash key
+--- config
+ location /t {
+ content_by_lua_block {
+ -- Flush all Redis state so this test starts clean.
+ local redis = require "resty.redis"
+ local red = redis:new()
+ red:set_timeout(1000)
+ local ok, err = red:connect("127.0.0.1", 6379)
+ if not ok then
+ ngx.say("connect failed: ", err)
+ return
+ end
+ local ok_flush, err_flush = red:flushall()
+ if not ok_flush then
+ ngx.say("flushall failed: ", err_flush)
+ return
+ end
+
+ -- Make one request to trigger the atomic script.
+ local httpc = require "resty.http"
+ local hc = httpc:new()
+ local port = ngx.var.server_port
+ local res, err2 = hc:request_uri("http://127.0.0.1:" .. port ..
"/hello")
+ if not res then
+ ngx.say("request failed: ", err2)
+ return
+ end
+ ngx.say("status: ", res.status)
+
+ -- Verify the state lives in a single hash key limit_req:<key>
+ -- holding both the "excess" and "last" fields.
+ local keys, err3 = red:keys("limit_req:*")
+ if not keys then
+ ngx.say("keys cmd failed: ", err3)
+ return
+ end
+ ngx.say("state keys found: ", #keys)
+
+ local fields, err4 = red:hmget(keys[1], "excess", "last")
+ if not fields then
+ ngx.say("hmget failed: ", err4)
+ return
+ end
+ local has_fields = fields[1] ~= ngx.null and fields[2] ~= ngx.null
+ ngx.say("excess and last fields present: ", has_fields and "yes"
or "no")
+ }
+ }
+--- request
+GET /t
+--- response_body
+status: 200
+state keys found: 1
+excess and last fields present: yes
+--- no_error_log
+[error]
+
+
+
+=== TEST 27: set route for first-request tests (rate=1, burst=0)
+--- config
+ location /t {
+ content_by_lua_block {
+ -- Flush so no stale excess remains from previous tests.
+ local redis = require "resty.redis"
+ local red = redis:new()
+ red:set_timeout(1000)
+ local ok, err = red:connect("127.0.0.1", 6379)
+ if not ok then
+ ngx.say("connect failed: ", err)
+ return
+ end
+ local ok_flush, err_flush = red:flushall()
+ if not ok_flush then
+ ngx.say("flushall failed: ", err_flush)
+ return
+ end
+
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "limit-req": {
+ "rate": 1,
+ "burst": 0,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis",
+ "redis_host": "127.0.0.1"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/hello"
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ ngx.say("route update failed: ", body)
+ return
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 28: first request is always admitted even when burst=0 (no prior
state)
+--- pipelined_requests eval
+["GET /hello", "GET /hello"]
+--- error_code eval
+[200, 503]
+
+
+
+=== TEST 29: set route for atomic-script concurrency tests (rate=1, burst=0)
+--- config
+ location /t {
+ content_by_lua_block {
+ -- Flush so no stale excess remains from previous tests.
+ local redis = require "resty.redis"
+ local red = redis:new()
+ red:set_timeout(1000)
+ local ok, err = red:connect("127.0.0.1", 6379)
+ if not ok then
+ ngx.say("connect failed: ", err)
+ return
+ end
+ local ok_flush, err_flush = red:flushall()
+ if not ok_flush then
+ ngx.say("flushall failed: ", err_flush)
+ return
+ end
+
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/1',
+ ngx.HTTP_PUT,
+ [[{
+ "plugins": {
+ "limit-req": {
+ "rate": 1,
+ "burst": 0,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis",
+ "redis_host": "127.0.0.1"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ },
+ "uri": "/hello"
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ ngx.say("route update failed: ", body)
+ return
+ end
+ ngx.say(body)
+ }
+ }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 30: atomic script prevents concurrent requests from bypassing rate
limit
+--- config
+ location /hello_proxy {
+ content_by_lua_block {
+ local httpc = require "resty.http"
+ local hc = httpc:new()
+ local port = ngx.var.server_port
+ local res, err = hc:request_uri("http://127.0.0.1:" .. port ..
"/hello")
+ if not res then
+ ngx.exit(500)
+ end
+ ngx.exit(res.status)
+ }
+ }
+
+ location /t {
+ content_by_lua_block {
+ -- Fire 5 concurrent sub-requests via capture_multi.
+ -- Each sub-request calls /hello through a separate resty.http
connection,
+ -- so the atomic Redis Lua script is exercised under concurrent
load.
+ -- With rate=1, burst=0 and a clean Redis state, exactly 1 request
+ -- should pass (status 200) and the remaining 4 should be rejected
(503).
+ local reqs = {}
+ for i = 1, 5 do
+ reqs[i] = { "/hello_proxy" }
+ end
+ local resps = { ngx.location.capture_multi(reqs) }
+ local ok_count = 0
+ local reject_count = 0
+ for _, resp in ipairs(resps) do
+ if resp.status == 200 then
+ ok_count = ok_count + 1
+ elseif resp.status == 503 then
+ reject_count = reject_count + 1
+ end
+ end
+ ngx.say("admitted: ", ok_count)
+ ngx.say("rejected: ", reject_count)
+ }
+ }
+--- request
+GET /t
+--- response_body
+admitted: 1
+rejected: 4
+--- no_error_log
+[error]