Skip to content

Commit 6e877eb

Browse files
fix(limit): atomic redis commits and resolved-var validation (#13467)
1 parent c730a74 commit 6e877eb

4 files changed

Lines changed: 506 additions & 36 deletions

File tree

apisix/plugins/limit-conn/init.lua

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ local tonumber = tonumber
2222
local type = type
2323
local tostring = tostring
2424
local ipairs = ipairs
25+
local floor = math.floor
2526
local shdict_name = "plugin-limit-conn"
2627
if ngx.config.subsystem == "stream" then
2728
shdict_name = shdict_name .. "-stream"
@@ -41,16 +42,38 @@ end
4142
local _M = {}
4243

4344

44-
local function resolve_var(ctx, value)
45+
local function resolve_var(ctx, value, allow_zero)
4546
if type(value) == "string" then
47+
local original = value
4648
local err, _
4749
value, err, _ = core.utils.resolve_var(value, ctx.var)
4850
if err then
49-
return nil, "could not resolve var for value: " .. value .. ", err: " .. err
51+
return nil, "could not resolve var for value: " .. original .. ", err: " .. err
5052
end
53+
local resolved = value
5154
value = tonumber(value)
5255
if not value then
53-
return nil, "resolved value is not a number: " .. tostring(value)
56+
return nil, "resolved value is not a number: " .. tostring(resolved)
57+
end
58+
-- conn must be positive; burst may be zero, mirroring the schema where
59+
-- conn has exclusiveMinimum 0 and burst has minimum 0
60+
if allow_zero then
61+
if value < 0 then
62+
return nil, "resolved value must be a non-negative number, got: "
63+
.. tostring(value)
64+
end
65+
elseif value <= 0 then
66+
return nil, "resolved value must be a positive number, got: " .. tostring(value)
67+
end
68+
-- conn/burst are used as integer operands in floor((conn - 1) / max);
69+
-- fractional values produce wrong delay calculations
70+
if value ~= floor(value) then
71+
return nil, "resolved value must be an integer, got: " .. tostring(value)
72+
end
73+
-- LuaJIT doubles lose integer precision above 2^53 (9007199254740992)
74+
if value > 9007199254740991 then
75+
return nil, "resolved value exceeds safe integer range (2^53-1), got: "
76+
.. tostring(value)
5477
end
5578
end
5679
return value
@@ -63,7 +86,7 @@ local function get_rules(ctx, conf)
6386
if err then
6487
return nil, err
6588
end
66-
local burst, err2 = resolve_var(ctx, conf.burst)
89+
local burst, err2 = resolve_var(ctx, conf.burst, true)
6790
if err2 then
6891
return nil, err2
6992
end
@@ -83,7 +106,7 @@ local function get_rules(ctx, conf)
83106
if err then
84107
goto CONTINUE
85108
end
86-
local burst, err2 = resolve_var(ctx, rule.burst)
109+
local burst, err2 = resolve_var(ctx, rule.burst, true)
87110
if err2 then
88111
goto CONTINUE
89112
end

apisix/plugins/limit-req/util.lua

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,55 +20,113 @@ local max = math.max
2020
local ngx_now = ngx.now
2121
local ngx_null = ngx.null
2222
local tonumber = tonumber
23+
local core = require("apisix.core")
2324

2425

2526
local _M = {version = 0.1}
2627

2728

28-
-- the "commit" argument controls whether should we record the event in shm.
29-
function _M.incoming(self, red, key, commit)
30-
local rate = self.rate
31-
local now = ngx_now() * 1000
32-
33-
key = "limit_req" .. ":" .. key
34-
local excess_key = key .. "excess"
35-
local last_key = key .. "last"
29+
-- Redis Lua script that reads, decays and persists the leaky-bucket state in a
30+
-- single atomic step. With separate GET/GET/SET/SET commands, concurrent
31+
-- requests can each read the same stale excess value, all conclude they are
32+
-- within limits, and all get admitted, exceeding the configured rate.
33+
--
34+
-- The whole state lives in a single Redis hash (fields "excess" and "last")
35+
-- so the script only needs one key. lua-resty-rediscluster refuses to run
36+
-- EVAL with more than one key, and a single key is trivially slot-safe in
37+
-- Redis Cluster.
38+
--
39+
-- KEYS[1] = state key (hash with fields "excess" and "last")
40+
-- ARGV[1] = rate (req/s * 1000), ARGV[2] = burst (* 1000), ARGV[3] = now (ms),
41+
-- ARGV[4] = ttl (seconds)
42+
-- Returns {1, excess} on allow (state already stored)
43+
-- {0, excess} on reject (nothing stored)
44+
-- excess is returned as a string to keep its fractional part: Redis truncates
45+
-- Lua numbers to integers when converting them to a reply.
46+
local redis_commit_script = core.string.compress_script([=[
47+
local state_key = KEYS[1]
48+
local rate = tonumber(ARGV[1])
49+
local burst = tonumber(ARGV[2])
50+
local now = tonumber(ARGV[3])
51+
local ttl = tonumber(ARGV[4])
3652
37-
local excess, err = red:get(excess_key)
38-
if err then
39-
return nil, err
40-
end
41-
local last, err = red:get(last_key)
42-
if err then
43-
return nil, err
44-
end
53+
local state = redis.call('hmget', state_key, 'excess', 'last')
54+
local excess_raw = state[1]
55+
local last_raw = state[2]
4556
46-
if excess ~= ngx_null and last ~= ngx_null then
47-
excess = tonumber(excess)
48-
last = tonumber(last)
49-
local elapsed = now - last
50-
excess = max(excess - rate * abs(elapsed) / 1000 + 1000, 0)
57+
local excess
58+
if excess_raw and last_raw then
59+
-- state exists: apply leaky-bucket decay then add one request-unit (1000)
60+
local elapsed = now - tonumber(last_raw)
61+
excess = math.max(tonumber(excess_raw) - rate * math.abs(elapsed) / 1000 + 1000, 0)
5162
52-
if excess > self.burst then
53-
return nil, "rejected"
63+
if excess > burst then
64+
return {0, tostring(excess)}
5465
end
5566
else
67+
-- no prior state: mirror the original behaviour, which skips the
68+
-- leaky-bucket formula and starts with excess = 0 so the very first
69+
-- request is always admitted
5670
excess = 0
5771
end
5872
59-
if commit then
60-
local ttl = math.ceil(self.burst / self.rate) + 1
61-
local ok, err
73+
redis.call('hset', state_key, 'excess', excess, 'last', now)
74+
redis.call('expire', state_key, ttl)
75+
return {1, tostring(excess)}
76+
]=])
6277

63-
ok, err = red:set(excess_key, excess, "EX", ttl)
64-
if not ok then
78+
79+
-- the "commit" argument controls whether should we record the event in shm.
80+
function _M.incoming(self, red, key, commit)
81+
local rate = self.rate
82+
local now = ngx_now() * 1000
83+
84+
-- all leaky-bucket state lives in a single Redis hash so that the commit
85+
-- script can run with one key on both Redis and Redis Cluster
86+
local state_key = "limit_req" .. ":" .. key
87+
88+
if not commit then
89+
-- read-only path: a plain HMGET is fine here because nothing is
90+
-- written back, so a stale read only affects this advisory check
91+
local state, err = red:hmget(state_key, "excess", "last")
92+
if not state then
6593
return nil, err
6694
end
6795

68-
ok, err = red:set(last_key, now, "EX", ttl)
69-
if not ok then
70-
return nil, err
96+
local excess = state[1]
97+
local last = state[2]
98+
local excess_val
99+
if excess ~= ngx_null and last ~= ngx_null then
100+
excess_val = tonumber(excess)
101+
local last_val = tonumber(last)
102+
local elapsed = now - last_val
103+
excess_val = max(excess_val - rate * abs(elapsed) / 1000 + 1000, 0)
104+
105+
if excess_val > self.burst then
106+
return nil, "rejected"
107+
end
108+
else
109+
excess_val = 0
71110
end
111+
112+
return excess_val / rate, excess_val / 1000
113+
end
114+
115+
-- commit path: run the read-compute-write cycle atomically in Redis so
116+
-- that concurrent requests cannot be admitted based on the same stale
117+
-- state
118+
local ttl = math.ceil(self.burst / self.rate) + 1
119+
120+
local res, err = red:eval(redis_commit_script, 1, state_key,
121+
rate, self.burst, now, ttl)
122+
if not res then
123+
return nil, err
124+
end
125+
126+
local allowed = res[1]
127+
local excess = tonumber(res[2])
128+
if allowed == 0 then
129+
return nil, "rejected"
72130
end
73131

74132
-- return the delay in seconds, as well as excess

t/plugin/limit-conn-variable.t

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,120 @@ GET /test_concurrency
351351
500
352352
--- error_log
353353
failed to get limit conn rules
354+
355+
356+
357+
=== TEST 11: restore route for invalid-variable tests (conn/burst as dynamic header)
358+
--- config
359+
location /t {
360+
content_by_lua_block {
361+
local t = require("lib.test_admin").test
362+
local code, body = t('/apisix/admin/routes/1',
363+
ngx.HTTP_PUT,
364+
[[{
365+
"plugins": {
366+
"limit-conn": {
367+
"conn": "${http_conn ?? 5}",
368+
"burst": "${http_burst ?? 2}",
369+
"default_conn_delay": 0.1,
370+
"rejected_code": 503,
371+
"key": "remote_addr"
372+
}
373+
},
374+
"upstream": {
375+
"nodes": {
376+
"127.0.0.1:1980": 1
377+
},
378+
"type": "roundrobin"
379+
},
380+
"uri": "/limit_conn"
381+
}]]
382+
)
383+
384+
if code >= 300 then
385+
ngx.status = code
386+
end
387+
ngx.say(body)
388+
}
389+
}
390+
--- request
391+
GET /t
392+
--- response_body
393+
passed
394+
395+
396+
397+
=== TEST 12: zero conn header value is rejected with 500 and error log
398+
--- request
399+
GET /limit_conn
400+
--- more_headers
401+
conn: 0
402+
--- error_code: 500
403+
--- error_log
404+
resolved value must be a positive number
405+
406+
407+
408+
=== TEST 13: negative conn header value is rejected with 500 and error log
409+
--- request
410+
GET /limit_conn
411+
--- more_headers
412+
conn: -1
413+
--- error_code: 500
414+
--- error_log
415+
resolved value must be a positive number
416+
417+
418+
419+
=== TEST 14: fractional conn header value is rejected with 500 and error log
420+
--- request
421+
GET /limit_conn
422+
--- more_headers
423+
conn: 1.5
424+
--- error_code: 500
425+
--- error_log
426+
resolved value must be an integer
427+
428+
429+
430+
=== TEST 15: fractional burst header value is rejected with 500 and error log
431+
--- request
432+
GET /limit_conn
433+
--- more_headers
434+
burst: 1.5
435+
--- error_code: 500
436+
--- error_log
437+
resolved value must be an integer
438+
439+
440+
441+
=== TEST 16: conn header value above 2^53-1 is rejected with 500 and error log
442+
--- request
443+
GET /limit_conn
444+
--- more_headers
445+
conn: 99007199254740993
446+
--- error_code: 500
447+
--- error_log
448+
resolved value exceeds safe integer range
449+
450+
451+
452+
=== TEST 17: dynamic burst resolving to 0 is accepted (schema allows burst minimum 0)
453+
--- request
454+
GET /limit_conn
455+
--- more_headers
456+
burst: 0
457+
--- error_code: 200
458+
--- no_error_log
459+
resolved value must be
460+
461+
462+
463+
=== TEST 18: negative burst header value is rejected with 500 and error log
464+
--- request
465+
GET /limit_conn
466+
--- more_headers
467+
burst: -1
468+
--- error_code: 500
469+
--- error_log
470+
resolved value must be a non-negative number

0 commit comments

Comments
 (0)