From 11d5714ce673ab1cd32c3a4951c7a432eaf7fe26 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 10:49:18 +0545 Subject: [PATCH 01/12] feat: support variables in limit-conn, limit-count and ai-rate-limiting Signed-off-by: Abhishek Choudhary --- apisix/plugins/ai-rate-limiting.lua | 28 ++- apisix/plugins/limit-conn.lua | 14 +- apisix/plugins/limit-conn/init.lua | 47 +++- apisix/plugins/limit-count/init.lua | 126 ++++++----- .../plugins/limit-count/limit-count-local.lua | 8 +- t/plugin/ai-rate-limiting.t | 206 ++++++++++++++++++ t/plugin/limit-conn-variable.t | 178 +++++++++++++++ t/plugin/limit-count-variable.t | 145 ++++++++++++ 8 files changed, 683 insertions(+), 69 deletions(-) create mode 100644 t/plugin/limit-conn-variable.t create mode 100644 t/plugin/limit-count-variable.t diff --git a/apisix/plugins/ai-rate-limiting.lua b/apisix/plugins/ai-rate-limiting.lua index c674bbef8460..2bbfa8acac40 100644 --- a/apisix/plugins/ai-rate-limiting.lua +++ b/apisix/plugins/ai-rate-limiting.lua @@ -27,8 +27,18 @@ local instance_limit_schema = { type = "object", properties = { name = {type = "string"}, - limit = {type = "integer", minimum = 1}, - time_window = {type = "integer", minimum = 1} + limit = { + oneOf = { + {type = "integer", minimum = 1}, + {type = "string"}, + }, + }, + time_window = { + oneOf = { + {type = "integer", minimum = 1}, + {type = "string"}, + }, + } }, required = {"name", "limit", "time_window"} } @@ -36,8 +46,18 @@ local instance_limit_schema = { local schema = { type = "object", properties = { - limit = {type = "integer", exclusiveMinimum = 0}, - time_window = {type = "integer", exclusiveMinimum = 0}, + limit = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, + time_window = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, show_limit_quota_header = {type = "boolean", default = true}, limit_strategy = { type = "string", diff --git a/apisix/plugins/limit-conn.lua b/apisix/plugins/limit-conn.lua index 6fdefd1f40d5..dd88162aff6a 100644 --- a/apisix/plugins/limit-conn.lua +++ b/apisix/plugins/limit-conn.lua @@ -24,8 +24,18 @@ local workflow = require("apisix.plugins.workflow") local schema = { type = "object", properties = { - conn = {type = "integer", exclusiveMinimum = 0}, -- limit.conn max - burst = {type = "integer", minimum = 0}, + conn = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, + burst = { + oneOf = { + {type = "integer", minimum = 0}, + {type = "string"}, + }, + }, default_conn_delay = {type = "number", exclusiveMinimum = 0}, only_use_default_delay = {type = "boolean", default = false}, key = {type = "string"}, diff --git a/apisix/plugins/limit-conn/init.lua b/apisix/plugins/limit-conn/init.lua index 22ce44a120a3..0789b17ebb70 100644 --- a/apisix/plugins/limit-conn/init.lua +++ b/apisix/plugins/limit-conn/init.lua @@ -18,6 +18,9 @@ local limit_conn_new = require("resty.limit.conn").new local core = require("apisix.core") local is_http = ngx.config.subsystem == "http" local sleep = core.sleep +local tonumber = tonumber +local type = type +local tostring = tostring local shdict_name = "plugin-limit-conn" if ngx.config.subsystem == "stream" then shdict_name = shdict_name .. "-stream" @@ -34,30 +37,56 @@ do end -local lrucache = core.lrucache.new({ - type = "plugin", -}) + local _M = {} -local function create_limit_obj(conf) +local function create_limit_obj(ctx, conf) core.log.info("create new limit-conn plugin instance") + local conn = conf.conn + if type(conn) == "string" then + local err, _ + conn, err, _ = core.utils.resolve_var(conn, ctx.var) + if err then + return nil, "could not resolve vars in conn: " .. err + end + conn = tonumber(conn) + if not conn then + return nil, "resolved conn is not a number: " .. tostring(conn) + end + end + + local burst = conf.burst + if type(burst) == "string" then + local err, _ + burst, err, _ = core.utils.resolve_var(burst, ctx.var) + if err then + return nil, "could not resolve vars in burst: " .. err + end + burst = tonumber(burst) + if not burst then + return nil, "resolved burst is not a number: " .. tostring(burst) + end + end + + core.log.info("limit conn: ", conn, ", burst: ", burst) + if conf.policy == "redis" then core.log.info("create new limit-conn redis plugin instance") - return redis_single_new("plugin-limit-conn", conf, conf.conn, conf.burst, + return redis_single_new("plugin-limit-conn", conf, conn, burst, conf.default_conn_delay) elseif conf.policy == "redis-cluster" then core.log.info("create new limit-conn redis-cluster plugin instance") - return redis_cluster_new("plugin-limit-conn", conf, conf.conn, conf.burst, + return redis_cluster_new("plugin-limit-conn", conf, conn, burst, conf.default_conn_delay) else core.log.info("create new limit-conn plugin instance") - return limit_conn_new(shdict_name, conf.conn, conf.burst, + return limit_conn_new(shdict_name, conn, burst, conf.default_conn_delay) end end @@ -65,7 +94,7 @@ end function _M.increase(conf, ctx) core.log.info("ver: ", ctx.conf_version) - local lim, err = lrucache(conf, nil, create_limit_obj, conf) + local lim, err = create_limit_obj(ctx, conf) if not lim then core.log.error("failed to instantiate a resty.limit.conn object: ", err) if conf.allow_degradation then @@ -108,6 +137,8 @@ function _M.increase(conf, ctx) return conf.rejected_code or 503 end + + core.log.error("failed to limit conn: ", err) if conf.allow_degradation then return diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua index c7b8579d0ffd..17ba8940999c 100644 --- a/apisix/plugins/limit-count/init.lua +++ b/apisix/plugins/limit-count/init.lua @@ -22,6 +22,9 @@ local pairs = pairs local redis_schema = require("apisix.utils.redis-schema") local policy_to_additional_properties = redis_schema.schema local get_phase = ngx.get_phase +local tonumber = tonumber +local type = type +local tostring = tostring local limit_redis_cluster_new local limit_redis_new @@ -70,8 +73,18 @@ local metadata_schema = { local schema = { type = "object", properties = { - count = {type = "integer", exclusiveMinimum = 0}, - time_window = {type = "integer", exclusiveMinimum = 0}, + count = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, + time_window = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, group = {type = "string"}, key = {type = "string", default = "remote_addr"}, key_type = {type = "string", @@ -174,75 +187,60 @@ function _M.check_schema(conf, schema_type) end -local function create_limit_obj(conf, plugin_name) +local function create_limit_obj(conf, ctx, plugin_name) core.log.info("create new " .. plugin_name .. " plugin instance") - if not conf.policy or conf.policy == "local" then - return limit_local_new("plugin-" .. plugin_name, conf.count, - conf.time_window) - end - - if conf.policy == "redis" then - return limit_redis_new("plugin-" .. plugin_name, - conf.count, conf.time_window, conf) + local count = conf.count + if type(count) == "string" then + local err, _ + count, err, _ = core.utils.resolve_var(count, ctx.var) + if err then + return nil, "could not resolve vars in count: " .. err + end + count = tonumber(count) + if not count then + return nil, "resolved count is not a number: " .. tostring(count) + end end - if conf.policy == "redis-cluster" then - return limit_redis_cluster_new("plugin-" .. plugin_name, conf.count, - conf.time_window, conf) + local time_window = conf.time_window + if type(time_window) == "string" then + local err, _ + time_window, err, _ = core.utils.resolve_var(time_window, ctx.var) + if err then + return nil, "could not resolve vars in time_window: " .. err + end + time_window = tonumber(time_window) + if not time_window then + return nil, "resolved time_window is not a number: " .. tostring(time_window) + end end - return nil -end - + core.log.info("limit count: ", count, ", time_window: ", time_window) -local function gen_limit_key(conf, ctx, key) - if conf.group then - return conf.group .. ':' .. key + if not conf.policy or conf.policy == "local" then + return limit_local_new("plugin-" .. plugin_name, count, time_window) end - -- here we add a separator ':' to mark the boundary of the prefix and the key itself - -- Here we use plugin-level conf version to prevent the counter from being resetting - -- because of the change elsewhere. - -- A route which reuses a previous route's ID will inherits its counter. - local parent = conf._meta and conf._meta.parent - if not parent or not parent.resource_key then - core.log.error("failed to generate key invalid parent: ", core.json.encode(parent)) - return nil + if conf.policy == "redis" then + return limit_redis_new("plugin-" .. plugin_name, count, time_window, conf) end - local new_key = parent.resource_key .. ':' .. apisix_plugin.conf_version(conf) - .. ':' .. 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. - return new_key .. ':' .. conf._vid + if conf.policy == "redis-cluster" then + return limit_redis_cluster_new("plugin-" .. plugin_name, count, time_window, conf) end - return new_key + return nil end -local function gen_limit_obj(conf, ctx, plugin_name) - if conf.group then - return lrucache(conf.group, "", create_limit_obj, conf, plugin_name) - end - local extra_key - if conf._vid then - extra_key = conf.policy .. '#' .. conf._vid - else - extra_key = conf.policy - end - - return core.lrucache.plugin_ctx(lrucache, ctx, extra_key, create_limit_obj, conf, plugin_name) -end function _M.rate_limit(conf, ctx, name, cost, dry_run) core.log.info("ver: ", ctx.conf_version) core.log.info("conf: ", core.json.delay_encode(conf, true)) - local lim, err = gen_limit_obj(conf, ctx, name) + local lim, err = create_limit_obj(conf, ctx, name) if not lim then core.log.error("failed to fetch limit.count object: ", err) @@ -276,7 +274,31 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) key = ctx.var["remote_addr"] end - key = gen_limit_key(conf, ctx, key) + -- copied from gen_limit_key + if conf.group then + key = conf.group .. ':' .. key + else + -- here we add a separator ':' to mark the boundary of the prefix and the key itself + -- Here we use plugin-level conf version to prevent the counter from being resetting + -- because of the change elsewhere. + -- A route which reuses a previous route's ID will inherits its counter. + local parent = conf._meta and conf._meta.parent + if not parent or not parent.resource_key then + core.log.warn("failed to generate key invalid parent, using key as is: ", core.json.encode(conf._meta)) + -- Fallback to using the key directly. + -- This ensures we don't return 500 if parent info is missing (e.g. in tests) + else + local new_key = parent.resource_key .. ':' .. apisix_plugin.conf_version(conf) + .. ':' .. 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. + key = new_key .. ':' .. conf._vid + else + key = new_key + end + end + end core.log.info("limit key: ", key) local delay, remaining, reset @@ -307,7 +329,7 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) if err == "rejected" then -- show count limit header when rejected if conf.show_limit_quota_header and set_header then - core.response.set_header(set_limit_headers.limit_header, conf.count, + core.response.set_header(set_limit_headers.limit_header, lim.limit, set_limit_headers.remaining_header, 0, set_limit_headers.reset_header, reset) end @@ -326,7 +348,7 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) end if conf.show_limit_quota_header and set_header then - core.response.set_header(set_limit_headers.limit_header, conf.count, + core.response.set_header(set_limit_headers.limit_header, lim.limit, set_limit_headers.remaining_header, remaining, set_limit_headers.reset_header, reset) end diff --git a/apisix/plugins/limit-count/limit-count-local.lua b/apisix/plugins/limit-count/limit-count-local.lua index b6f319ae0106..9ad33d89cf61 100644 --- a/apisix/plugins/limit-count/limit-count-local.lua +++ b/apisix/plugins/limit-count/limit-count-local.lua @@ -57,7 +57,9 @@ function _M.new(plugin_name, limit, window) local self = { limit_count = limit_count.new(plugin_name, limit, window), - dict = ngx.shared[plugin_name .. "-reset-header"] + dict = ngx.shared[plugin_name .. "-reset-header"], + limit = limit, + window = window, } return setmetatable(self, mt) @@ -67,8 +69,8 @@ function _M.incoming(self, key, commit, conf, cost) local delay, remaining = self.limit_count:incoming(key, commit, cost) local reset - if remaining == conf.count - cost then - reset = set_endtime(self, key, conf.time_window) + if remaining == self.limit - cost then + reset = set_endtime(self, key, self.window) else reset = read_reset(self, key) end diff --git a/t/plugin/ai-rate-limiting.t b/t/plugin/ai-rate-limiting.t index 66aa0b07f164..c6112b9ab638 100644 --- a/t/plugin/ai-rate-limiting.t +++ b/t/plugin/ai-rate-limiting.t @@ -984,3 +984,209 @@ passed Authorization: Bearer token --- error_code eval [200, 200, 200, 200, 200, 200, 200, 503, 503] + + + +=== TEST 21: use variable in count and time_window with default value +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local data = { + uri = "/ai", + plugins = { + ["ai-proxy-multi"] = { + fallback_strategy = "instance_health_and_rate_limiting", + instances = { + { + name = "deepseek", + provider = "openai", + weight = 1, + priority = 1, + auth = { + header = { + Authorization = "Bearer token" + } + }, + override = { + endpoint = "http://localhost:16724" + } + }, + { + name = "openai", + provider = "openai", + weight = 1, + priority = 0, + auth = { + header = { + Authorization = "Bearer token" + } + }, + override = { + endpoint = "http://localhost:16724" + } + } + }, + ssl_verify = false + }, + ["ai-rate-limiting"] = { + limit = "${http_count ?? 10}", + time_window = "${http_time_window ?? 60}", + instances = { + { + name = "openai", + limit = "${http_openai_count ?? 20}", + time_window = "${http_time_window ?? 60}" + } + } + } + }, + upstream = { + type = "roundrobin", + nodes = { + ["canbeanything.com"] = 1 + } + } + } + + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + core.json.encode(data) + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 22: request with default variable values +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + + local test_cases = { + { code = 200 }, + { code = 200 }, + { code = 200 }, + { code = 503 }, + } + + local httpc = http.new() + for i, case in ipairs(test_cases) do + local res = httpc:request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. "/ai", + { + method = "POST", + body = [[{ + "messages": [ + { "role": "system", "content": "You are a mathematician" }, + { "role": "user", "content": "What is 1+1?" } + ] + }]], + headers = { + ["Content-Type"] = "application/json", + } + } + ) + if res.status ~= case.code then + ngx.say( i .. "th request should return " .. case.code .. ", but got " .. res.status) + return + end + end + + ngx.say("passed") + } + } +--- request +GET /t +--- timeout: 10 +--- response_body +passed +--- grep_error_log eval +qr/picked instance: [^,]+/ +--- grep_error_log_out +picked instance: deepseek +picked instance: openai +picked instance: openai +picked instance: nil + + + +=== TEST 23: request with custom count/time_window headers +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + + local test_cases = { + { count = 20, openai_count = 30, time_window = 2, code = 200 }, + { count = 20, openai_count = 30, time_window = 2, code = 200 }, + { count = 20, openai_count = 30, time_window = 2, code = 200 }, + { count = 20, openai_count = 30, time_window = 2, code = 200 }, + { count = 20, openai_count = 30, time_window = 2, code = 200 }, + { count = 20, openai_count = 30, time_window = 2, code = 503 }, + } + + local run_tests = function() + local httpc = http.new() + for i, case in ipairs(test_cases) do + local res = httpc:request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. "/ai", + { + method = "POST", + body = [[{ + "messages": [ + { "role": "system", "content": "You are a mathematician" }, + { "role": "user", "content": "What is 1+1?" } + ] + }]], + headers = { + ["Content-Type"] = "application/json", + ["count"] = tostring(case.count), + ["time-window"] = tostring(case.time_window), + ["openai-count"] = tostring(case.openai_count), + } + } + ) + if res.status ~= case.code then + ngx.say( i .. "th request should return " .. case.code .. ", but got " .. res.status) + ngx.exit(500) + end + end + end + + run_tests() + ngx.sleep(2) + run_tests() + + ngx.say("passed") + } + } +--- request +GET /t +--- timeout: 10 +--- response_body +passed +--- grep_error_log eval +qr/picked instance: [^,]+/ +--- grep_error_log_out +picked instance: deepseek +picked instance: deepseek +picked instance: openai +picked instance: openai +picked instance: openai +picked instance: nil +picked instance: deepseek +picked instance: deepseek +picked instance: openai +picked instance: openai +picked instance: openai +picked instance: nil diff --git a/t/plugin/limit-conn-variable.t b/t/plugin/limit-conn-variable.t new file mode 100644 index 000000000000..44248c9f8549 --- /dev/null +++ b/t/plugin/limit-conn-variable.t @@ -0,0 +1,178 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +BEGIN { + if ($ENV{TEST_NGINX_CHECK_LEAK}) { + $SkipReason = "unavailable for the hup tests"; + + } else { + $ENV{TEST_NGINX_USE_HUP} = 1; + undef $ENV{TEST_NGINX_USE_STAP}; + } +} + +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); +log_level('info'); + + +add_block_preprocessor(sub { + my ($block) = @_; + my $port = $ENV{TEST_NGINX_SERVER_PORT}; + + my $config = $block->config // <<_EOC_; + location /access_root_dir { + content_by_lua_block { + local httpc = require "resty.http" + local hc = httpc:new() + + local res, err = hc:request_uri('http://127.0.0.1:$port/limit_conn', { + headers = ngx.req.get_headers() + }) + if res then + ngx.exit(res.status) + end + } + } + + location /test_concurrency { + content_by_lua_block { + local reqs = {} + for i = 1, 10 do + reqs[i] = { "/access_root_dir" } + end + local resps = { ngx.location.capture_multi(reqs) } + for i, resp in ipairs(resps) do + ngx.say(resp.status) + end + } + } +_EOC_ + + $block->set_value("config", $config); +}); + +run_tests; + +__DATA__ + +=== TEST 1: use variable in conn and burst with default value +--- 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 2: request without conn/burst headers +--- request +GET /test_concurrency +--- timeout: 10s +--- response_body +200 +200 +200 +200 +200 +200 +200 +503 +503 +503 +--- error_log +limit conn: 5, burst: 2 + + + +=== TEST 3: request with conn header +--- request +GET /test_concurrency +--- more_headers +conn: 3 +--- timeout: 10s +--- response_body +200 +200 +200 +200 +200 +503 +503 +503 +503 +503 +--- error_log +limit conn: 3, burst: 2 + + + +=== TEST 4: request with conn and burst header +--- request +GET /test_concurrency +--- more_headers +conn: 3 +burst: 4 +--- timeout: 10s +--- response_body +200 +200 +200 +200 +200 +200 +200 +503 +503 +503 +--- error_log +limit conn: 3, burst: 4 diff --git a/t/plugin/limit-count-variable.t b/t/plugin/limit-count-variable.t new file mode 100644 index 000000000000..9e27d2fa1e7f --- /dev/null +++ b/t/plugin/limit-count-variable.t @@ -0,0 +1,145 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +use t::APISIX 'no_plan'; + +no_long_string(); +no_shuffle(); +no_root_location(); +log_level('info'); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: use variable in count and time_window with default value +--- 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, + '{\ + "methods": ["GET"],\ + "plugins": {\ + "limit-count": {\ + "count": "${http_count ?? 2}",\ + "time_window": "${http_time_window ?? 5}",\ + "rejected_code": 503,\ + "key_type": "var",\ + "key": "remote_addr",\ + "policy": "local"\ + }\ + },\ + "upstream": {\ + "nodes": {\ + "127.0.0.1:1980": 1\ + },\ + "type": "roundrobin"\ + },\ + "uri": "/hello"\ + }' + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 2: request without count/time_window headers +--- pipelined_requests eval +["GET /hello", "GET /hello", "GET /hello"] +--- error_code eval +[200, 200, 503] + + + +=== TEST 3: request with count header +--- pipelined_requests eval +["GET /hello", "GET /hello", "GET /hello"] +--- more_headers +count: 5 +--- error_code eval +[200, 200, 200, 200, 200, 503] + + + +=== TEST 4: request with count and time_window header +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local core = require("apisix.core") + + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + local opt = {method = "GET", headers = { ["count"] = 3, ["time-window"] = "2" }} + local httpc = http.new() + + for i = 1, 3, 1 do + local res = httpc:request_uri(uri, opt) + if res.status ~= 200 then + ngx.say("first two requests should return 200, but got " .. res.status) + return + end + if res.headers["X-RateLimit-Limit"] ~= "3" then + ngx.say("X-RateLimit-Limit should be 3, but got " .. core.json.encode(res.headers)) + return + end + end + local res = httpc:request_uri(uri, opt) + if res.status ~= 503 then + ngx.say("third requests should return 503, but got " .. res.status) + return + end + + ngx.sleep(2) + + for i = 1, 3, 1 do + local res = httpc:request_uri(uri, opt) + if res.status ~= 200 then + ngx.say("two requests after sleep 2s should return 200, but got " .. res.status) + return + end + end + ngx.say("passed") + } + } +--- request +GET /t +--- timeout: 10 +--- response_body +passed +--- error_log +limit count: 3, time_window: 2 From c34c602d4337ad066cbae153a37a5e356f3758c3 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 11:01:29 +0545 Subject: [PATCH 02/12] feat(limit-count): support configure multiple rules Signed-off-by: Abhishek Choudhary --- apisix/plugins/limit-count/init.lua | 176 ++++++++++--- t/plugin/ai-rate-limiting-variable.t | 158 +++++++++++ t/plugin/limit-count-rules.t | 380 +++++++++++++++++++++++++++ t/plugin/limit-count-variable.t | 3 +- 4 files changed, 674 insertions(+), 43 deletions(-) create mode 100644 t/plugin/ai-rate-limiting-variable.t create mode 100644 t/plugin/limit-count-rules.t diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua index 17ba8940999c..5ed7f3e3d73e 100644 --- a/apisix/plugins/limit-count/init.lua +++ b/apisix/plugins/limit-count/init.lua @@ -25,6 +25,7 @@ local get_phase = ngx.get_phase local tonumber = tonumber local type = type local tostring = tostring +local str_format = string.format local limit_redis_cluster_new local limit_redis_new @@ -85,6 +86,28 @@ local schema = { {type = "string"}, }, }, + rules = { + type = "array", + items = { + type = "object", + properties = { + count = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, + time_window = { + oneOf = { + {type = "integer", exclusiveMinimum = 0}, + {type = "string"}, + }, + }, + key = {type = "string"}, + }, + required = {"count", "time_window", "key"}, + }, + }, group = {type = "string"}, key = {type = "string", default = "remote_addr"}, key_type = {type = "string", @@ -105,7 +128,14 @@ local schema = { allow_degradation = {type = "boolean", default = false}, show_limit_quota_header = {type = "boolean", default = true} }, - required = {"count", "time_window"}, + oneOf = { + { + required = {"count", "time_window"}, + }, + { + required = {"rules"}, + } + }, ["if"] = { properties = { policy = { @@ -183,51 +213,34 @@ function _M.check_schema(conf, schema_type) end end - return true -end - - -local function create_limit_obj(conf, ctx, plugin_name) - core.log.info("create new " .. plugin_name .. " plugin instance") - - local count = conf.count - if type(count) == "string" then - local err, _ - count, err, _ = core.utils.resolve_var(count, ctx.var) - if err then - return nil, "could not resolve vars in count: " .. err - end - count = tonumber(count) - if not count then - return nil, "resolved count is not a number: " .. tostring(count) + local keys = {} + for _, rule in ipairs(conf.rules or {}) do + if keys[rule.key] then + return false, str_format("duplicate key '%s' in rules", rule.key) end + keys[rule.key] = true end - local time_window = conf.time_window - if type(time_window) == "string" then - local err, _ - time_window, err, _ = core.utils.resolve_var(time_window, ctx.var) - if err then - return nil, "could not resolve vars in time_window: " .. err - end - time_window = tonumber(time_window) - if not time_window then - return nil, "resolved time_window is not a number: " .. tostring(time_window) - end - end + return true +end + - core.log.info("limit count: ", count, ", time_window: ", time_window) +local function create_limit_obj(conf, rule, plugin_name) + core.log.info("create new " .. plugin_name .. " plugin instance", + ", rule: ", core.json.delay_encode(rule, true)) if not conf.policy or conf.policy == "local" then - return limit_local_new("plugin-" .. plugin_name, count, time_window) + return limit_local_new("plugin-" .. plugin_name, rule.count, + rule.time_window) end if conf.policy == "redis" then - return limit_redis_new("plugin-" .. plugin_name, count, time_window, conf) + return limit_redis_new("plugin-" .. plugin_name, rule.count, rule.time_window, conf) end if conf.policy == "redis-cluster" then - return limit_redis_cluster_new("plugin-" .. plugin_name, count, time_window, conf) + return limit_redis_cluster_new("plugin-" .. plugin_name, rule.count, + rule.time_window, conf) end return nil @@ -236,11 +249,71 @@ end -function _M.rate_limit(conf, ctx, name, cost, dry_run) - core.log.info("ver: ", ctx.conf_version) - core.log.info("conf: ", core.json.delay_encode(conf, true)) +local function resolve_var(ctx, value) + if type(value) == "string" then + 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 + end + value = tonumber(value) + if not value then + return nil, "resolved value is not a number: " .. tostring(value) + end + end + return value +end - local lim, err = create_limit_obj(conf, ctx, name) + +local function get_rules(ctx, conf) + if not conf.rules then + local count, err = resolve_var(ctx, conf.count) + if err then + return nil, err + end + local time_window, err2 = resolve_var(ctx, conf.time_window) + if err2 then + return nil, err2 + end + return { + { + count = count, + time_window = time_window, + key = conf.key, + key_type = conf.key_type, + } + } + end + + local rules = {} + for _, rule in ipairs(conf.rules) do + local count, err = resolve_var(ctx, rule.count) + if err then + goto CONTINUE + end + local time_window, err2 = resolve_var(ctx, rule.time_window) + if err2 then + goto CONTINUE + end + local key, _, n_resolved = core.utils.resolve_var(rule.key, ctx.var) + if n_resolved == 0 then + goto CONTINUE + end + core.table.insert(rules, { + count = count, + time_window = time_window, + key_type = "constant", + key = key, + }) + + ::CONTINUE:: + end + return rules +end + + +local function run_rate_limit(conf, rule, ctx, name, cost, dry_run) + local lim, err = create_limit_obj(conf, rule, name) if not lim then core.log.error("failed to fetch limit.count object: ", err) @@ -250,9 +323,9 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) return 500 end - local conf_key = conf.key + local conf_key = rule.key local key - if conf.key_type == "var_combination" then + if rule.key_type == "var_combination" then local err, n_resolved key, err, n_resolved = core.utils.resolve_var(conf_key, ctx.var) if err then @@ -262,7 +335,7 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) if n_resolved == 0 then key = nil end - elseif conf.key_type == "constant" then + elseif rule.key_type == "constant" then key = conf_key else key = ctx.var[conf_key] @@ -355,4 +428,25 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) end +function _M.rate_limit(conf, ctx, name, cost, dry_run) + core.log.info("ver: ", ctx.conf_version) + + local rules, err = get_rules(ctx, conf) + if not rules or #rules == 0 then + core.log.error("failed to get rate limit rules: ", err) + if conf.allow_degradation then + return + end + return 500 + end + + for _, rule in ipairs(rules) do + local code, msg = run_rate_limit(conf, rule, ctx, name, cost, dry_run) + if code then + return code, msg + end + end +end + + return _M diff --git a/t/plugin/ai-rate-limiting-variable.t b/t/plugin/ai-rate-limiting-variable.t new file mode 100644 index 000000000000..20b8c1b1febf --- /dev/null +++ b/t/plugin/ai-rate-limiting-variable.t @@ -0,0 +1,158 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_shuffle(); +no_root_location(); + + +run_tests; + +__DATA__ + +=== TEST 1: use variable in count and time_window with default value +--- 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": "/ai",\ + "plugins": {\ + "ai-proxy-multi": {\ + "fallback_strategy": "instance_health_and_rate_limiting",\ + "instances": [\ + {\ + "name": "deepseek",\ + "provider": "openai",\ + "weight": 1,\ + "priority": 1,\ + "auth": {\ + "header": {\ + "Authorization": "Bearer token"\ + }\ + },\ + "override": {\ + "endpoint": "http://localhost:16724"\ + }\ + },\ + {\ + "name": "openai",\ + "provider": "openai",\ + "weight": 1,\ + "priority": 0,\ + "auth": {\ + "header": {\ + "Authorization": "Bearer token"\ + }\ + },\ + "override": {\ + "endpoint": "http://localhost:16724"\ + }\ + }\ + ],\ + "ssl_verify": false\ + },\ + "ai-rate-limiting": {\ + "limit": "${http_count ?? 10}",\ + "time_window": "${http_time_window ?? 60}",\ + "instances": [\ + {\ + "name": "openai",\ + "limit": "${http_openai_count ?? 20}",\ + "time_window": "${http_time_window ?? 60}"\ + }\ + ]\ + }\ + },\ + "upstream": {\ + "type": "roundrobin",\ + "nodes": {\ + "canbeanything.com": 1\ + }\ + }\ + }' + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed + + + +=== TEST 2: request with default variable values +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + + local test_cases = { + { code = 200 }, + { code = 200 }, + { code = 200 }, + { code = 503 }, + } + + local httpc = http.new() + for i, case in ipairs(test_cases) do + local res = httpc:request_uri( + "http://127.0.0.1:" .. ngx.var.server_port .. "/ai", + { + method = "POST", + body = '{\ + "messages": [\ + { "role": "system", "content": "You are a mathematician" },\ + { "role": "user", "content": "What is 1+1?" }\ + ]\ + }', + headers = { + ["Content-Type"] = "application/json", + } + } + ) + if res.status ~= case.code then + ngx.say( i .. "th request should return " .. case.code .. ", but got " .. res.status) + return + end + end + + ngx.say("passed") + } + } +--- request +GET /t +--- timeout: 10 +--- response_body +passed +--- grep_error_log eval +qr/picked instance: [^,]+/ +--- grep_error_log_out +picked instance: deepseek +picked instance: openai +picked instance: openai +picked instance: nil diff --git a/t/plugin/limit-count-rules.t b/t/plugin/limit-count-rules.t new file mode 100644 index 000000000000..cda541e65c01 --- /dev/null +++ b/t/plugin/limit-count-rules.t @@ -0,0 +1,380 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +use t::APISIX 'no_plan'; + +no_long_string(); +no_shuffle(); +no_root_location(); +log_level('info'); + +add_block_preprocessor(sub { + my ($block) = @_; + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->error_log && !$block->no_error_log) { + $block->set_value("no_error_log", "[error]\n[alert]"); + } +}); + +run_tests; + +__DATA__ + +=== TEST 1: configure count/time_window and rules at same time +--- 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, + [[{ + "methods": ["GET"], + "plugins": { + "limit-count": { + "count": 2, + "time_window": 5, + "rejected_code": 503, + "key_type": "var", + "key": "remote_addr", + "rules": [ + { + "count": 1, + "time_window": 10, + "key": "${http_company}" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.print(body) + } + } +--- error_code: 400 +--- response_body +{"error_msg":"failed to check the configuration of plugin limit-count err: value should match only one schema, but matches both schemas 1 and 2"} + + + +=== TEST 2: configure multiple rules with same key +--- 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, + [[{ + "methods": ["GET"], + "plugins": { + "limit-count": { + "rejected_code": 503, + "rules": [ + { + "count": 5, + "time_window": 10, + "key": "${http_company}" + }, + { + "count": 8, + "time_window": 20, + "key": "${http_company}" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.print(body) + } + } +--- error_code: 400 +--- response_body +{"error_msg":"failed to check the configuration of plugin limit-count err: duplicate key '${http_company}' in rules"} + + + +=== TEST 3: setup route with rules +--- 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, + [[{ + "methods": ["GET"], + "plugins": { + "limit-count": { + "rejected_code": 503, + "rejected_msg" : "rejected", + "rules": [ + { + "key": "${http_user}", + "count": "${http_jack_count}", + "time_window": 60 + }, + { + "key": "${http_project}", + "count": "${http_apisix_count}", + "time_window": 60 + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 4: no any rule matched +--- request +GET /hello +--- error_code: 500 +--- error_log +failed to get rate limit rules + + + +=== TEST 5: match user rule +--- pipelined_requests eval +["GET /hello", "GET /hello", "GET /hello"] +--- more_headers +user: jack +jack-count: 2 +--- error_code eval +[200, 200, 503] +--- response_body eval +["hello world\n", "hello world\n", "{\"error_msg\":\"rejected\"}\n"] + + + +=== TEST 6: match project rule +--- pipelined_requests eval +["GET /hello", "GET /hello"] +--- more_headers +project: apisix +apisix-count: 3 +--- error_code eval +[200, 200, 200, 503] +--- response_body eval +["hello world\n", "hello world\n", "hello world\n", "{\"error_msg\":\"rejected\"}\n"] + + + +=== TEST 7: setup route with rules with variables with default values +--- 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, + [[{ + "methods": ["GET"], + "plugins": { + "limit-count": { + "rejected_code": 503, + "rejected_msg" : "rejected", + "rules": [ + { + "count": "${http_count ?? 2}", + "time_window": "${http_tw ?? 5}", + "key": "${remote_addr}" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 8: rules with variables in count - default value +--- pipelined_requests eval +["GET /hello", "GET /hello", "GET /hello"] +--- error_code eval +[200, 200, 503] +--- response_body eval +["hello world\n", "hello world\n", "{\"error_msg\":\"rejected\"}\n"] + + + +=== TEST 9: rules with variables in count - with header +--- setup + ngx.sleep(5) +--- pipelined_requests eval +["GET /hello", "GET /hello"] +--- more_headers +count: 1 +--- error_code eval +[200, 503] +--- response_body eval +["hello world\n", "{\"error_msg\":\"rejected\"}\n"] + + + +=== TEST 10: rules with same key and custom headers +--- 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, + [[{ + "methods": ["GET"], + "plugins": { + "limit-count": { + "rejected_code": 503, + "rejected_msg" : "rejected", + "show_limit_quota_header": true, + "rules": [ + { + "count": 2, + "time_window": 2, + "key": "${remote_addr}_2s" + }, + { + "count": 3, + "time_window": 5, + "key": "${remote_addr}_5s" + } + ] + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- response_body +passed + + + +=== TEST 11: test rules with same key +--- config + location /t { + content_by_lua_block { + local http = require("resty.http") + local httpc = http.new() + local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" + + for i = 1, 2, 1 do + local res = httpc:request_uri(uri) + if res.status ~= 200 then + ngx.say("first two requests failed, status: " .. res.status) + return + end + end + + -- req 3, rejected by rule 1 + res = httpc:request_uri(uri) + if res.status ~= 503 then + ngx.say("req 3 should be rejected by rule 1, but got status: ", res.status) + return + end + + ngx.sleep(2) + + -- req 4, after sleep + res = httpc:request_uri(uri) + if res.status ~= 200 then + ngx.say("req 4 failed, status: ", res.status) + return + end + + -- req 5, rejected by rule 2 + res = httpc:request_uri(uri) + if res.status ~= 503 then + ngx.say("req 5 should be rejected by rule 2, but got status: ", res.status) + return + end + + ngx.say("passed") + } + } +--- request +GET /t +--- response_body +passed diff --git a/t/plugin/limit-count-variable.t b/t/plugin/limit-count-variable.t index 9e27d2fa1e7f..97439430f1cc 100644 --- a/t/plugin/limit-count-variable.t +++ b/t/plugin/limit-count-variable.t @@ -141,5 +141,4 @@ GET /t --- timeout: 10 --- response_body passed ---- error_log -limit count: 3, time_window: 2 + From 59c25026275ab5e5574a6dfc92db51c294b9e3b2 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 12:39:55 +0545 Subject: [PATCH 03/12] rm Signed-off-by: Abhishek Choudhary --- t/plugin/ai-rate-limiting-variable.t | 158 --------------------------- 1 file changed, 158 deletions(-) delete mode 100644 t/plugin/ai-rate-limiting-variable.t diff --git a/t/plugin/ai-rate-limiting-variable.t b/t/plugin/ai-rate-limiting-variable.t deleted file mode 100644 index 20b8c1b1febf..000000000000 --- a/t/plugin/ai-rate-limiting-variable.t +++ /dev/null @@ -1,158 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -use t::APISIX 'no_plan'; - -repeat_each(1); -no_long_string(); -no_shuffle(); -no_root_location(); - - -run_tests; - -__DATA__ - -=== TEST 1: use variable in count and time_window with default value ---- 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": "/ai",\ - "plugins": {\ - "ai-proxy-multi": {\ - "fallback_strategy": "instance_health_and_rate_limiting",\ - "instances": [\ - {\ - "name": "deepseek",\ - "provider": "openai",\ - "weight": 1,\ - "priority": 1,\ - "auth": {\ - "header": {\ - "Authorization": "Bearer token"\ - }\ - },\ - "override": {\ - "endpoint": "http://localhost:16724"\ - }\ - },\ - {\ - "name": "openai",\ - "provider": "openai",\ - "weight": 1,\ - "priority": 0,\ - "auth": {\ - "header": {\ - "Authorization": "Bearer token"\ - }\ - },\ - "override": {\ - "endpoint": "http://localhost:16724"\ - }\ - }\ - ],\ - "ssl_verify": false\ - },\ - "ai-rate-limiting": {\ - "limit": "${http_count ?? 10}",\ - "time_window": "${http_time_window ?? 60}",\ - "instances": [\ - {\ - "name": "openai",\ - "limit": "${http_openai_count ?? 20}",\ - "time_window": "${http_time_window ?? 60}"\ - }\ - ]\ - }\ - },\ - "upstream": {\ - "type": "roundrobin",\ - "nodes": {\ - "canbeanything.com": 1\ - }\ - }\ - }' - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- request -GET /t ---- response_body -passed - - - -=== TEST 2: request with default variable values ---- config - location /t { - content_by_lua_block { - local http = require("resty.http") - - local test_cases = { - { code = 200 }, - { code = 200 }, - { code = 200 }, - { code = 503 }, - } - - local httpc = http.new() - for i, case in ipairs(test_cases) do - local res = httpc:request_uri( - "http://127.0.0.1:" .. ngx.var.server_port .. "/ai", - { - method = "POST", - body = '{\ - "messages": [\ - { "role": "system", "content": "You are a mathematician" },\ - { "role": "user", "content": "What is 1+1?" }\ - ]\ - }', - headers = { - ["Content-Type"] = "application/json", - } - } - ) - if res.status ~= case.code then - ngx.say( i .. "th request should return " .. case.code .. ", but got " .. res.status) - return - end - end - - ngx.say("passed") - } - } ---- request -GET /t ---- timeout: 10 ---- response_body -passed ---- grep_error_log eval -qr/picked instance: [^,]+/ ---- grep_error_log_out -picked instance: deepseek -picked instance: openai -picked instance: openai -picked instance: nil From 6b26f1c40372b5fd5a8455973b74ec7729731f44 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 12:41:39 +0545 Subject: [PATCH 04/12] Revert "feat(limit-count): support configure multiple rules" This reverts commit c34c602d4337ad066cbae153a37a5e356f3758c3. --- apisix/plugins/limit-count/init.lua | 176 +++---------- t/plugin/limit-count-rules.t | 380 ---------------------------- t/plugin/limit-count-variable.t | 3 +- 3 files changed, 43 insertions(+), 516 deletions(-) delete mode 100644 t/plugin/limit-count-rules.t diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua index 5ed7f3e3d73e..17ba8940999c 100644 --- a/apisix/plugins/limit-count/init.lua +++ b/apisix/plugins/limit-count/init.lua @@ -25,7 +25,6 @@ local get_phase = ngx.get_phase local tonumber = tonumber local type = type local tostring = tostring -local str_format = string.format local limit_redis_cluster_new local limit_redis_new @@ -86,28 +85,6 @@ local schema = { {type = "string"}, }, }, - rules = { - type = "array", - items = { - type = "object", - properties = { - count = { - oneOf = { - {type = "integer", exclusiveMinimum = 0}, - {type = "string"}, - }, - }, - time_window = { - oneOf = { - {type = "integer", exclusiveMinimum = 0}, - {type = "string"}, - }, - }, - key = {type = "string"}, - }, - required = {"count", "time_window", "key"}, - }, - }, group = {type = "string"}, key = {type = "string", default = "remote_addr"}, key_type = {type = "string", @@ -128,14 +105,7 @@ local schema = { allow_degradation = {type = "boolean", default = false}, show_limit_quota_header = {type = "boolean", default = true} }, - oneOf = { - { - required = {"count", "time_window"}, - }, - { - required = {"rules"}, - } - }, + required = {"count", "time_window"}, ["if"] = { properties = { policy = { @@ -213,34 +183,51 @@ function _M.check_schema(conf, schema_type) end end - local keys = {} - for _, rule in ipairs(conf.rules or {}) do - if keys[rule.key] then - return false, str_format("duplicate key '%s' in rules", rule.key) - end - keys[rule.key] = true - end - return true end -local function create_limit_obj(conf, rule, plugin_name) - core.log.info("create new " .. plugin_name .. " plugin instance", - ", rule: ", core.json.delay_encode(rule, true)) +local function create_limit_obj(conf, ctx, plugin_name) + core.log.info("create new " .. plugin_name .. " plugin instance") + + local count = conf.count + if type(count) == "string" then + local err, _ + count, err, _ = core.utils.resolve_var(count, ctx.var) + if err then + return nil, "could not resolve vars in count: " .. err + end + count = tonumber(count) + if not count then + return nil, "resolved count is not a number: " .. tostring(count) + end + end + + local time_window = conf.time_window + if type(time_window) == "string" then + local err, _ + time_window, err, _ = core.utils.resolve_var(time_window, ctx.var) + if err then + return nil, "could not resolve vars in time_window: " .. err + end + time_window = tonumber(time_window) + if not time_window then + return nil, "resolved time_window is not a number: " .. tostring(time_window) + end + end + + core.log.info("limit count: ", count, ", time_window: ", time_window) if not conf.policy or conf.policy == "local" then - return limit_local_new("plugin-" .. plugin_name, rule.count, - rule.time_window) + return limit_local_new("plugin-" .. plugin_name, count, time_window) end if conf.policy == "redis" then - return limit_redis_new("plugin-" .. plugin_name, rule.count, rule.time_window, conf) + return limit_redis_new("plugin-" .. plugin_name, count, time_window, conf) end if conf.policy == "redis-cluster" then - return limit_redis_cluster_new("plugin-" .. plugin_name, rule.count, - rule.time_window, conf) + return limit_redis_cluster_new("plugin-" .. plugin_name, count, time_window, conf) end return nil @@ -249,71 +236,11 @@ end -local function resolve_var(ctx, value) - if type(value) == "string" then - 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 - end - value = tonumber(value) - if not value then - return nil, "resolved value is not a number: " .. tostring(value) - end - end - return value -end - - -local function get_rules(ctx, conf) - if not conf.rules then - local count, err = resolve_var(ctx, conf.count) - if err then - return nil, err - end - local time_window, err2 = resolve_var(ctx, conf.time_window) - if err2 then - return nil, err2 - end - return { - { - count = count, - time_window = time_window, - key = conf.key, - key_type = conf.key_type, - } - } - end - - local rules = {} - for _, rule in ipairs(conf.rules) do - local count, err = resolve_var(ctx, rule.count) - if err then - goto CONTINUE - end - local time_window, err2 = resolve_var(ctx, rule.time_window) - if err2 then - goto CONTINUE - end - local key, _, n_resolved = core.utils.resolve_var(rule.key, ctx.var) - if n_resolved == 0 then - goto CONTINUE - end - core.table.insert(rules, { - count = count, - time_window = time_window, - key_type = "constant", - key = key, - }) - - ::CONTINUE:: - end - return rules -end - +function _M.rate_limit(conf, ctx, name, cost, dry_run) + core.log.info("ver: ", ctx.conf_version) + core.log.info("conf: ", core.json.delay_encode(conf, true)) -local function run_rate_limit(conf, rule, ctx, name, cost, dry_run) - local lim, err = create_limit_obj(conf, rule, name) + local lim, err = create_limit_obj(conf, ctx, name) if not lim then core.log.error("failed to fetch limit.count object: ", err) @@ -323,9 +250,9 @@ local function run_rate_limit(conf, rule, ctx, name, cost, dry_run) return 500 end - local conf_key = rule.key + local conf_key = conf.key local key - if rule.key_type == "var_combination" then + if conf.key_type == "var_combination" then local err, n_resolved key, err, n_resolved = core.utils.resolve_var(conf_key, ctx.var) if err then @@ -335,7 +262,7 @@ local function run_rate_limit(conf, rule, ctx, name, cost, dry_run) if n_resolved == 0 then key = nil end - elseif rule.key_type == "constant" then + elseif conf.key_type == "constant" then key = conf_key else key = ctx.var[conf_key] @@ -428,25 +355,4 @@ local function run_rate_limit(conf, rule, ctx, name, cost, dry_run) end -function _M.rate_limit(conf, ctx, name, cost, dry_run) - core.log.info("ver: ", ctx.conf_version) - - local rules, err = get_rules(ctx, conf) - if not rules or #rules == 0 then - core.log.error("failed to get rate limit rules: ", err) - if conf.allow_degradation then - return - end - return 500 - end - - for _, rule in ipairs(rules) do - local code, msg = run_rate_limit(conf, rule, ctx, name, cost, dry_run) - if code then - return code, msg - end - end -end - - return _M diff --git a/t/plugin/limit-count-rules.t b/t/plugin/limit-count-rules.t deleted file mode 100644 index cda541e65c01..000000000000 --- a/t/plugin/limit-count-rules.t +++ /dev/null @@ -1,380 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -use t::APISIX 'no_plan'; - -no_long_string(); -no_shuffle(); -no_root_location(); -log_level('info'); - -add_block_preprocessor(sub { - my ($block) = @_; - - if (!$block->request) { - $block->set_value("request", "GET /t"); - } - - if (!$block->error_log && !$block->no_error_log) { - $block->set_value("no_error_log", "[error]\n[alert]"); - } -}); - -run_tests; - -__DATA__ - -=== TEST 1: configure count/time_window and rules at same time ---- 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, - [[{ - "methods": ["GET"], - "plugins": { - "limit-count": { - "count": 2, - "time_window": 5, - "rejected_code": 503, - "key_type": "var", - "key": "remote_addr", - "rules": [ - { - "count": 1, - "time_window": 10, - "key": "${http_company}" - } - ] - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.print(body) - } - } ---- error_code: 400 ---- response_body -{"error_msg":"failed to check the configuration of plugin limit-count err: value should match only one schema, but matches both schemas 1 and 2"} - - - -=== TEST 2: configure multiple rules with same key ---- 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, - [[{ - "methods": ["GET"], - "plugins": { - "limit-count": { - "rejected_code": 503, - "rules": [ - { - "count": 5, - "time_window": 10, - "key": "${http_company}" - }, - { - "count": 8, - "time_window": 20, - "key": "${http_company}" - } - ] - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.print(body) - } - } ---- error_code: 400 ---- response_body -{"error_msg":"failed to check the configuration of plugin limit-count err: duplicate key '${http_company}' in rules"} - - - -=== TEST 3: setup route with rules ---- 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, - [[{ - "methods": ["GET"], - "plugins": { - "limit-count": { - "rejected_code": 503, - "rejected_msg" : "rejected", - "rules": [ - { - "key": "${http_user}", - "count": "${http_jack_count}", - "time_window": 60 - }, - { - "key": "${http_project}", - "count": "${http_apisix_count}", - "time_window": 60 - } - ] - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- response_body -passed - - - -=== TEST 4: no any rule matched ---- request -GET /hello ---- error_code: 500 ---- error_log -failed to get rate limit rules - - - -=== TEST 5: match user rule ---- pipelined_requests eval -["GET /hello", "GET /hello", "GET /hello"] ---- more_headers -user: jack -jack-count: 2 ---- error_code eval -[200, 200, 503] ---- response_body eval -["hello world\n", "hello world\n", "{\"error_msg\":\"rejected\"}\n"] - - - -=== TEST 6: match project rule ---- pipelined_requests eval -["GET /hello", "GET /hello"] ---- more_headers -project: apisix -apisix-count: 3 ---- error_code eval -[200, 200, 200, 503] ---- response_body eval -["hello world\n", "hello world\n", "hello world\n", "{\"error_msg\":\"rejected\"}\n"] - - - -=== TEST 7: setup route with rules with variables with default values ---- 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, - [[{ - "methods": ["GET"], - "plugins": { - "limit-count": { - "rejected_code": 503, - "rejected_msg" : "rejected", - "rules": [ - { - "count": "${http_count ?? 2}", - "time_window": "${http_tw ?? 5}", - "key": "${remote_addr}" - } - ] - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- response_body -passed - - - -=== TEST 8: rules with variables in count - default value ---- pipelined_requests eval -["GET /hello", "GET /hello", "GET /hello"] ---- error_code eval -[200, 200, 503] ---- response_body eval -["hello world\n", "hello world\n", "{\"error_msg\":\"rejected\"}\n"] - - - -=== TEST 9: rules with variables in count - with header ---- setup - ngx.sleep(5) ---- pipelined_requests eval -["GET /hello", "GET /hello"] ---- more_headers -count: 1 ---- error_code eval -[200, 503] ---- response_body eval -["hello world\n", "{\"error_msg\":\"rejected\"}\n"] - - - -=== TEST 10: rules with same key and custom headers ---- 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, - [[{ - "methods": ["GET"], - "plugins": { - "limit-count": { - "rejected_code": 503, - "rejected_msg" : "rejected", - "show_limit_quota_header": true, - "rules": [ - { - "count": 2, - "time_window": 2, - "key": "${remote_addr}_2s" - }, - { - "count": 3, - "time_window": 5, - "key": "${remote_addr}_5s" - } - ] - } - }, - "upstream": { - "nodes": { - "127.0.0.1:1980": 1 - }, - "type": "roundrobin" - }, - "uri": "/hello" - }]] - ) - - if code >= 300 then - ngx.status = code - end - ngx.say(body) - } - } ---- response_body -passed - - - -=== TEST 11: test rules with same key ---- config - location /t { - content_by_lua_block { - local http = require("resty.http") - local httpc = http.new() - local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello" - - for i = 1, 2, 1 do - local res = httpc:request_uri(uri) - if res.status ~= 200 then - ngx.say("first two requests failed, status: " .. res.status) - return - end - end - - -- req 3, rejected by rule 1 - res = httpc:request_uri(uri) - if res.status ~= 503 then - ngx.say("req 3 should be rejected by rule 1, but got status: ", res.status) - return - end - - ngx.sleep(2) - - -- req 4, after sleep - res = httpc:request_uri(uri) - if res.status ~= 200 then - ngx.say("req 4 failed, status: ", res.status) - return - end - - -- req 5, rejected by rule 2 - res = httpc:request_uri(uri) - if res.status ~= 503 then - ngx.say("req 5 should be rejected by rule 2, but got status: ", res.status) - return - end - - ngx.say("passed") - } - } ---- request -GET /t ---- response_body -passed diff --git a/t/plugin/limit-count-variable.t b/t/plugin/limit-count-variable.t index 97439430f1cc..9e27d2fa1e7f 100644 --- a/t/plugin/limit-count-variable.t +++ b/t/plugin/limit-count-variable.t @@ -141,4 +141,5 @@ GET /t --- timeout: 10 --- response_body passed - +--- error_log +limit count: 3, time_window: 2 From f39932af9c9aff82d87740bd303137cb6f7bd7a6 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 12:58:25 +0545 Subject: [PATCH 05/12] lint Signed-off-by: Abhishek Choudhary --- apisix/plugins/limit-count/init.lua | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua index 17ba8940999c..f76b16a78a2f 100644 --- a/apisix/plugins/limit-count/init.lua +++ b/apisix/plugins/limit-count/init.lua @@ -39,9 +39,6 @@ do local cluster_src = "apisix.plugins.limit-count.limit-count-redis-cluster" limit_redis_cluster_new = require(cluster_src).new end -local lrucache = core.lrucache.new({ - type = 'plugin', serial_creating = true, -}) local group_conf_lru = core.lrucache.new({ type = 'plugin', }) @@ -284,7 +281,8 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) -- A route which reuses a previous route's ID will inherits its counter. local parent = conf._meta and conf._meta.parent if not parent or not parent.resource_key then - core.log.warn("failed to generate key invalid parent, using key as is: ", core.json.encode(conf._meta)) + core.log.warn("failed to generate key invalid parent, using key as is: ", + core.json.encode(conf._meta)) -- Fallback to using the key directly. -- This ensures we don't return 500 if parent info is missing (e.g. in tests) else From 418201d484a7b9ad3ea593ed0cb62095f1de691c Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 13:06:18 +0545 Subject: [PATCH 06/12] f Signed-off-by: Abhishek Choudhary --- t/plugin/limit-conn-variable.t | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/t/plugin/limit-conn-variable.t b/t/plugin/limit-conn-variable.t index 44248c9f8549..8c82100e48ef 100644 --- a/t/plugin/limit-conn-variable.t +++ b/t/plugin/limit-conn-variable.t @@ -81,24 +81,24 @@ __DATA__ 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"\ - }' + [[{ + "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 From 9047f42cab521448c89f269458a0acc20c985d53 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 13:07:55 +0545 Subject: [PATCH 07/12] test Signed-off-by: Abhishek Choudhary --- t/plugin/limit-conn.t | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/t/plugin/limit-conn.t b/t/plugin/limit-conn.t index 0182019ee6d4..364282f49cfd 100644 --- a/t/plugin/limit-conn.t +++ b/t/plugin/limit-conn.t @@ -362,7 +362,7 @@ GET /t GET /t --- error_code: 400 --- response_body -{"error_msg":"failed to check the configuration of plugin limit-conn err: property \"conn\" validation failed: expected -1 to be greater than 0"} +{"error_msg":"failed to check the configuration of plugin limit-conn err: property \"conn\" validation failed: value should match only one schema, but matches none"} @@ -441,7 +441,7 @@ GET /t GET /t --- error_code: 400 --- response_body -{"error_msg":"failed to check the configuration of plugin limit-conn err: property \"conn\" validation failed: expected -1 to be greater than 0"} +{"error_msg":"failed to check the configuration of plugin limit-conn err: property \"conn\" validation failed: value should match only one schema, but matches none"} @@ -868,7 +868,7 @@ GET /test_concurrency --- request GET /t --- response_body -property "conn" validation failed: expected 0 to be greater than 0 +property "conn" validation failed: value should match only one schema, but matches none property "default_conn_delay" validation failed: expected 0 to be greater than 0 done From 76ded12e97d34f3152795bd7a300029193087a83 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 13:08:38 +0545 Subject: [PATCH 08/12] fix Signed-off-by: Abhishek Choudhary --- apisix/plugins/limit-conn/init.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/apisix/plugins/limit-conn/init.lua b/apisix/plugins/limit-conn/init.lua index 0789b17ebb70..092a5a77e460 100644 --- a/apisix/plugins/limit-conn/init.lua +++ b/apisix/plugins/limit-conn/init.lua @@ -37,7 +37,6 @@ do end - local _M = {} From 4f143e5aa65922988130990ac742a4ade5242319 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 13:13:05 +0545 Subject: [PATCH 09/12] fx Signed-off-by: Abhishek Choudhary --- t/plugin/limit-count-variable.t | 40 ++++++++++++++++----------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/t/plugin/limit-count-variable.t b/t/plugin/limit-count-variable.t index 9e27d2fa1e7f..ad021c2d5284 100644 --- a/t/plugin/limit-count-variable.t +++ b/t/plugin/limit-count-variable.t @@ -45,26 +45,26 @@ __DATA__ local t = require("lib.test_admin").test local code, body = t('/apisix/admin/routes/1', ngx.HTTP_PUT, - '{\ - "methods": ["GET"],\ - "plugins": {\ - "limit-count": {\ - "count": "${http_count ?? 2}",\ - "time_window": "${http_time_window ?? 5}",\ - "rejected_code": 503,\ - "key_type": "var",\ - "key": "remote_addr",\ - "policy": "local"\ - }\ - },\ - "upstream": {\ - "nodes": {\ - "127.0.0.1:1980": 1\ - },\ - "type": "roundrobin"\ - },\ - "uri": "/hello"\ - }' + [[{ + "methods": ["GET"], + "plugins": { + "limit-count": { + "count": "${http_count ?? 2}", + "time_window": "${http_time_window ?? 5}", + "rejected_code": 503, + "key_type": "var", + "key": "remote_addr", + "policy": "local" + } + }, + "upstream": { + "nodes": { + "127.0.0.1:1980": 1 + }, + "type": "roundrobin" + }, + "uri": "/hello" + }]] ) if code >= 300 then From c682bee4774a34b884f2c175e52c05c799d3b148 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 14:12:33 +0545 Subject: [PATCH 10/12] fix Signed-off-by: Abhishek Choudhary --- apisix/plugins/limit-count/init.lua | 54 +++++++++++++++-------------- t/plugin/limit-count.t | 12 +++---- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua index f76b16a78a2f..f8c0bfe154f0 100644 --- a/apisix/plugins/limit-count/init.lua +++ b/apisix/plugins/limit-count/init.lua @@ -232,6 +232,32 @@ end +local function gen_limit_key(conf, ctx, key) + if conf.group then + return conf.group .. ':' .. key + end + + -- here we add a separator ':' to mark the boundary of the prefix and the key itself + -- Here we use plugin-level conf version to prevent the counter from being resetting + -- because of the change elsewhere. + -- A route which reuses a previous route's ID will inherits its counter. + local parent = conf._meta and conf._meta.parent + if not parent or not parent.resource_key then + core.log.error("failed to generate key invalid parent: ", core.json.encode(parent)) + return nil + end + + local new_key = parent.resource_key .. ':' .. apisix_plugin.conf_version(conf) + .. ':' .. 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. + return new_key .. ':' .. conf._vid + end + + return new_key +end + function _M.rate_limit(conf, ctx, name, cost, dry_run) core.log.info("ver: ", ctx.conf_version) @@ -271,32 +297,8 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) key = ctx.var["remote_addr"] end - -- copied from gen_limit_key - if conf.group then - key = conf.group .. ':' .. key - else - -- here we add a separator ':' to mark the boundary of the prefix and the key itself - -- Here we use plugin-level conf version to prevent the counter from being resetting - -- because of the change elsewhere. - -- A route which reuses a previous route's ID will inherits its counter. - local parent = conf._meta and conf._meta.parent - if not parent or not parent.resource_key then - core.log.warn("failed to generate key invalid parent, using key as is: ", - core.json.encode(conf._meta)) - -- Fallback to using the key directly. - -- This ensures we don't return 500 if parent info is missing (e.g. in tests) - else - local new_key = parent.resource_key .. ':' .. apisix_plugin.conf_version(conf) - .. ':' .. 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. - key = new_key .. ':' .. conf._vid - else - key = new_key - end - end - end + core.log.info("key bef: ", key, ". conf: ", core.json.encode(conf), ". ctx: ", type(ctx)) + key = gen_limit_key(conf, ctx, key) core.log.info("limit key: ", key) local delay, remaining, reset diff --git a/t/plugin/limit-count.t b/t/plugin/limit-count.t index f150c01d020d..402c93dd2008 100644 --- a/t/plugin/limit-count.t +++ b/t/plugin/limit-count.t @@ -253,7 +253,7 @@ passed } --- error_code: 400 --- response_body -{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: expected -100 to be greater than 0"} +{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: value should match only one schema, but matches none"} @@ -291,7 +291,7 @@ passed } --- error_code: 400 --- response_body -{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: expected -100 to be greater than 0"} +{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: value should match only one schema, but matches none"} @@ -363,7 +363,7 @@ passed } --- error_code: 400 --- response_body -{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: expected -100 to be greater than 0"} +{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: value should match only one schema, but matches none"} @@ -400,7 +400,7 @@ passed } --- error_code: 400 --- response_body -{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: expected -100 to be greater than 0"} +{"error_msg":"failed to check the configuration of plugin limit-count err: property \"count\" validation failed: value should match only one schema, but matches none"} @@ -1171,7 +1171,7 @@ passed } } --- response_body eval -qr/property \"count\" validation failed: expected 0 to be greater than 0/ +qr/property \"count\" validation failed: value should match only one schema, but matches none/ @@ -1189,4 +1189,4 @@ qr/property \"count\" validation failed: expected 0 to be greater than 0/ } } --- response_body eval -qr/property \"time_window\" validation failed: expected 0 to be greater than 0/ +qr/property \"time_window\" validation failed: value should match only one schema, but matches none/ From bda2f157483f7770455b6ae8ed33540d21abd399 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Wed, 4 Feb 2026 14:25:58 +0545 Subject: [PATCH 11/12] f Signed-off-by: Abhishek Choudhary --- t/plugin/limit-count-redis-cluster.t | 3 --- t/plugin/limit-count-redis.t | 3 --- 2 files changed, 6 deletions(-) diff --git a/t/plugin/limit-count-redis-cluster.t b/t/plugin/limit-count-redis-cluster.t index 7a4798a60e48..1263f80a3682 100644 --- a/t/plugin/limit-count-redis-cluster.t +++ b/t/plugin/limit-count-redis-cluster.t @@ -167,9 +167,6 @@ passed === TEST 4: up the limit --- request GET /hello ---- error_log -try to lock with key route#1#redis-cluster -unlock with key route#1#redis-cluster diff --git a/t/plugin/limit-count-redis.t b/t/plugin/limit-count-redis.t index d06188050df2..db13ba4e45c5 100644 --- a/t/plugin/limit-count-redis.t +++ b/t/plugin/limit-count-redis.t @@ -169,9 +169,6 @@ passed === TEST 4: up the limit --- request GET /hello ---- error_log -try to lock with key route#1#redis -unlock with key route#1#redis From bc604b3800b3636cea58031760462a50aad721c9 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Thu, 5 Feb 2026 10:17:31 +0545 Subject: [PATCH 12/12] fix Signed-off-by: Abhishek Choudhary --- apisix/plugins/limit-conn/init.lua | 2 -- apisix/plugins/limit-count/init.lua | 2 -- 2 files changed, 4 deletions(-) diff --git a/apisix/plugins/limit-conn/init.lua b/apisix/plugins/limit-conn/init.lua index 092a5a77e460..6c5c823e11e1 100644 --- a/apisix/plugins/limit-conn/init.lua +++ b/apisix/plugins/limit-conn/init.lua @@ -136,8 +136,6 @@ function _M.increase(conf, ctx) return conf.rejected_code or 503 end - - core.log.error("failed to limit conn: ", err) if conf.allow_degradation then return diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua index f8c0bfe154f0..7d5fe7ca9baf 100644 --- a/apisix/plugins/limit-count/init.lua +++ b/apisix/plugins/limit-count/init.lua @@ -231,7 +231,6 @@ local function create_limit_obj(conf, ctx, plugin_name) end - local function gen_limit_key(conf, ctx, key) if conf.group then return conf.group .. ':' .. key @@ -297,7 +296,6 @@ function _M.rate_limit(conf, ctx, name, cost, dry_run) key = ctx.var["remote_addr"] end - core.log.info("key bef: ", key, ". conf: ", core.json.encode(conf), ". ctx: ", type(ctx)) key = gen_limit_key(conf, ctx, key) core.log.info("limit key: ", key)