This is an automated email from the ASF dual-hosted git repository.
nic-6443 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 88c4b8c47 fix(limit-count): refresh the delayed-sync quota after the
local delta expired (#14015)
88c4b8c47 is described below
commit 88c4b8c47d9d59725d22e02aca0364388c76e51f
Author: Nic <[email protected]>
AuthorDate: Thu Oct 8 18:08:52 2026 +0800
fix(limit-count): refresh the delayed-sync quota after the local delta
expired (#14015)
---
apisix/plugins/limit-count/delayed-syncer.lua | 22 +-
t/plugin/limit-count-redis-delayed-sync3.t | 350 ++++++++++++++++++++++++++
2 files changed, 366 insertions(+), 6 deletions(-)
diff --git a/apisix/plugins/limit-count/delayed-syncer.lua
b/apisix/plugins/limit-count/delayed-syncer.lua
index 369975925..acb3689ff 100644
--- a/apisix/plugins/limit-count/delayed-syncer.lua
+++ b/apisix/plugins/limit-count/delayed-syncer.lua
@@ -125,10 +125,14 @@ function _M.sync_to_shm(self, key, remaining, reset,
local_delta, info)
return err
end
- _, err = self.shd:incr(self:key_local_delta(key), -local_delta, 0, 2 *
self.window)
- if err then
- core.log.error("incr local delta shm to failed: ", err, ", key: ", key)
- return err
+ -- nothing was flushed: don't create an empty local delta entry, a failed
+ -- sync would take it for a pending delta and charge the fallback limiter
+ if local_delta ~= 0 then
+ _, err = self.shd:incr(self:key_local_delta(key), -local_delta, 0, 2 *
self.window)
+ if err then
+ core.log.error("incr local delta shm to failed: ", err, ", key: ",
key)
+ return err
+ end
end
return nil, quota
@@ -357,7 +361,12 @@ local function sync_key(self, key)
core.log.error("get local delta from shm failed: ", err)
end
- if delta then
+ -- the key expires two windows after its creation and is not renewed, so a
+ -- node that only rejects loses it; flush anyway, otherwise that node keeps
+ -- judging on the same cached quota until the quota itself expires
+ if not err then
+ local had_delta = delta ~= nil
+ delta = delta or 0
local flush = self.limiter.commit or self.limiter.incoming
local _, remaining_or_err, reset, info = flush(self.limiter, key,
delta)
-- compat
@@ -365,7 +374,8 @@ local function sync_key(self, key)
self:sync_to_shm(key, remaining_or_err, reset, delta, info)
elseif remaining_or_err ~= "rejected" then
core.log.error("sync to redis failed: ", remaining_or_err, ", key:
", key)
- if self.limiter.fallback_limiter then
+ -- without a local delta there is nothing to account for locally
+ if self.limiter.fallback_limiter and had_delta then
core.log.warn("try use fallback limiter to do rate limiting")
if delta < 1 then
delta = 1
diff --git a/t/plugin/limit-count-redis-delayed-sync3.t
b/t/plugin/limit-count-redis-delayed-sync3.t
new file mode 100644
index 000000000..2c0f4d5b9
--- /dev/null
+++ b/t/plugin/limit-count-redis-delayed-sync3.t
@@ -0,0 +1,350 @@
+#
+# 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();
+
+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: sliding window, redis, delayed sync
+--- 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-count": {
+ "count": 2,
+ "time_window": 60,
+ "window_type": "sliding",
+ "key_type": "constant",
+ "key": "delayed-sync-stale-snapshot",
+ "policy": "redis",
+ "redis_host": "127.0.0.1",
+ "sync_interval": 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)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: a node that only rejects still refreshes its quota after its local
delta expired
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local redis = require("resty.redis")
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+
+ local function hit()
+ local res, err = http.new():request_uri(uri)
+ return res and res.status or err
+ end
+
+ local function drop_redis_counters()
+ local red = redis:new()
+ assert(red:connect("127.0.0.1", 6379))
+ local keys = assert(red:keys("*delayed-sync-stale-snapshot*"))
+ for _, key in ipairs(keys) do
+ red:del(key)
+ end
+ red:close()
+ end
+
+ -- keep all requests inside one 60s sliding window
+ local left = 60 - ngx.now() % 60
+ if left < 5 then
+ ngx.sleep(left + 0.1)
+ end
+ drop_redis_counters()
+
+ local codes = {hit(), hit(), hit()}
+ -- let the delayed sync flush the local delta
+ ngx.sleep(0.3)
+
+ -- the local delta key lives two windows from its creation and is
+ -- not renewed; drop it as if that time had passed
+ local dict = ngx.shared["plugin-limit-count"]
+ for _, key in ipairs(dict:get_keys(0)) do
+ if key:find("^local_delta#") then
+ dict:delete(key)
+ end
+ end
+
+ -- free the quota in Redis, as the previous window's weight
+ -- decaying or another node's view would
+ drop_redis_counters()
+
+ -- judged on the cached quota, which is still exhausted
+ codes[4] = hit()
+ -- the sync this rejection scheduled must refresh the cached quota
+ ngx.sleep(0.3)
+ codes[5] = hit()
+
+ ngx.say(table.concat(codes, " "))
+ }
+ }
+--- response_body
+200 200 503 503 200
+--- timeout: 10
+
+
+
+=== TEST 3: fixed window, unreachable redis, delayed sync
+--- 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-count": {
+ "count": 1,
+ "time_window": 60,
+ "window_type": "fixed",
+ "key_type": "constant",
+ "key": "delayed-sync-no-redis",
+ "policy": "redis",
+ "redis_host": "127.0.0.1",
+ "redis_port": 16399,
+ "sync_interval": 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)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 4: without a local delta, a failed sync does not charge the fallback
limiter
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+ local dict = ngx.shared["plugin-limit-count"]
+
+ local function hit()
+ local res, err = http.new():request_uri(uri)
+ return res and res.status or err
+ end
+
+ -- what the fallback limiter has counted for this route
+ local function fallback_count()
+ local sum = 0
+ for _, key in ipairs(dict:get_keys(0)) do
+ -- the fixed window counter is stored under the limit key
+ if key:find("^/apisix/routes/")
+ and key:find("delayed-sync-no-redis", 1, true) then
+ sum = sum + dict:get(key)
+ end
+ end
+ return sum
+ end
+
+ -- admitted on the fallback limiter, then synced to it
+ local codes = {hit()}
+ ngx.sleep(0.3)
+ local before = fallback_count()
+
+ -- the local delta key expired: the node has nothing to sync
+ for _, key in ipairs(dict:get_keys(0)) do
+ if key:find("^local_delta#") then
+ dict:delete(key)
+ end
+ end
+
+ -- rejected, and the sync it schedules fails against Redis
+ codes[2] = hit()
+ ngx.sleep(0.3)
+
+ ngx.say(table.concat(codes, " "), ", fallback count ", before, "
-> ",
+ fallback_count())
+ }
+ }
+--- response_body
+200 503, fallback count 1 -> 1
+--- error_log
+sync to redis failed
+--- timeout: 10
+
+
+
+=== TEST 5: fixed window, redis, delayed sync
+--- 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-count": {
+ "count": 1,
+ "time_window": 60,
+ "window_type": "fixed",
+ "key_type": "constant",
+ "key": "delayed-sync-redis-error",
+ "policy": "redis",
+ "redis_host": "127.0.0.1",
+ "sync_interval": 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)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 6: a quota refresh without a local delta leaves nothing to charge
when redis fails later
+--- config
+ location /t {
+ content_by_lua_block {
+ local http = require("resty.http")
+ local redis = require("resty.redis")
+ local uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/hello"
+ local dict = ngx.shared["plugin-limit-count"]
+
+ local function hit()
+ local res, err = http.new():request_uri(uri)
+ return res and res.status or err
+ end
+
+ local function redis_keys()
+ local red = redis:new()
+ assert(red:connect("127.0.0.1", 6379))
+ return red, assert(red:keys("*delayed-sync-redis-error*"))
+ end
+
+ -- what the fallback limiter has counted for this route
+ local function fallback_count()
+ local sum = 0
+ for _, key in ipairs(dict:get_keys(0)) do
+ if key:find("^/apisix/routes/")
+ and key:find("delayed-sync-redis-error", 1, true) then
+ sum = sum + dict:get(key)
+ end
+ end
+ return sum
+ end
+
+ local red, keys = redis_keys()
+ for _, key in ipairs(keys) do
+ red:del(key)
+ end
+
+ local codes = {hit()}
+ ngx.sleep(0.3)
+
+ -- the local delta key expired
+ for _, key in ipairs(dict:get_keys(0)) do
+ if key:find("^local_delta#") then
+ dict:delete(key)
+ end
+ end
+
+ -- rejected; its sync refreshes the quota from Redis with nothing
+ -- to flush
+ codes[2] = hit()
+ ngx.sleep(0.3)
+
+ -- make the next sync fail: the counter is no longer an integer
+ red, keys = redis_keys()
+ for _, key in ipairs(keys) do
+ red:set(key, "not-a-number", "KEEPTTL")
+ end
+
+ local before = fallback_count()
+ codes[3] = hit()
+ ngx.sleep(0.3)
+
+ ngx.say(table.concat(codes, " "), ", fallback count ", before, "
-> ",
+ fallback_count())
+ }
+ }
+--- response_body
+200 503 503, fallback count 0 -> 0
+--- error_log
+sync to redis failed
+--- timeout: 10