You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by al...@apache.org on 2026/06/11 22:45:49 UTC
(apisix) branch master updated: feat(limit-count): add Redis Sentinel backend, sliding window and delayed sync support (#13443)
This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git
The following commit(s) were added to refs/heads/master by this push:
new e4ace03c8 feat(limit-count): add Redis Sentinel backend, sliding window and delayed sync support (#13443)
e4ace03c8 is described below
commit e4ace03c8bb4103dee99b419102174f9e1e68612
Author: AlinsRan <al...@apache.org>
AuthorDate: Fri Jun 12 06:45:43 2026 +0800
feat(limit-count): add Redis Sentinel backend, sliding window and delayed sync support (#13443)
---
Makefile | 6 +
apisix-master-0.rockspec | 1 +
apisix/cli/config.lua | 1 +
apisix/cli/ngx_tpl.lua | 1 +
apisix/plugins/limit-count/delayed-syncer.lua | 474 +++++++++++++++++++
apisix/plugins/limit-count/init.lua | 221 +++++++--
apisix/plugins/limit-count/limit-count-local.lua | 56 ++-
.../limit-count/limit-count-redis-cluster.lua | 114 +++--
.../limit-count/limit-count-redis-sentinel.lua | 130 ++++++
apisix/plugins/limit-count/limit-count-redis.lua | 128 ++++--
.../limit-count/sliding-window/sliding-window.lua | 244 ++++++++++
.../limit-count/sliding-window/store/redis.lua | 72 +++
.../sliding-window/store/shared-dict.lua | 64 +++
apisix/plugins/limit-count/util.lua | 175 +++++++
ci/pod/docker-compose.plugin.yml | 75 +++
conf/config.yaml.example | 1 +
docs/en/latest/plugins/limit-count.md | 207 +++++++--
docs/zh/latest/plugins/limit-count.md | 189 +++++++-
t/APISIX.pm | 1 +
t/control/services.t | 2 +-
t/plugin/limit-count-redis-delayed-sync.t | 243 ++++++++++
t/plugin/limit-count-redis-delayed-sync2.t | 273 +++++++++++
t/plugin/limit-count-redis-sentinel.t | 509 +++++++++++++++++++++
t/plugin/limit-count-rules.t | 79 +++-
t/plugin/limit-count-sliding.t | 294 ++++++++++++
t/plugin/limit-count-variable.t | 143 ++++++
26 files changed, 3512 insertions(+), 191 deletions(-)
diff --git a/Makefile b/Makefile
index 577193764..b0d7820cf 100644
--- a/Makefile
+++ b/Makefile
@@ -325,6 +325,12 @@ install: runtime
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/limit-count
$(ENV_INSTALL) apisix/plugins/limit-count/*.lua $(ENV_INST_LUADIR)/apisix/plugins/limit-count/
+ $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/limit-count/sliding-window
+ $(ENV_INSTALL) apisix/plugins/limit-count/sliding-window/*.lua $(ENV_INST_LUADIR)/apisix/plugins/limit-count/sliding-window/
+
+ $(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/limit-count/sliding-window/store
+ $(ENV_INSTALL) apisix/plugins/limit-count/sliding-window/store/*.lua $(ENV_INST_LUADIR)/apisix/plugins/limit-count/sliding-window/store/
+
$(ENV_INSTALL) -d $(ENV_INST_LUADIR)/apisix/plugins/opa
$(ENV_INSTALL) apisix/plugins/opa/*.lua $(ENV_INST_LUADIR)/apisix/plugins/opa/
diff --git a/apisix-master-0.rockspec b/apisix-master-0.rockspec
index dc2051262..f10887106 100644
--- a/apisix-master-0.rockspec
+++ b/apisix-master-0.rockspec
@@ -32,6 +32,7 @@ description = {
dependencies = {
"lua-resty-ctxdump = 0.1-0",
+ "api7-lua-resty-redis-connector = 0.12.0",
"lyaml = 6.2.8-1",
"api7-lua-resty-dns-client = 7.1.1-0",
"lua-resty-template = 2.0-1",
diff --git a/apisix/cli/config.lua b/apisix/cli/config.lua
index 664159a36..771c21bd3 100644
--- a/apisix/cli/config.lua
+++ b/apisix/cli/config.lua
@@ -168,6 +168,7 @@ local _M = {
["balancer-ewma-locks"] = "10m",
["balancer-ewma-last-touched-at"] = "10m",
["plugin-limit-req-redis-cluster-slot-lock"] = "1m",
+ ["plugin-limit-count-lock"] = "1m",
["plugin-limit-count-redis-cluster-slot-lock"] = "1m",
["plugin-limit-conn-redis-cluster-slot-lock"] = "1m",
["plugin-graphql-limit-count"] = "10m",
diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index c86f83e70..b02623aab 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -323,6 +323,7 @@ http {
{% if enabled_plugins["limit-count"] then %}
lua_shared_dict plugin-limit-count {* http.lua_shared_dict["plugin-limit-count"] *};
+ lua_shared_dict plugin-limit-count-lock {* http.lua_shared_dict["plugin-limit-count-lock"] *};
lua_shared_dict plugin-limit-count-redis-cluster-slot-lock {* http.lua_shared_dict["plugin-limit-count-redis-cluster-slot-lock"] *};
lua_shared_dict plugin-limit-count-reset-header {* http.lua_shared_dict["plugin-limit-count"] *};
{% end %}
diff --git a/apisix/plugins/limit-count/delayed-syncer.lua b/apisix/plugins/limit-count/delayed-syncer.lua
new file mode 100644
index 000000000..61f0b5570
--- /dev/null
+++ b/apisix/plugins/limit-count/delayed-syncer.lua
@@ -0,0 +1,474 @@
+--
+-- 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.
+--
+
+local core = require("apisix.core")
+local resty_lock = require "resty.lock"
+local cjson_safe = require "cjson.safe"
+local table_new = require("table.new")
+local table_nkeys = require("table.nkeys")
+
+local setmetatable = setmetatable
+local tostring = tostring
+local pairs = pairs
+local ipairs = ipairs
+local tab_insert = table.insert
+local type = type
+local pcall = pcall
+
+local ngx_now = ngx.now
+local ngx_shared = ngx.shared
+local worker_id = ngx.worker.id
+local ngx_worker = ngx.worker
+local ngx_sleep = ngx.sleep
+local ngx_timer = ngx.timer
+
+
+local KEY_PREFIX_LOCKER = "locker#"
+local KEY_PREFIX_LOCAL_DELTA = "local_delta#" -- delta since last time sync with redis
+local KEY_PREFIX_LOCAL_DELTA_KEYS = "local_delta_keys#" -- keys to be sync with redis next time
+ -- per plugin instance timer, in server instance dimension
+local KEY_PREFIX_SYNC_TIMER = "sync_timer#"
+local KEY_PREFIX_REMOTE_QUOTA = "remote_quota#" -- save remaining/reset/sync_at in JSON format
+
+-- Maximum number of keys allowed in the delayed-sync queue per syncer instance.
+-- Each request does one unconditional lpush, so without this cap the queue can fill
+-- the fixed-size `plugin-limit-count` shared dict under high request rates.
+-- Each entry is a rate-limit key string; 10000 entries is well within typical usage.
+local MAX_DELAYED_SYNC_QUEUE_SIZE = 10000
+
+-- Throttle for the queue-saturation warning: at most one log per worker per
+-- syncer per this many seconds. A saturated queue happens under sustained high
+-- request rate, so logging every request would flood the error log.
+local QUEUE_FULL_WARN_INTERVAL = 10
+local last_queue_full_warn = {}
+
+local time_to_sync_records = {}
+
+local _M = {}
+
+local mt = {
+ __index = _M
+}
+
+
+function _M.build_key(self, prefix, key)
+ if self.shd_per_worker then
+ return prefix .. worker_id() .. "#" .. key
+ end
+ return prefix .. key
+end
+
+
+function _M.key_locker(self, key)
+ return self:build_key(KEY_PREFIX_LOCKER, key)
+end
+
+
+function _M.key_local_delta(self, key)
+ return self:build_key(KEY_PREFIX_LOCAL_DELTA, key)
+end
+
+
+function _M.key_local_delta_keys(self, syncer_id)
+ return self:build_key(KEY_PREFIX_LOCAL_DELTA_KEYS, syncer_id)
+end
+
+
+function _M.key_sync_timer(self, syncer_id)
+ return self:build_key(KEY_PREFIX_SYNC_TIMER, syncer_id)
+end
+
+
+function _M.key_remote_quota(self, key)
+ return self:build_key(KEY_PREFIX_REMOTE_QUOTA, key)
+end
+
+
+function _M.sync_to_shm(self, key, remaining, reset, local_delta)
+ local quota = {
+ remaining = remaining,
+ reset = reset,
+ sync_at = ngx_now(),
+ }
+
+ local _, err, quota_json
+
+ quota_json, err = cjson_safe.encode(quota)
+ if err then
+ core.log.error("encode remote_quota to json failed: ", err)
+ return err
+ end
+
+ _, err = self.shd:set(self:key_remote_quota(key), quota_json, 2 * self.window)
+ if err then
+ core.log.error("set remote quota to shm failed: ", err, ", key: ", key)
+ 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
+ end
+end
+
+
+function _M.release(self, syncer_id)
+ self.shd:delete(self:key_sync_timer(syncer_id))
+end
+
+
+function _M.delayed_sync(self, key, cost, syncer_id)
+ local locker, err = resty_lock:new(self.lock_shdict_name)
+ if not locker then
+ core.log.error("new resty locker failed: ", err, ", syncer_id: ", syncer_id)
+ return nil, nil, err
+ end
+
+ local elapsed
+ elapsed, err = locker:lock(self:key_locker(key))
+ if err then
+ core.log.error("lock key(" .. key .. ") failed: ", err, ", elapsed: ", elapsed)
+ return nil, nil, err
+ end
+
+ -- wrap the delayed syncer call in a pcall to avoid the lock being held forever
+ local ok, remaining, reset, err = pcall(self._delayed_sync, self, key, cost, syncer_id)
+ if not ok then
+ err = remaining
+ remaining = nil
+ core.log.error("delayed sync failed: ", err, ", key: ", key)
+ end
+
+ local ok, err_unlock = locker:unlock()
+ if not ok then
+ core.log.error("unlock key(" .. key .. ") failed: ", err_unlock)
+ end
+
+ return remaining, reset, err
+end
+
+
+function _M._delayed_sync(self, key, cost, syncer_id)
+ local _, reset, remote_quota_json
+ local local_delta, err = self.shd:get(self:key_local_delta(key))
+ if err then
+ return nil, nil, err
+ end
+ if not local_delta then
+ local_delta = 0
+ end
+
+ remote_quota_json, err = self.shd:get(self:key_remote_quota(key))
+ if err then
+ return nil, nil, err
+ end
+
+ core.log.info("trying to delayed sync, key: ", key,
+ ", local_delta: ", local_delta,
+ ", cost: ", cost,
+ ", syncer_id: ", syncer_id)
+
+ local remote_remaining, remote_reset, sync_at, quota
+ if remote_quota_json then
+ core.log.info("remote_quota_json: ", remote_quota_json)
+ quota, err = cjson_safe.decode(tostring(remote_quota_json))
+ if err then
+ core.log.error("decode remote_quota_json failed: ", err)
+ return nil, nil, err
+ end
+
+ remote_remaining, remote_reset, sync_at = quota.remaining, quota.reset, quota.sync_at
+ reset = remote_reset - (ngx_now() - sync_at)
+ if reset < 0 then
+ reset = 0 -- flag that indicates needing to sync with redis
+ time_to_sync_records[syncer_id] = nil
+ end
+ end
+
+ if not remote_quota_json or 0 == reset then
+ remote_remaining = 0
+ remote_reset = 0
+ local remaining_or_err
+ -- prefer commit() when the limiter provides one (sliding window), so an
+ -- already-permitted delta is always written even if the remote counter
+ -- is at/over the limit; the fixed-window backend has no commit() and its
+ -- incoming() already increments before reporting "rejected".
+ local flush = self.limiter.commit or self.limiter.incoming
+ _, remaining_or_err, reset = flush(self.limiter, key, local_delta)
+ if type(remaining_or_err) ~= "string" then
+ remote_remaining = remaining_or_err
+ remote_reset = reset
+ elseif remaining_or_err ~= "rejected" then
+ core.log.error("sync to redis failed: ", remaining_or_err, ", key: ", key)
+ if self.limiter.fallback_limiter then
+ core.log.warn("try use fallback limiter to do rate limiting")
+ _, remaining_or_err, reset =
+ self.limiter.fallback_limiter:incoming(key, local_delta)
+ if type(remaining_or_err) ~= "string" then
+ remote_remaining = remaining_or_err
+ remote_reset = reset
+ elseif remaining_or_err ~= "rejected" then
+ core.log.error("sync to fallback_limiter failed: ",
+ remaining_or_err, ", key: ", key)
+ else
+ remote_remaining = 0
+ remote_reset = reset
+ end
+ else
+ return nil, nil, remaining_or_err
+ end
+ else
+ -- rejected: rate limit exceeded on Redis
+ remote_remaining = 0
+ remote_reset = reset
+ end
+
+ core.log.info("sync to shm, key: ", key, ", remote_remaining: ", remote_remaining,
+ ", remote_reset: ", remote_reset)
+ err = self:sync_to_shm(key, remote_remaining, remote_reset, local_delta)
+ if err then
+ return nil, nil, err
+ end
+ end
+
+ local queue_key = self:key_local_delta_keys(syncer_id)
+ local queue_len, q_err = self.shd:llen(queue_key)
+ if q_err then
+ core.log.warn("failed to get delayed-sync queue length: ", q_err)
+ end
+ local enqueued = false
+ if not (queue_len and queue_len >= MAX_DELAYED_SYNC_QUEUE_SIZE) then
+ _, err = self.shd:lpush(queue_key, key)
+ if not err then
+ enqueued = true
+ end
+ end
+
+ -- The queue is saturated, either the cap is reached, or lpush failed because a
+ -- concurrent worker filled the shm between the llen check and the lpush. Either
+ -- way, skip the enqueue and let the request proceed on the already-computed
+ -- remaining quota; the delta stays in the per-key shm slot and syncs once the
+ -- queue drains. Returning an error here would 500 requests under the same load
+ -- the cap is meant to survive, so we degrade instead.
+ if not enqueued then
+ local now = ngx_now()
+ local last = last_queue_full_warn[syncer_id]
+ if not last or now - last >= QUEUE_FULL_WARN_INTERVAL then
+ last_queue_full_warn[syncer_id] = now
+ core.log.warn("delayed-sync queue saturated, skipping enqueue; syncer_id: ",
+ syncer_id, ", queue_len: ", queue_len or "unknown",
+ ", err: ", err or "queue full")
+ end
+ end
+
+ local key_sync_timer = self:key_sync_timer(syncer_id)
+
+ -- timer has not started or has already triggered, try starting a new one
+ local now = ngx_now()
+ if not time_to_sync_records[syncer_id] or time_to_sync_records[syncer_id] <= now then
+ local time_to_sync = now + self.sync_interval
+ -- nginx server instance dimension, each plug-in instance corresponds to a timer
+ -- shd:add - ensure only one worker can start timer
+ local success
+ success, err = self.shd:add(key_sync_timer, time_to_sync)
+ if success then
+ -- start timer ASAP
+ local ok, err = ngx_timer.at(
+ 0,
+ function (premature)
+ if not premature then
+ local ok, err = pcall(self.sync, self, syncer_id, time_to_sync)
+ if not ok then
+ core.log.error("sync failed: ", err, ", syncer_id: ", syncer_id)
+ end
+ end
+ self:release(syncer_id)
+ end
+ )
+ if not ok then
+ local running = ngx_timer.running_count()
+ local pending = ngx_timer.pending_count()
+ core.log.error("failed to create timer: ", err, ", running_count: ", running,
+ ", pending_count: ", pending, ", syncer_id: ", syncer_id)
+ self:release(syncer_id)
+ else
+ time_to_sync_records[syncer_id] = time_to_sync
+ end
+ elseif err == "exists" then
+ -- other workers
+ time_to_sync_records[syncer_id], err = self.shd:get(key_sync_timer)
+ if err then
+ core.log.error("get sync timer created time failed: ", err)
+ end
+ else
+ core.log.error("try starting new timer failed: ", err)
+ return nil, nil, err
+ end
+ end
+
+ local remaining = remote_remaining - local_delta - cost
+ if 0 <= remaining then
+ _, err = self.shd:incr(self:key_local_delta(key), cost, 0, 2 * self.window)
+ if err then
+ core.log.error("incr local delta to shm failed: ", err, ", key: ", key)
+ return nil, nil, err
+ end
+ end
+
+ return remaining, reset
+end
+
+
+local function sync_key(self, key)
+ local delta, err = self.shd:get(self:key_local_delta(key))
+ if err then
+ core.log.error("get local delta from shm failed: ", err)
+ end
+
+ if delta then
+ local flush = self.limiter.commit or self.limiter.incoming
+ local _, remaining_or_err, reset = flush(self.limiter, key, delta)
+ -- compat
+ if type(remaining_or_err) ~= "string" then
+ self:sync_to_shm(key, remaining_or_err, reset, delta)
+ elseif remaining_or_err ~= "rejected" then
+ core.log.error("sync to redis failed: ", remaining_or_err, ", key: ", key)
+ if self.limiter.fallback_limiter then
+ core.log.warn("try use fallback limiter to do rate limiting")
+ if delta < 1 then
+ delta = 1
+ end
+ _, remaining_or_err, reset =
+ self.limiter.fallback_limiter:incoming(key, delta)
+ if type(remaining_or_err) ~= "string" then
+ self:sync_to_shm(key, remaining_or_err, reset, delta)
+ elseif remaining_or_err ~= "rejected" then
+ core.log.error("sync to fallback_limiter failed: ",
+ remaining_or_err, ", key: ", key)
+ else
+ self:sync_to_shm(key, 0, reset, delta)
+ end
+ end
+ else
+ self:sync_to_shm(key, 0, reset, delta)
+ end
+ end
+end
+
+
+function _M.sync(self, syncer_id, time_to_sync)
+ local key_local_delta_keys = self:key_local_delta_keys(syncer_id) -- name of keys queue
+ local local_delta_keys_dedup = {} -- duplicate removal
+ while not ngx_worker.exiting() and time_to_sync > ngx_now() do
+ local key, err = self.shd:rpop(key_local_delta_keys)
+ if err then
+ core.log.error("shdict.rpop failed: ", err, ", syncer_id: ", syncer_id)
+ return
+ end
+ if key then
+ if not local_delta_keys_dedup[key] then
+ local_delta_keys_dedup[key] = true
+ end
+ else
+ ngx_sleep(0.001)
+ end
+ end
+
+ if ngx_worker.exiting() then
+ core.log.info("sync interrupted due to worker exit")
+ return
+ end
+
+ -- drain all remaining keys from the queue
+ local key = {}
+ while key ~= nil do
+ local err
+ key, err = self.shd:rpop(key_local_delta_keys)
+ if err then
+ core.log.error("shdict.rpop failed: ", err, ", syncer_id: ", syncer_id)
+ return
+ end
+
+ if key then
+ if not local_delta_keys_dedup[key] then
+ local_delta_keys_dedup[key] = true
+ end
+ end
+ end
+
+ local nkeys = table_nkeys(local_delta_keys_dedup)
+ local local_delta_keys_uniq = table_new(nkeys, 0)
+
+ core.log.info(nkeys, " keys to be sync, time_to_sync: ", time_to_sync)
+
+ for key, _ in pairs(local_delta_keys_dedup) do
+ tab_insert(local_delta_keys_uniq, key)
+ end
+
+ local locker, err = resty_lock:new(self.lock_shdict_name)
+ if not locker then
+ core.log.error("new resty locker failed: ", err, ", syncer_id: ", syncer_id)
+ return
+ end
+
+ local elapsed
+ for _, key in ipairs(local_delta_keys_uniq) do
+ elapsed, err = locker:lock(self:key_locker(key))
+ if err then
+ core.log.error("lock key(" .. key .. ") failed: ", err, ", elapsed: ", elapsed)
+ return
+ end
+
+ local ok, err = pcall(sync_key, self, key)
+ if not ok then
+ core.log.error("sync failed: ", err, ", key: ", key)
+ end
+
+ local ok, err_unlock = locker:unlock()
+ if not ok then
+ core.log.error("unlock key(" .. key .. ") failed: ", err_unlock)
+ end
+ end
+end
+
+
+function _M.new(shdict_name, limit, window, conf, limiter)
+ local shd = ngx_shared[shdict_name]
+ if not shd then
+ return nil, "shared dict (" .. shdict_name .. ") not found"
+ end
+ local lock_shdict_name = shdict_name .. "-lock"
+ local self = {
+ shdict_name = shdict_name,
+ lock_shdict_name = lock_shdict_name,
+ shd = shd,
+ conf = conf,
+ limit = limit,
+ window = window,
+ limiter = limiter,
+ sync_interval = conf.sync_interval,
+ }
+ -- self.shd_per_worker = true: simulate multiple nginx server instance
+ if conf._shd_per_worker then
+ self.shd_per_worker = true
+ end
+ return setmetatable(self, mt)
+end
+
+
+return _M
diff --git a/apisix/plugins/limit-count/init.lua b/apisix/plugins/limit-count/init.lua
index ea1d0158e..ed4db64a8 100644
--- a/apisix/plugins/limit-count/init.lua
+++ b/apisix/plugins/limit-count/init.lua
@@ -19,17 +19,25 @@ local apisix_plugin = require("apisix.plugin")
local tab_insert = table.insert
local ipairs = ipairs
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 redis_schema = require("apisix.utils.redis-schema")
+local get_phase = ngx.get_phase
+local math_floor = math.floor
local str_format = string.format
-local error = error
+
+local NO_DELAYED_SYNC = -1
+-- Redis counter storage-format version, passed into the Redis backends and
+-- embedded into the counter key by util.redis_incoming. Bump it (v1 -> v2 ...)
+-- whenever the stored format changes so new code never reads a pre-upgrade key
+-- with the new meaning; old keys just expire via their own TTL.
+local KEY_VERSION = "v1"
+local policy_to_additional_properties = core.table.deepcopy(redis_schema.schema)
local limit_redis_cluster_new
local limit_redis_new
+local limit_redis_sentinel_new
local limit_local_new
do
local local_src = "apisix.plugins.limit-count.limit-count-local"
@@ -40,10 +48,48 @@ do
local cluster_src = "apisix.plugins.limit-count.limit-count-redis-cluster"
limit_redis_cluster_new = require(cluster_src).new
+
+ local sentinel_src = "apisix.plugins.limit-count.limit-count-redis-sentinel"
+ limit_redis_sentinel_new = require(sentinel_src).new
end
local group_conf_lru = core.lrucache.new({
type = 'plugin',
})
+local group_limit_lru = core.lrucache.new({type = 'plugin'})
+local lrucache = core.lrucache.new({type = 'plugin', serial_creating = true})
+
+policy_to_additional_properties["redis-sentinel"] = {
+ properties = {
+ redis_sentinels = {
+ type = "array",
+ minItems = 1,
+ items = {
+ type = "object",
+ properties = {
+ host = {type = "string", minLength = 2},
+ port = {type = "integer", minimum = 1, maximum = 65535},
+ },
+ required = {"host", "port"},
+ additionalProperties = false,
+ },
+ },
+ redis_master_name = {type = "string", minLength = 1},
+ redis_role = {
+ type = "string",
+ enum = {"master", "slave"},
+ default = "master",
+ },
+ redis_connect_timeout = {type = "integer", minimum = 1, default = 1000},
+ redis_read_timeout = {type = "integer", minimum = 1, default = 1000},
+ redis_keepalive_timeout = {type = "integer", minimum = 1, default = 60000},
+ redis_database = {type = "integer", minimum = 0, default = 0},
+ redis_username = {type = "string", minLength = 1},
+ redis_password = {type = "string", minLength = 0},
+ sentinel_username = {type = "string", minLength = 1},
+ sentinel_password = {type = "string", minLength = 0},
+ },
+ required = {"redis_sentinels", "redis_master_name"},
+}
local metadata_defaults = {
limit_header = "X-RateLimit-Limit",
@@ -84,6 +130,11 @@ local schema = {
{type = "string"},
},
},
+ window_type = {
+ type = "string",
+ enum = {"fixed", "sliding"},
+ default = "fixed",
+ },
rules = {
type = "array",
items = {
@@ -124,11 +175,15 @@ local schema = {
},
policy = {
type = "string",
- enum = {"local", "redis", "redis-cluster"},
+ enum = {"local", "redis", "redis-cluster", "redis-sentinel"},
default = "local",
},
allow_degradation = {type = "boolean", default = false},
- show_limit_quota_header = {type = "boolean", default = true}
+ show_limit_quota_header = {type = "boolean", default = true},
+ sync_interval = {
+ type = "number",
+ default = NO_DELAYED_SYNC,
+ }
},
oneOf = {
{
@@ -155,6 +210,16 @@ local schema = {
},
},
["then"] = policy_to_additional_properties["redis-cluster"],
+ ["else"] = {
+ ["if"] = {
+ properties = {
+ policy = {
+ enum = {"redis-sentinel"},
+ },
+ },
+ },
+ ["then"] = policy_to_additional_properties["redis-sentinel"],
+ }
}
}
@@ -178,9 +243,23 @@ function _M.check_schema(conf, schema_type)
local ok, err = core.schema.check(schema, conf)
if not ok then
+ -- oneOf conflict: both count/time_window and rules are present
+ if err and err:find("value should match only one schema", 1, true) then
+ if (conf.count or conf.time_window) and conf.rules then
+ return false, "count/time_window and rules cannot be specified at the same time"
+ end
+ end
return false, err
end
+ if conf.rules and (conf.count or conf.time_window) then
+ return false, "count/time_window and rules cannot be specified at the same time"
+ end
+
+ if conf.group and conf.rules then
+ return false, "group and rules cannot be specified at the same time"
+ end
+
if conf.group then
-- means that call by some plugin not support
if conf._vid then
@@ -214,6 +293,24 @@ function _M.check_schema(conf, schema_type)
end
end
+ if conf.policy == "redis" or conf.policy == "redis-cluster" or
+ conf.policy == "redis-sentinel"
+ then
+ if conf.sync_interval and conf.sync_interval ~= NO_DELAYED_SYNC then
+ if conf.sync_interval < 0.1 then
+ return false, "sync_interval should not be smaller than 0.1"
+ end
+
+ if type(conf.time_window) == "number" and conf.sync_interval >= conf.time_window then
+ return false, "sync_interval should be smaller than time_window"
+ end
+ end
+ end
+
+ -- Each rule writes its own counter, but the runtime counter key is derived
+ -- only from the resolved key (not the rule index/window). Two rules sharing
+ -- the same key would therefore read/write the same counter, double-counting
+ -- a request and polluting each other's window/TTL. Reject duplicates instead.
local keys = {}
for _, rule in ipairs(conf.rules or {}) do
if keys[rule.key] then
@@ -228,20 +325,29 @@ end
local function create_limit_obj(conf, rule, plugin_name)
core.log.info("create new ", plugin_name, " plugin instance",
+ ", policy: ", conf.policy,
+ ", window_type: ", conf.window_type,
+ ", sync_interval: ", conf.sync_interval,
", rule: ", core.json.delay_encode(rule, true))
if not conf.policy or conf.policy == "local" then
return limit_local_new("plugin-" .. plugin_name, rule.count,
- rule.time_window)
+ rule.time_window, conf.window_type)
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, rule.count, rule.time_window, conf,
+ KEY_VERSION)
end
if conf.policy == "redis-cluster" then
return limit_redis_cluster_new("plugin-" .. plugin_name, rule.count,
- rule.time_window, conf)
+ rule.time_window, conf, KEY_VERSION)
+ end
+
+ if conf.policy == "redis-sentinel" then
+ return limit_redis_sentinel_new("plugin-" .. plugin_name, rule.count,
+ rule.time_window, conf, KEY_VERSION)
end
return nil
@@ -259,7 +365,8 @@ local function gen_limit_key(conf, ctx, key)
-- 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
- error("failed to generate key invalid parent: ", core.json.encode(parent))
+ 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)
@@ -276,10 +383,11 @@ end
local function resolve_var(ctx, value)
if type(value) == "string" then
+ local original_value = value
local err, _
value, err, _ = core.utils.resolve_var(value, ctx.var)
if err then
- return nil, "could not resolve var for value: " .. value .. ", err: " .. err
+ return nil, "could not resolve var for value: " .. original_value .. ", err: " .. err
end
value = tonumber(value)
if not value then
@@ -339,17 +447,18 @@ end
-local function construct_rate_limiting_headers(conf, name, rule, metadata)
- local prefix = "X-"
- if name == "ai-rate-limiting" then
- prefix = "X-AI-"
- end
-
+local function construct_rate_limiting_headers(conf, rule, metadata)
if rule.header_prefix then
+ local base_limit = conf.limit_header or metadata.limit_header
+ local base_remaining = conf.remaining_header or metadata.remaining_header
+ local base_reset = conf.reset_header or metadata.reset_header
+ -- Insert rule prefix before "RateLimit-" to preserve any custom header base
+ -- e.g. "X-AI-RateLimit-Limit" + prefix "1" -> "X-AI-1-RateLimit-Limit"
+ local prefix = tostring(rule.header_prefix)
return {
- limit_header = prefix .. rule.header_prefix .. "-RateLimit-Limit",
- remaining_header = prefix .. rule.header_prefix .. "-RateLimit-Remaining",
- reset_header = prefix .. rule.header_prefix .. "-RateLimit-Reset",
+ limit_header = base_limit:gsub("RateLimit%-", prefix .. "-RateLimit-", 1),
+ remaining_header = base_remaining:gsub("RateLimit%-", prefix .. "-RateLimit-", 1),
+ reset_header = base_reset:gsub("RateLimit%-", prefix .. "-RateLimit-", 1),
}
end
return {
@@ -361,7 +470,22 @@ end
local function run_rate_limit(conf, rule, ctx, name, cost, dry_run)
- local lim, err = create_limit_obj(conf, rule, name)
+ local lim, err
+ if conf.group then
+ lim, err = group_limit_lru(conf.group, "", create_limit_obj, conf, conf, name)
+ elseif not conf.rules
+ and type(conf.count) == "number"
+ and type(conf.time_window) == "number"
+ then
+ local key = name .. "#" .. (conf.policy or "local")
+ if conf._vid then
+ key = key .. "#" .. conf._vid
+ end
+ lim, err = core.lrucache.plugin_ctx(lrucache, ctx, key,
+ create_limit_obj, conf, conf, name)
+ else
+ lim, err = create_limit_obj(conf, rule, name)
+ end
if not lim then
core.log.error("failed to fetch limit.count object: ", err)
@@ -396,28 +520,50 @@ local function run_rate_limit(conf, rule, ctx, name, cost, dry_run)
end
key = gen_limit_key(conf, ctx, key)
- core.log.info("limit key: ", key)
+ if not key then
+ return 500
+ end
+ core.log.info("limit key: ", key, ", count: ", rule.count,
+ ", time_window: ", rule.time_window)
+
+ local phase = get_phase()
+ local is_log_phase = phase == "log"
+ local commit_cost = dry_run and 0 or cost
local delay, remaining, reset
if not conf.policy or conf.policy == "local" then
- if dry_run then
- -- peek with cost=0 and commit=false to avoid side effects:
- -- dict:get reads without creating or incrementing the key
- delay, remaining, reset = lim:incoming(key, false, conf, 0)
- if type(remaining) == "number" and remaining - cost < 0 then
- delay = nil
- remaining = "rejected"
+ delay, remaining, reset = lim:incoming(key, commit_cost)
+ else
+ local enable_delayed_sync = conf.sync_interval and (conf.sync_interval ~= NO_DELAYED_SYNC)
+ -- a dynamic time_window may resolve to a value <= sync_interval at request
+ -- time, which would break delayed-sync semantics; fall back to direct sync
+ if enable_delayed_sync and rule.time_window <= conf.sync_interval then
+ enable_delayed_sync = false
+ end
+ if is_log_phase then
+ lim:log_phase_incoming(key, commit_cost)
+ return
+ elseif enable_delayed_sync then
+ local extra_key = name .. '#' .. conf.policy
+ if conf._vid then
+ extra_key = extra_key .. '#' .. conf._vid
end
+ local plugin_instance_id = core.lrucache.plugin_ctx_id(ctx, extra_key)
+ delay, remaining, reset = lim:incoming_delayed(key, commit_cost, plugin_instance_id)
else
- delay, remaining, reset = lim:incoming(key, true, conf, cost)
+ delay, remaining, reset = lim:incoming(key, commit_cost)
end
- else
- delay, remaining, reset = lim:incoming(key, cost)
end
+ if dry_run and type(remaining) == "number" and remaining - cost < 0 then
+ delay = nil
+ remaining = "rejected"
+ end
+ reset = reset and (math_floor(reset * 100) / 100)
+
core.utils.set_var_rate_limiting_info(ctx, key, lim.limit, remaining, reset)
- local metadata = apisix_plugin.plugin_metadata("limit-count")
+ local metadata = apisix_plugin.plugin_metadata(name)
if metadata then
metadata = metadata.value
else
@@ -425,9 +571,8 @@ local function run_rate_limit(conf, rule, ctx, name, cost, dry_run)
end
core.log.info("limit-count plugin-metadata: ", core.json.delay_encode(metadata))
- local set_limit_headers = construct_rate_limiting_headers(conf, name, rule, metadata)
- local phase = get_phase()
- local set_header = phase ~= "log"
+ local set_limit_headers = construct_rate_limiting_headers(conf, rule, metadata)
+ local set_header = not is_log_phase
if not delay then
local err = remaining
@@ -435,8 +580,8 @@ local function run_rate_limit(conf, rule, ctx, name, cost, dry_run)
-- 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, lim.limit,
- set_limit_headers.remaining_header, 0,
- set_limit_headers.reset_header, reset)
+ set_limit_headers.remaining_header, 0,
+ set_limit_headers.reset_header, reset)
end
if conf.rejected_msg then
diff --git a/apisix/plugins/limit-count/limit-count-local.lua b/apisix/plugins/limit-count/limit-count-local.lua
index 87d1059d7..d6d0eb7d6 100644
--- a/apisix/plugins/limit-count/limit-count-local.lua
+++ b/apisix/plugins/limit-count/limit-count-local.lua
@@ -15,10 +15,13 @@
-- limitations under the License.
--
local limit_count = require("resty.limit.count")
+local sliding_window = require("apisix.plugins.limit-count.sliding-window.sliding-window")
+local shared_dict_store = require("apisix.plugins.limit-count.sliding-window."
+ .. "store.shared-dict")
local ngx = ngx
local type = type
-local ngx_time = ngx.time
+local ngx_now = ngx.now
local assert = assert
local setmetatable = setmetatable
local core = require("apisix.core")
@@ -31,7 +34,7 @@ local mt = {
local function set_endtime(self, key, time_window)
-- set an end time
- local end_time = ngx_time() + time_window
+ local end_time = ngx_now() + time_window
-- save to dict by key
local success, err = self.dict:set(key, end_time, time_window)
@@ -46,28 +49,65 @@ end
local function read_reset(self, key)
-- read from dict
local end_time = (self.dict:get(key) or 0)
- local reset = end_time - ngx_time()
+ local reset = end_time - ngx_now()
if reset < 0 then
reset = 0
end
return reset
end
-function _M.new(plugin_name, limit, window)
+function _M.new(plugin_name, limit, window, window_type)
assert(limit > 0 and window > 0)
+ if window_type == "sliding" then
+ local shd_store, err = shared_dict_store.new({name = plugin_name})
+ if not shd_store then
+ return nil, err
+ end
+
+ local sw_limit_count
+ sw_limit_count, err = sliding_window.new(shd_store, limit, window)
+
+ if not sw_limit_count then
+ return nil, err
+ end
+
+ local self = {
+ limit = limit,
+ window = window,
+ window_type = window_type,
+ limit_count = sw_limit_count,
+ }
+
+ return setmetatable(self, mt)
+ end
+
local self = {
- limit_count = limit_count.new(plugin_name, limit, window),
- dict = ngx.shared[plugin_name .. "-reset-header"],
limit = limit,
window = window,
+ window_type = window_type,
+ limit_count = limit_count.new(plugin_name, limit, window),
+ dict = ngx.shared[plugin_name .. "-reset-header"]
}
return setmetatable(self, mt)
end
-function _M.incoming(self, key, commit, conf, cost)
- local delay, consumed_or_err = self.limit_count:incoming(key, commit, cost)
+function _M.incoming(self, key, flag_or_cost, _conf, cost_arg)
+ local cost
+ if type(flag_or_cost) == "boolean" then
+ -- old API: incoming(key, flag, conf, cost)
+ cost = cost_arg
+ else
+ -- new API: incoming(key, cost)
+ cost = flag_or_cost
+ end
+
+ if self.window_type == "sliding" then
+ return self.limit_count:incoming(key, cost)
+ end
+
+ local delay, consumed_or_err = self.limit_count:incoming(key, true, cost)
local reset
local remaining_or_err = consumed_or_err
diff --git a/apisix/plugins/limit-count/limit-count-redis-cluster.lua b/apisix/plugins/limit-count/limit-count-redis-cluster.lua
index 89a823e61..d0d55e338 100644
--- a/apisix/plugins/limit-count/limit-count-redis-cluster.lua
+++ b/apisix/plugins/limit-count/limit-count-redis-cluster.lua
@@ -17,73 +17,113 @@
local redis_cluster = require("apisix.utils.rediscluster")
local core = require("apisix.core")
-local to_hex = require("resty.string").to_hex
+local delayed_syncer = require("apisix.plugins.limit-count.delayed-syncer")
+local sliding_window = require("apisix.plugins.limit-count.sliding-window.sliding-window")
+local sliding_window_store = require("apisix.plugins.limit-count."
+ .. "sliding-window.store.redis")
+local limit_count_local = require("apisix.plugins.limit-count.limit-count-local")
+local util = require("apisix.plugins.limit-count.util")
+
+local timer_at = ngx.timer.at
local setmetatable = setmetatable
-local tostring = tostring
local _M = {}
-
local mt = {
__index = _M
}
-
-local script = core.string.compress_script([=[
- assert(tonumber(ARGV[3]) >= 1, "cost must be at least 1")
- local ttl = redis.call('ttl', KEYS[1])
- if ttl < 0 then
- redis.call('set', KEYS[1], ARGV[1] - ARGV[3], 'EX', ARGV[2])
- return {ARGV[1] - ARGV[3], ARGV[2]}
- end
- return {redis.call('incrby', KEYS[1], 0 - ARGV[3]), ttl}
-]=])
-local script_sha = to_hex(ngx.sha1_bin(script))
-
-
-function _M.new(plugin_name, limit, window, conf)
+function _M.new(plugin_name, limit, window, conf, key_version)
local red_cli, err = redis_cluster.new(conf, "plugin-limit-count-redis-cluster-slot-lock")
if not red_cli then
return nil, err
end
+ local fallback_limiter, fallback_err = limit_count_local.new(plugin_name,
+ limit, window,
+ conf.window_type)
+ if not fallback_limiter then
+ return nil, fallback_err
+ end
+
+ local enable_delayed_sync = conf.sync_interval and conf.sync_interval ~= -1
+
+ if conf.window_type == "sliding" then
+ local sw_limit_count
+ sw_limit_count, err = sliding_window.new(sliding_window_store, limit, window, red_cli)
+ if not sw_limit_count then
+ return nil, err
+ end
+
+ sw_limit_count.fallback_limiter = fallback_limiter
+ sw_limit_count.plugin_name = plugin_name
+ local self = {
+ limit = limit,
+ window_type = conf.window_type,
+ limit_count = sw_limit_count,
+ }
+
+ if enable_delayed_sync then
+ local ds, ds_err = delayed_syncer.new(plugin_name, limit, window,
+ conf, self.limit_count)
+ if not ds then
+ return nil, ds_err
+ end
+ self.delayed_syncer = ds
+ end
+ return setmetatable(self, mt)
+ end
+
local self = {
limit = limit,
window = window,
conf = conf,
plugin_name = plugin_name,
+ key_version = key_version,
red_cli = red_cli,
+ fallback_limiter = fallback_limiter,
}
+ if enable_delayed_sync then
+ local ds, ds_err = delayed_syncer.new(plugin_name, limit, window, conf, self)
+ if not ds then
+ return nil, ds_err
+ end
+ self.delayed_syncer = ds
+ end
+
return setmetatable(self, mt)
end
-
-function _M.incoming(self, key, cost)
- local red = self.red_cli
- local limit = self.limit
- local window = self.window
- key = self.plugin_name .. tostring(key)
-
- local ttl = 0
- local res, err = red:evalsha(script_sha, 1, key, limit, window, cost or 1)
- if err and core.string.has_prefix(err, "NOSCRIPT") then
- core.log.warn("redis evalsha failed: ", err, ". Falling back to eval")
- res, err = red:eval(script, 1, key, limit, window, cost or 1)
+function _M.incoming_delayed(self, key, cost, syncer_id)
+ local remaining, reset, err = self.delayed_syncer:delayed_sync(key, cost, syncer_id)
+ if not remaining then
+ return nil, err, 0
+ end
+ if remaining < 0 then
+ return nil, "rejected", reset
end
+ return 0, remaining, reset
+end
- if err then
- return nil, err, ttl
+function _M.incoming(self, key, cost)
+ if self.window_type == "sliding" then
+ return self.limit_count:incoming(key, cost)
end
- local remaining = res[1]
- ttl = res[2]
+ return util.redis_incoming(self, key, cost, false)
+end
- if remaining < 0 then
- return nil, "rejected", ttl
+function _M.log_phase_incoming(self, key, cost)
+ local ok, err = timer_at(0, function ()
+ local delay, incoming_err = self:incoming(key, cost)
+ if not delay and incoming_err ~= "rejected" then
+ core.log.error("failed to sync limit count in log phase: ", incoming_err)
+ end
+ end)
+ if not ok then
+ core.log.error("failed to schedule timer: ", err)
end
- return 0, remaining, ttl
end
-
return _M
diff --git a/apisix/plugins/limit-count/limit-count-redis-sentinel.lua b/apisix/plugins/limit-count/limit-count-redis-sentinel.lua
new file mode 100644
index 000000000..dfe20e508
--- /dev/null
+++ b/apisix/plugins/limit-count/limit-count-redis-sentinel.lua
@@ -0,0 +1,130 @@
+--
+-- 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.
+--
+local delayed_syncer = require("apisix.plugins.limit-count.delayed-syncer")
+local sliding_window = require("apisix.plugins.limit-count.sliding-window.sliding-window")
+local sliding_window_store = require("apisix.plugins.limit-count."
+ .. "sliding-window.store.redis")
+local limit_count_local = require("apisix.plugins.limit-count.limit-count-local")
+local redis_cli_sentinel = require("apisix.plugins.limit-count.util").redis_cli_sentinel
+local util = require("apisix.plugins.limit-count.util")
+local timer_at = ngx.timer.at
+local core = require("apisix.core")
+local assert = assert
+local setmetatable = setmetatable
+local _M = {}
+
+local mt = {
+ __index = _M
+}
+
+
+function _M.new(plugin_name, limit, window, conf, key_version)
+ assert(limit > 0 and window > 0)
+ local fallback_limiter, err = limit_count_local.new(plugin_name,
+ limit, window, conf.window_type)
+ if not fallback_limiter then
+ return nil, err
+ end
+
+ local enable_delayed_sync = conf.sync_interval and conf.sync_interval ~= -1
+
+ if conf.window_type == "sliding" then
+ local sw_limit_count, err = sliding_window.new_with_red_cli_factory(sliding_window_store,
+ limit, window, redis_cli_sentinel, conf)
+ if not sw_limit_count then
+ return nil, err
+ end
+ sw_limit_count.fallback_limiter = fallback_limiter
+ sw_limit_count.plugin_name = plugin_name
+ local self = {
+ limit = limit,
+ window_type = conf.window_type,
+ limit_count = sw_limit_count,
+ }
+
+ if enable_delayed_sync then
+ local ds, ds_err = delayed_syncer.new(plugin_name, limit, window,
+ conf, self.limit_count)
+ if not ds then
+ return nil, ds_err
+ end
+ self.delayed_syncer = ds
+ end
+ return setmetatable(self, mt)
+ end
+
+ local self = {
+ limit = limit,
+ window = window,
+ conf = conf,
+ plugin_name = plugin_name,
+ key_version = key_version,
+ fallback_limiter = fallback_limiter,
+ }
+ if enable_delayed_sync then
+ local ds, ds_err = delayed_syncer.new(plugin_name, limit, window, conf, self)
+ if not ds then
+ return nil, ds_err
+ end
+ self.delayed_syncer = ds
+ end
+ return setmetatable(self, mt)
+end
+
+
+function _M.incoming_delayed(self, key, cost, syncer_id)
+ local remaining, reset, err = self.delayed_syncer:delayed_sync(key, cost, syncer_id)
+ if not remaining then
+ return nil, err, 0
+ end
+ if remaining < 0 then
+ return nil, "rejected", reset
+ end
+ return 0, remaining, reset
+end
+
+
+function _M.incoming(self, key, cost)
+ if self.window_type == "sliding" then
+ return self.limit_count:incoming(key, cost)
+ end
+
+ local conf = self.conf
+ local red, err = redis_cli_sentinel(conf)
+ if not red then
+ return nil, err, 0
+ end
+ self.red_cli = red
+ return util.redis_incoming(self, key, cost, true)
+end
+
+
+function _M.log_phase_incoming(self, key, cost)
+ local ok, err = timer_at(0, function ()
+ local delay, err = self:incoming(key, cost)
+ if not delay then
+ if err ~= "rejected" then
+ core.log.error("failed to sync limit count in log phase: ", err)
+ end
+ end
+ end)
+ if not ok then
+ core.log.error("failed to schedule timer: ", err)
+ end
+end
+
+return _M
diff --git a/apisix/plugins/limit-count/limit-count-redis.lua b/apisix/plugins/limit-count/limit-count-redis.lua
index 5580b22a0..20b627078 100644
--- a/apisix/plugins/limit-count/limit-count-redis.lua
+++ b/apisix/plugins/limit-count/limit-count-redis.lua
@@ -14,82 +14,124 @@
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-local redis = require("apisix.utils.redis")
local core = require("apisix.core")
-local to_hex = require("resty.string").to_hex
+local delayed_syncer = require("apisix.plugins.limit-count.delayed-syncer")
+local sliding_window = require("apisix.plugins.limit-count.sliding-window.sliding-window")
+local sliding_window_store = require("apisix.plugins.limit-count."
+ .. "sliding-window.store.redis")
+local limit_count_local = require("apisix.plugins.limit-count.limit-count-local")
+local util = require("apisix.plugins.limit-count.util")
+local redis_cli = util.redis_cli
+
local assert = assert
local setmetatable = setmetatable
-local tostring = tostring
-
+local timer_at = ngx.timer.at
local _M = {version = 0.3}
-
local mt = {
__index = _M
}
+function _M.new(plugin_name, limit, window, conf, key_version)
+ assert(limit > 0 and window > 0)
-local script = core.string.compress_script([=[
- assert(tonumber(ARGV[3]) >= 1, "cost must be at least 1")
- local ttl = redis.call('ttl', KEYS[1])
- if ttl < 0 then
- redis.call('set', KEYS[1], ARGV[1] - ARGV[3], 'EX', ARGV[2])
- return {ARGV[1] - ARGV[3], ARGV[2]}
+ local fallback_limiter, err = limit_count_local.new(plugin_name,
+ limit, window, conf.window_type)
+ if not fallback_limiter then
+ return nil, err
end
- return {redis.call('incrby', KEYS[1], 0 - ARGV[3]), ttl}
-]=])
-local script_sha = to_hex(ngx.sha1_bin(script))
-
-function _M.new(plugin_name, limit, window, conf)
- assert(limit > 0 and window > 0)
+ local enable_delayed_sync = conf.sync_interval and conf.sync_interval ~= -1
+
+ if conf.window_type == "sliding" then
+ local sw_limit_count
+ sw_limit_count, err = sliding_window.new_with_red_cli_factory(sliding_window_store,
+ limit, window,
+ redis_cli, conf)
+ if not sw_limit_count then
+ return nil, err
+ end
+
+ sw_limit_count.fallback_limiter = fallback_limiter
+ sw_limit_count.plugin_name = plugin_name
+ local self = {
+ limit = limit,
+ window_type = conf.window_type,
+ limit_count = sw_limit_count,
+ }
+
+ if enable_delayed_sync then
+ local ds, ds_err = delayed_syncer.new(plugin_name, limit, window,
+ conf, self.limit_count)
+ if not ds then
+ return nil, ds_err
+ end
+ self.delayed_syncer = ds
+ end
+ return setmetatable(self, mt)
+ end
local self = {
limit = limit,
window = window,
conf = conf,
plugin_name = plugin_name,
+ key_version = key_version,
+ fallback_limiter = fallback_limiter,
}
+
+ if enable_delayed_sync then
+ local ds, ds_err = delayed_syncer.new(plugin_name, limit, window, conf, self)
+ if not ds then
+ return nil, ds_err
+ end
+ self.delayed_syncer = ds
+ end
+
return setmetatable(self, mt)
end
-function _M.incoming(self, key, cost)
- local conf = self.conf
- local red, err = redis.new(conf)
- if not red then
- return red, err, 0
+function _M.incoming_delayed(self, key, cost, syncer_id)
+ local remaining, reset, err = self.delayed_syncer:delayed_sync(key, cost, syncer_id)
+ if not remaining then
+ return nil, err, 0
end
+ if remaining < 0 then
+ return nil, "rejected", reset
+ end
+ return 0, remaining, reset
+end
- local limit = self.limit
- local window = self.window
- local res
- key = self.plugin_name .. tostring(key)
+function _M.incoming(self, key, cost)
+ if self.window_type == "sliding" then
+ return self.limit_count:incoming(key, cost)
+ end
- local ttl = 0
- res, err = red:evalsha(script_sha, 1, key, limit, window, cost or 1)
- if err and core.string.has_prefix(err, "NOSCRIPT") then
- core.log.warn("redis evalsha failed: ", err, ". Falling back to eval")
- res, err = red:eval(script, 1, key, limit, window, cost or 1)
+ local red, err = redis_cli(self.conf)
+ if not red then
+ return nil, err, 0
end
- if err then
- return nil, err, ttl
+ self.red_cli = red
+ local delay, remaining, ttl = util.redis_incoming(self, key, cost, true)
+ if not delay and remaining ~= "rejected" then
+ return nil, remaining, ttl
end
- local remaining = res[1]
- ttl = res[2]
+ return delay, remaining, ttl
+end
- local ok, err = red:set_keepalive(conf.redis_keepalive_timeout, conf.redis_keepalive_pool)
+function _M.log_phase_incoming(self, key, cost)
+ local ok, err = timer_at(0, function ()
+ local delay, incoming_err = self:incoming(key, cost)
+ if not delay and incoming_err ~= "rejected" then
+ core.log.error("failed to sync limit count in log phase: ", incoming_err)
+ end
+ end)
if not ok then
- return nil, err, ttl
+ core.log.error("failed to schedule timer: ", err)
end
-
- if remaining < 0 then
- return nil, "rejected", ttl
- end
- return 0, remaining, ttl
end
-
return _M
diff --git a/apisix/plugins/limit-count/sliding-window/sliding-window.lua b/apisix/plugins/limit-count/sliding-window/sliding-window.lua
new file mode 100644
index 000000000..89d33a273
--- /dev/null
+++ b/apisix/plugins/limit-count/sliding-window/sliding-window.lua
@@ -0,0 +1,244 @@
+--
+-- 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.
+--
+local tostring = tostring
+local string_format = string.format
+local math_floor = math.floor
+local math_ceil = math.ceil
+
+local ngx_now = ngx.now
+local setmetatable = setmetatable
+local log = require("apisix.core.log")
+
+local _M = {}
+local mt = { __index = _M }
+
+local function round_off_decimal_places(input, places)
+ local multiplier = 10 ^ places
+ return math_ceil(input * multiplier) / multiplier
+end
+
+
+-- uniquely identifies the window associated with given time
+local function get_window_id(self, time)
+ return tostring(math_floor(time / self.window_size))
+end
+
+
+local function get_counter_key(self, key, time)
+ local wid = get_window_id(self, time)
+ -- Prefix with plugin_name (set only for the Redis-backed stores) so that two
+ -- plugins reusing this module on the same resource with identical config
+ -- (and therefore the same gen_limit_key) cannot share a Redis counter, the
+ -- way the fixed-window Redis path already isolates them. The local store is
+ -- already namespaced by its per-plugin shared dict, so it passes no name and
+ -- keeps the original key format.
+ if self.plugin_name then
+ return string_format("%s:%s.%s.counter", self.plugin_name, key, wid)
+ end
+ return string_format("%s.%s.counter", key, wid)
+end
+
+
+local function get_last_rate(self, sample, now_ms, red_cli)
+ local a_window_ago_from_now = now_ms - self.window_size
+ local last_counter_key = get_counter_key(self, sample, a_window_ago_from_now)
+
+ local last_count, err = self.store:get(last_counter_key, red_cli)
+ if err then
+ return nil, err
+ end
+ if not last_count then
+ last_count = 0
+ end
+ if last_count > self.limit then
+ -- in incoming we also reactively check for exceeding limit
+ -- after icnrementing the counter. So even though counter can be higher
+ -- than the limit as a result of racy behaviour we would still throttle
+ -- anyway. That is way it is important to correct the last count here
+ -- to avoid over-punishment.
+ last_count = self.limit
+ end
+
+ return last_count / self.window_size
+end
+
+
+function _M.new(store, limit, window_size, red_cli)
+ if not store then
+ return nil, "'store' parameter is missing"
+ end
+ if not store.incr then
+ return nil, "'store' has to implement 'incr' function"
+ end
+ if not store.get then
+ return nil, "'store' has to implement 'get' function"
+ end
+
+ return setmetatable({
+ store = store,
+ limit = limit,
+ window_size = window_size,
+ red_cli = red_cli
+ }, mt)
+end
+
+
+function _M.new_with_red_cli_factory(store, limit, window_size, red_cli_factory, conf)
+ if not store then
+ return nil, "'store' parameter is missing"
+ end
+ if not store.incr then
+ return nil, "'store' has to implement 'incr' function"
+ end
+ if not store.get then
+ return nil, "'store' has to implement 'get' function"
+ end
+
+ return setmetatable({
+ store = store,
+ limit = limit,
+ window_size = window_size,
+ conf = conf,
+ red_cli_factory = red_cli_factory
+ }, mt)
+end
+
+
+local function get_desired_delay(self, remaining_time, last_rate, count)
+ if last_rate == 0 then
+ return remaining_time
+ end
+
+ local desired_delay = remaining_time - (self.limit - count) / last_rate
+
+ if desired_delay <= 0 then
+ return 0
+ end
+
+ return desired_delay
+end
+
+
+function _M.incoming(self, key, cost)
+ local now = ngx_now()
+ local counter_key = get_counter_key(self, key, now)
+ local remaining_time = self.window_size - now % self.window_size
+
+ local red_cli, err
+ if not self.red_cli and self.red_cli_factory then
+ red_cli, err = self.red_cli_factory(self.conf)
+ if not red_cli then
+ return nil, err, 0
+ end
+ end
+
+ local count, err = self.store:get(counter_key, self.red_cli or red_cli)
+ if err then
+ return nil, err
+ end
+ if not count then
+ count = 0
+ end
+ log.debug("count: ", count, ", limit: ", self.limit)
+ if count >= self.limit then
+ return nil, "rejected", round_off_decimal_places(remaining_time, 2)
+ end
+
+ local last_rate
+ last_rate, err = get_last_rate(self, key, now, self.red_cli or red_cli)
+ if err then
+ return nil, err, 0
+ end
+
+ local estimated_last_window_count = last_rate * remaining_time
+ local estimated_final_count = estimated_last_window_count + count
+ log.debug("estimated_final_count: ", estimated_final_count, ", limit: ", self.limit)
+ if estimated_final_count >= self.limit then
+ local desired_delay =
+ get_desired_delay(self, remaining_time, last_rate, count)
+ return nil, "rejected", round_off_decimal_places(desired_delay, 2)
+ end
+
+ local expiry = self.window_size * 2
+ local new_count
+ new_count, err = self.store:incr(counter_key, cost, expiry, self.red_cli or red_cli)
+ if err then
+ return nil, err, 0
+ end
+
+ if red_cli then
+ red_cli:set_keepalive(10000, 100)
+ end
+
+ -- The below limit checking is only to cope with a racy behaviour where
+ -- counter for the given sample is incremented at the same time by multiple
+ -- sliding_window instances. That is we re-adjust the new count by ignoring
+ -- the current occurrence of the sample. Otherwise the limit would
+ -- unncessarily be exceeding.
+ local new_adjusted_count = new_count - cost
+ log.debug("new_adjusted_count: ", new_adjusted_count, ", limit: ", self.limit)
+
+ if new_adjusted_count >= self.limit then
+ -- incr above might take long enough to make difference, so
+ -- we recalculate time-dependant variables.
+ remaining_time = self.window_size - ngx_now() % self.window_size
+ return nil, "rejected", round_off_decimal_places(remaining_time, 2)
+ end
+
+ local remaining = self.limit - new_count - estimated_last_window_count
+ local rounded_remaining = math_floor(remaining)
+
+ return 0, rounded_remaining, round_off_decimal_places(remaining_time, 2)
+end
+
+
+-- commit unconditionally adds an already-permitted delta to the counter and
+-- reports the resulting remaining, skipping the pre-increment rejection that
+-- incoming() applies. Delayed sync flushes a locally-permitted delta with it:
+-- those requests already happened, so the delta must reach the shared store
+-- even when the window is already at/over the limit -- otherwise the global
+-- count permanently under-records. This mirrors the fixed-window backend, whose
+-- Redis script likewise always increments before it can report "rejected".
+function _M.commit(self, key, cost)
+ local now = ngx_now()
+ local counter_key = get_counter_key(self, key, now)
+ local remaining_time = self.window_size - now % self.window_size
+
+ local red_cli, err
+ if not self.red_cli and self.red_cli_factory then
+ red_cli, err = self.red_cli_factory(self.conf)
+ if not red_cli then
+ return nil, err, 0
+ end
+ end
+
+ local expiry = self.window_size * 2
+ local new_count
+ new_count, err = self.store:incr(counter_key, cost, expiry, self.red_cli or red_cli)
+ if err then
+ return nil, err, 0
+ end
+
+ if red_cli then
+ red_cli:set_keepalive(10000, 100)
+ end
+
+ local remaining = math_floor(self.limit - new_count)
+ return 0, remaining, round_off_decimal_places(remaining_time, 2)
+end
+
+return _M
diff --git a/apisix/plugins/limit-count/sliding-window/store/redis.lua b/apisix/plugins/limit-count/sliding-window/store/redis.lua
new file mode 100644
index 000000000..6ff77d603
--- /dev/null
+++ b/apisix/plugins/limit-count/sliding-window/store/redis.lua
@@ -0,0 +1,72 @@
+--
+-- 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.
+--
+local ngx_null = ngx.null
+local tonumber = tonumber
+local core = require("apisix.core")
+local to_hex = require("resty.string").to_hex
+
+local _M = {}
+
+
+local incr_script = core.string.compress_script([=[
+ local ttl = redis.call('pttl', KEYS[1])
+ if ttl < 0 then
+ redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2])
+ return tonumber(ARGV[1])
+ end
+ return redis.call('incrby', KEYS[1], ARGV[1])
+]=])
+local incr_script_sha = to_hex(ngx.sha1_bin(incr_script))
+
+
+-- TODO: keepalive or close
+function _M.incr(self, key, delta, expiry, red)
+ -- nk key1 argv1 argv2
+ local new_value, err
+ new_value, err = red:evalsha(incr_script_sha, 1, key, delta, expiry)
+ if err and core.string.has_prefix(err, "NOSCRIPT") then
+ core.log.warn("redis evalsha failed: ", err, ". Falling back to eval")
+ new_value, err = red:eval(incr_script, 1, key, delta, expiry)
+ end
+ if err then
+ return nil, err
+ end
+
+ if not new_value then
+ return nil, "malformed redis response while calling incr"
+ end
+
+ return new_value
+end
+
+
+-- TODO: keepalive or close
+function _M.get(self, key, red)
+ local value, err = red:get(key)
+ if not value or value == ngx_null then
+ return nil, err
+ end
+
+ value = tonumber(value)
+ if not value then -- maybe warn log?
+ return nil, "redis counter is not a number the value could have been modified"
+ end
+
+ return value, nil
+end
+
+return _M
diff --git a/apisix/plugins/limit-count/sliding-window/store/shared-dict.lua b/apisix/plugins/limit-count/sliding-window/store/shared-dict.lua
new file mode 100644
index 000000000..0401bf0b4
--- /dev/null
+++ b/apisix/plugins/limit-count/sliding-window/store/shared-dict.lua
@@ -0,0 +1,64 @@
+--
+-- 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.
+--
+local ngx = ngx
+local log = require("apisix.core.log")
+local string_format = string.format
+local setmetatable = setmetatable
+
+local _M = {}
+local mt = { __index = _M }
+
+function _M.new(options)
+ if not options.name then
+ return nil, "shared dictionary name is mandatory"
+ end
+
+ local dict = ngx.shared[options.name]
+ if not dict then
+ return nil,
+ string_format("shared dictionary with name \"%s\" is not configured",
+ options.name)
+ end
+
+ return setmetatable({
+ dict = dict,
+ }, mt)
+end
+
+function _M.incr(self, key, delta, expiry)
+ local new_value, err, forcible = self.dict:incr(key, delta, 0, expiry)
+ if err then
+ return nil, err
+ end
+
+ if forcible then
+ log.warn("shared dictionary is full, removed valid key(s) to store the new one")
+ end
+
+ return new_value
+end
+
+function _M.get(self, key)
+ local value, err = self.dict:get(key)
+ if not value then
+ return nil, err
+ end
+
+ return value
+end
+
+return _M
diff --git a/apisix/plugins/limit-count/util.lua b/apisix/plugins/limit-count/util.lua
new file mode 100644
index 000000000..ce1a87c19
--- /dev/null
+++ b/apisix/plugins/limit-count/util.lua
@@ -0,0 +1,175 @@
+--
+-- 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.
+--
+local core = require("apisix.core")
+local redis_new = require("resty.redis").new
+local redis_sentinel = require("resty.redis.connector")
+local to_hex = require("resty.string").to_hex
+local _M = {}
+
+local tostring = tostring
+
+local commit_script = core.string.compress_script([=[
+ assert(tonumber(ARGV[3]) >= 0, "cost must be at least 0")
+ local ttl = redis.call('pttl', KEYS[1])
+ if ttl < 0 then
+ redis.call('set', KEYS[1], ARGV[3], 'EX', ARGV[2])
+ return {ARGV[3], ARGV[2] * 1000}
+ end
+ return {redis.call('incrby', KEYS[1], ARGV[3]), ttl}
+]=])
+local commit_script_sha = to_hex(ngx.sha1_bin(commit_script))
+
+
+function _M.redis_cli(conf)
+ local red = redis_new()
+ local timeout = conf.redis_timeout or 1000 -- 1sec
+
+ red:set_timeouts(timeout, timeout, timeout)
+
+ local sock_opts = {
+ ssl = conf.redis_ssl,
+ ssl_verify = conf.redis_ssl_verify
+ }
+
+ local ok, err = red:connect(conf.redis_host, conf.redis_port or 6379, sock_opts)
+ if not ok then
+ return nil, err
+ end
+
+ local count
+ count, err = red:get_reused_times()
+ if 0 == count then
+ if conf.redis_password and conf.redis_password ~= '' then
+ local ok, err
+ if conf.redis_username then
+ ok, err = red:auth(conf.redis_username, conf.redis_password)
+ else
+ ok, err = red:auth(conf.redis_password)
+ end
+ if not ok then
+ return nil, err
+ end
+ end
+
+ -- select db
+ if (conf.redis_database or 0) ~= 0 then
+ local ok, err = red:select(conf.redis_database)
+ if not ok then
+ return nil, "failed to change redis db, err: " .. err
+ end
+ end
+ elseif err then
+ -- core.log.info(" err: ", err)
+ return nil, err
+ end
+ return red, nil
+end
+
+function _M.redis_cli_sentinel(conf)
+ local redis_conf = {
+ username = conf.redis_username,
+ password = conf.redis_password,
+ sentinel_username = conf.sentinel_username,
+ sentinel_password = conf.sentinel_password,
+ db = conf.redis_database or 0,
+ sentinels = conf.redis_sentinels or {},
+ master_name = conf.redis_master_name,
+ role = conf.redis_role or "master",
+ connect_timeout = conf.redis_connect_timeout or 1000,
+ read_timeout = conf.redis_read_timeout or 1000,
+ keepalive_timeout = conf.redis_keepalive_timeout or 60000,
+ }
+
+ local sentinel_client, err = redis_sentinel.new(redis_conf)
+ if not sentinel_client then
+ return nil, "failed to create redis client: " .. (err or "unknown error")
+ end
+
+ -- In case of errors, returns "nil, err, previous_errors" where err is
+ -- the last error received, and previous_errors is a table of the previous errors.
+ local red, err, previous_errors = sentinel_client:connect()
+ if not red then
+ local err = "redis connection failed, err: " .. (err or "unknown error")
+ if previous_errors and #previous_errors > 0 then
+ err = err .. ", previous_errors: " .. core.table.concat(previous_errors, ", ")
+ end
+ return nil, err
+ end
+ return red, nil
+end
+
+
+function _M.redis_incoming(self, key, cost, keepalive)
+ if self.window_type == "sliding" then
+ return self.limit_count:incoming(key, cost)
+ end
+
+ local red = self.red_cli
+ if not red then
+ return nil, "redis client not initialized", 0
+ end
+
+ local limit = self.limit
+ local window = self.window
+ -- The fixed-window counter stores the consumed count and increments it;
+ -- earlier releases stored the remaining quota and decremented it on the same
+ -- key. When a storage-format version is set, embed it in the key so the two
+ -- formats never share a key across an upgrade (which would over- or
+ -- under-limit the route); old keys just expire via their own TTL. Backends
+ -- created without a key_version (e.g. direct unit-test use) keep the
+ -- original unversioned key.
+ if self.key_version then
+ key = self.plugin_name .. ":" .. self.key_version .. ":" .. tostring(key)
+ else
+ key = self.plugin_name .. tostring(key)
+ end
+
+ core.log.info("syncing limit count to redis, key: ", key,
+ ", limit: ", limit, ", window: ", window, ", cost: ", cost)
+
+ local res, err
+ res, err = red:evalsha(commit_script_sha, 1, key, limit, window, cost)
+ if err and core.string.has_prefix(err, "NOSCRIPT") then
+ core.log.warn("redis evalsha failed: ", err, ". Falling back to eval")
+ res, err = red:eval(commit_script, 1, key, limit, window, cost)
+ end
+ if err then
+ return nil, err, 0
+ end
+
+ local remaining = limit - res[1]
+ local ttl = res[2] / 1000.0
+
+ if keepalive then
+ local conf = self.conf or {}
+ local ok, err = red:set_keepalive(conf.redis_keepalive_timeout or 10000,
+ conf.redis_keepalive_pool or 100)
+ if not ok then
+ core.log.error("failed to set keepalive for redis: ", err)
+ end
+ end
+
+
+ if remaining < 0 then
+ return nil, "rejected", ttl
+ end
+
+ return 0, remaining, ttl
+end
+
+
+return _M
diff --git a/ci/pod/docker-compose.plugin.yml b/ci/pod/docker-compose.plugin.yml
index 3f5803e50..e66877ca6 100644
--- a/ci/pod/docker-compose.plugin.yml
+++ b/ci/pod/docker-compose.plugin.yml
@@ -18,6 +18,81 @@
version: "3.8"
services:
+ redis-master:
+ image: redis:latest
+ container_name: redis-master
+ hostname: redis-master
+ ports:
+ - "6479:6379"
+ volumes:
+ - ./data/master:/data
+ command: >
+ sh -c '
+ echo "user master on >master-password ~* +@all" > /data/users.acl;
+ redis-server --appendonly yes --repl-diskless-load on-empty-db --protected-mode no --aclfile /data/users.acl
+ '
+ networks:
+ - apisix_net
+
+ redis-slave-1:
+ image: redis:latest
+ container_name: redis-slave-1
+ hostname: redis-slave-1
+ depends_on:
+ - redis-master
+ volumes:
+ - ./data/slave1:/data
+ command: [
+ "redis-server",
+ "--appendonly", "yes",
+ "--replicaof", "redis-master", "6379",
+ "--repl-diskless-load", "on-empty-db",
+ "--protected-mode", "no"
+ ]
+ networks:
+ - apisix_net
+
+ sentinel-1:
+ image: redis:latest
+ container_name: sentinel-1
+ hostname: sentinel-1
+ depends_on:
+ - redis-master
+ ports:
+ - "26379:26379"
+ command: >
+ sh -c 'echo "bind 0.0.0.0" > /etc/sentinel.conf;
+ echo "sentinel monitor mymaster redis-master 6379 2" >> /etc/sentinel.conf;
+ echo "sentinel resolve-hostnames yes" >> /etc/sentinel.conf;
+ echo "sentinel down-after-milliseconds mymaster 10000" >> /etc/sentinel.conf;
+ echo "sentinel failover-timeout mymaster 10000" >> /etc/sentinel.conf;
+ echo "sentinel parallel-syncs mymaster 1" >> /etc/sentinel.conf;
+ redis-sentinel /etc/sentinel.conf'
+ networks:
+ - apisix_net
+
+ sentinel-2:
+ image: redis:7.0
+ container_name: sentinel-2
+ hostname: sentinel-2
+ depends_on:
+ - redis-master
+ ports:
+ - "26380:26379"
+ command: >
+ sh -c 'echo "bind 0.0.0.0" > /etc/sentinel.conf;
+ echo "sentinel monitor mymaster redis-master 6379 2" >> /etc/sentinel.conf;
+ echo "sentinel resolve-hostnames yes" >> /etc/sentinel.conf;
+ echo "sentinel down-after-milliseconds mymaster 10000" >> /etc/sentinel.conf;
+ echo "sentinel failover-timeout mymaster 10000" >> /etc/sentinel.conf;
+ echo "sentinel parallel-syncs mymaster 1" >> /etc/sentinel.conf;
+ echo "aclfile /etc/sentinel.acl" >> /etc/sentinel.conf;
+ echo "user admin on >admin-password +@all" > /etc/sentinel.acl;
+ echo "user default off" >> /etc/sentinel.acl;
+ redis-sentinel /etc/sentinel.conf'
+ networks:
+ - apisix_net
+
## keycloak
apisix_keycloak:
container_name: apisix_keycloak
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index e26dbd5a3..a4f5c6f1e 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -294,6 +294,7 @@ nginx_config: # Config for render the template to generate n
balancer-ewma-locks: 10m
balancer-ewma-last-touched-at: 10m
plugin-limit-req-redis-cluster-slot-lock: 1m
+ plugin-limit-count-lock: 10m
plugin-limit-count-redis-cluster-slot-lock: 1m
plugin-limit-conn-redis-cluster-slot-lock: 1m
plugin-graphql-limit-count: 10m
diff --git a/docs/en/latest/plugins/limit-count.md b/docs/en/latest/plugins/limit-count.md
index 8b760211d..f00672cfe 100644
--- a/docs/en/latest/plugins/limit-count.md
+++ b/docs/en/latest/plugins/limit-count.md
@@ -4,7 +4,7 @@ keywords:
- Apache APISIX
- API Gateway
- Limit Count
-description: The limit-count plugin uses a fixed window algorithm to limit the rate of requests by the number of requests within a given time interval. Requests exceeding the configured quota will be rejected.
+description: The limit-count plugin uses fixed or sliding window algorithms to limit the rate of requests by the number of requests within a given time interval. Requests exceeding the configured quota will be rejected.
---
<!--
@@ -35,7 +35,7 @@ import TabItem from '@theme/TabItem';
## Description
-The `limit-count` plugin uses a fixed window algorithm to limit the rate of requests by the number of requests within a given time interval. Requests exceeding the configured quota will be rejected.
+The `limit-count` plugin uses fixed or sliding window algorithms to limit the rate of requests by the number of requests within a given time interval. Requests exceeding the configured quota will be rejected.
You may see the following rate limiting headers in the response:
@@ -47,35 +47,44 @@ You may see the following rate limiting headers in the response:
| Name | Type | Required | Default | Valid values | Description [...]
| ----------------------- | ------- | ----------------------------------------- | ------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- [...]
-| count | integer or string | False | | > 0 | The maximum number of requests allowed within a given time interval. Required if `rules` is not configured. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr) from APISIX 3.16.0. [...]
-| time_window | integer or string | False | | > 0 | The time interval corresponding to the rate limiting `count` in seconds. Required if `rules` is not configured. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr) from APISIX 3.16.0. [...]
-| rules | array[object] | False | | | A list of rate limiting rules. Each rule is an object containing `count`, `time_window`, and `key`. [...]
-| rules.count | integer or string | True | | > 0 | The maximum number of requests allowed within a given time interval. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr). [...]
-| rules.time_window | integer or string | True | | > 0 | The time interval corresponding to the rate limiting `count` in seconds. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr). [...]
-| rules.key | string | True | | | The key to count requests by. If the configured key does not exist, the rule will not be executed. The `key` is interpreted as a combination of variables, for example: `$http_custom_a $http_custom_b`. [...]
-| rules.header_prefix | string | False | | | Prefix for rate limit headers. If configured, the response will include `X-{header_prefix}-RateLimit-Limit`, `X-{header_prefix}-RateLimit-Remaining`, and `X-{header_prefix}-RateLimit-Reset` headers. If not configured, the index of the rule in the rules array is used as the prefix. For example, headers for the first rule will be `X-1-RateLimit-Limit`, `X-1-RateLim [...]
-| key_type | string | False | var | ["var","var_combination","constant"] | The type of key. If the `key_type` is `var`, the `key` is interpreted a variable. If the `key_type` is `var_combination`, the `key` is interpreted as a combination of variables. If the `key_type` is `constant`, the `key` is interpreted as a constant. |
-| key | string | False | remote_addr | | The key to count requests by. If the `key_type` is `var`, the `key` is interpreted a variable. The variable does not need to be prefixed by a dollar sign (`$`). If the `key_type` is `var_combination`, the `key` is interpreted as a combination of variables. All variables should be prefixed by dollar signs (`$`). For example, to configure the `key` to use [...]
-| rejected_code | integer | False | 503 | [200,...,599] | The HTTP status code returned when a request is rejected for exceeding the threshold. [...]
-| rejected_msg | string | False | | non-empty | The response body returned when a request is rejected for exceeding the threshold. [...]
-| policy | string | False | local | ["local","redis","redis-cluster"] | The policy for rate limiting counter. If it is `local`, the counter is stored in memory locally. If it is `redis`, the counter is stored on a Redis instance. If it is `redis-cluster`, the counter is stored in a Redis cluster. |
-| allow_degradation | boolean | False | false | | If true, allow APISIX to continue handling requests without the plugin when the plugin or its dependencies become unavailable. [...]
-| show_limit_quota_header | boolean | False | true | | If true, include `X-RateLimit-Limit` to show the total quota and `X-RateLimit-Remaining` to show the remaining quota in the response header. [...]
-| group | string | False | | non-empty | The `group` ID for the plugin, such that routes of the same `group` can share the same rate limiting counter. [...]
-| redis_host | string | False | | | The address of the Redis node. Required when `policy` is `redis`. [...]
-| redis_port | integer | False | 6379 | [1,...] | The port of the Redis node when `policy` is `redis`. [...]
-| redis_username | string | False | | | The username for Redis if Redis ACL is used. If you use the legacy authentication method `requirepass`, configure only the `redis_password`. Used when `policy` is `redis`. [...]
-| redis_password | string | False | | | The password of the Redis node when `policy` is `redis` or `redis-cluster`. [...]
-| redis_ssl | boolean | False | false | | If true, use SSL to connect to Redis cluster when `policy` is `redis`. [...]
-| redis_ssl_verify | boolean | False | false | | If true, verify the server SSL certificate when `policy` is `redis`. [...]
-| redis_database | integer | False | 0 | >= 0 | The database number in Redis when `policy` is `redis`. |
-| redis_timeout | integer | False | 1000 | [1,...] | The Redis timeout value in milliseconds when `policy` is `redis` or `redis-cluster`. [...]
-| redis_keepalive_timeout | integer | False | 10000 | ≥ 1000 | Keepalive timeout in milliseconds for redis when `policy` is `redis` or `redis-cluster`. |
-| redis_keepalive_pool | integer | False | 100 | ≥ 1 | Keepalive pool size for redis when `policy` is `redis` or `redis-cluster`. |
-| redis_cluster_nodes | array[string] | False | | | The list of Redis cluster nodes with at least one address. Required when `policy` is `redis-cluster`. [...]
-| redis_cluster_name | string | False | | | The name of the Redis cluster. Required when `policy` is `redis-cluster`. [...]
-| redis_cluster_ssl | boolean | False | false | | If true, use SSL to connect to Redis cluster when `policy` is `redis-cluster`. [...]
-| redis_cluster_ssl_verify | boolean | False | false | | If true, verify the server SSL certificate when `policy` is `redis-cluster`. [...]
+| count | integer or string | False | | > 0 | The maximum number of requests allowed within a given time interval. Required if `rules` is not configured. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr) from APISIX 3.16.0. |
+| time_window | integer or string | False | | > 0 | The time interval corresponding to the rate limiting `count` in seconds. Required if `rules` is not configured. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr) from APISIX 3.16.0. |
+| window_type | string | False | fixed | ["fixed","sliding"] | The window algorithm used by the plugin. |
+| rules | array[object] | False | | | A list of rate limiting rules. Each rule is an object containing `count`, `time_window`, and `key`. |
+| rules.count | integer or string | True | | > 0 | The maximum number of requests allowed within a given time interval. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr). |
+| rules.time_window | integer or string | True | | > 0 | The time interval corresponding to the rate limiting `count` in seconds. Supports [lua-resty-expr](https://github.com/api7/lua-resty-expr). |
+| rules.key | string | True | | | The key to count requests by. If the configured key does not exist, the rule will not be executed. The `key` is interpreted as a combination of variables, for example: `$http_custom_a $http_custom_b`. |
+| rules.header_prefix | string | False | | | Prefix for rate limit headers. If configured, the response will include `X-{header_prefix}-RateLimit-Limit`, `X-{header_prefix}-RateLimit-Remaining`, and `X-{header_prefix}-RateLimit-Reset` headers. If not configured, the index of the rule in the rules array is used as the prefix. |
+| key_type | string | False | var | ["var","var_combination","constant"] | The type of key. If the `key_type` is `var`, the `key` is interpreted as a variable. If the `key_type` is `var_combination`, the `key` is interpreted as a combination of variables. If the `key_type` is `constant`, the `key` is interpreted as a constant. |
+| key | string | False | remote_addr | | The key to count requests by. If the `key_type` is `var`, the `key` is interpreted as a variable. The variable does not need to be prefixed by a dollar sign (`$`). If the `key_type` is `var_combination`, the `key` is interpreted as a combination of variables. All variables should be prefixed by dollar signs (`$`). If the `key_type` is `constant`, the `k [...]
+| rejected_code | integer | False | 503 | [200,...,599] | The HTTP status code returned when a request is rejected for exceeding the threshold. |
+| rejected_msg | string | False | | non-empty | The response body returned when a request is rejected for exceeding the threshold. |
+| policy | string | False | local | ["local","redis","redis-cluster","redis-sentinel"] | The policy for the rate limiting counter. If it is `local`, the counter is stored in local memory. If it is `redis`, the counter is stored on a Redis instance. If it is `redis-cluster`, the counter is stored in a Redis cluster. If it is `redis-sentinel`, the counter is stored on the Redis master discovered through Sentinel. |
+| allow_degradation | boolean | False | false | | If true, allow APISIX to continue handling requests without the plugin when the plugin or its dependencies become unavailable. |
+| show_limit_quota_header | boolean | False | true | | If true, include `X-RateLimit-Limit` to show the total quota and `X-RateLimit-Remaining` to show the remaining quota in the response header. |
+| sync_interval | number | False | | -1 or >= 0.1 | The delayed synchronization interval in seconds for Redis-based policies. Set to `-1` to disable delayed synchronization explicitly. |
+| group | string | False | | non-empty | The `group` ID for the plugin, such that routes of the same `group` can share the same rate limiting counter. |
+| redis_host | string | False | | | The address of the Redis node. Required when `policy` is `redis`. |
+| redis_port | integer | False | 6379 | [1,...] | The port of the Redis node when `policy` is `redis`. |
+| redis_username | string | False | | | The username for Redis if Redis ACL is used. If you use the legacy authentication method `requirepass`, configure only the `redis_password`. Used when `policy` is `redis` or `redis-sentinel`. |
+| redis_password | string | False | | | The password of the Redis node when `policy` is `redis`, `redis-cluster`, or `redis-sentinel`. |
+| redis_ssl | boolean | False | false | | If true, use SSL to connect to Redis when `policy` is `redis`. |
+| redis_ssl_verify | boolean | False | false | | If true, verify the server SSL certificate when `policy` is `redis`. |
+| redis_database | integer | False | 0 | >= 0 | The database number in Redis when `policy` is `redis` or `redis-sentinel`. |
+| redis_timeout | integer | False | 1000 | [1,...] | The Redis timeout value in milliseconds when `policy` is `redis` or `redis-cluster`. |
+| redis_keepalive_timeout | integer | False | 10000 | ≥ 1000 | Keepalive timeout in milliseconds for Redis when `policy` is `redis` or `redis-cluster`. |
+| redis_keepalive_pool | integer | False | 100 | ≥ 1 | Keepalive pool size for Redis when `policy` is `redis` or `redis-cluster`. |
+| redis_cluster_nodes | array[string] | False | | | The list of Redis cluster nodes with at least one address. Required when `policy` is `redis-cluster`. |
+| redis_cluster_name | string | False | | | The name of the Redis cluster. Required when `policy` is `redis-cluster`. |
+| redis_cluster_ssl | boolean | False | false | | If true, use SSL to connect to Redis when `policy` is `redis-cluster`. |
+| redis_cluster_ssl_verify| boolean | False | false | | If true, verify the server SSL certificate when `policy` is `redis-cluster`. |
+| redis_sentinels | array[object] | False | | | The list of Sentinel nodes. Required when `policy` is `redis-sentinel`. Each item must contain `host` and `port`. |
+| redis_master_name | string | False | | | The Redis master name monitored by Sentinel. Required when `policy` is `redis-sentinel`. |
+| redis_role | string | False | master | ["master","slave"] | The Redis role selected through Sentinel. |
+| redis_connect_timeout | integer | False | 1000 | [1,...] | The Redis connection timeout in milliseconds when `policy` is `redis-sentinel`. |
+| redis_read_timeout | integer | False | 1000 | [1,...] | The Redis read timeout in milliseconds when `policy` is `redis-sentinel`. |
+| sentinel_username | string | False | | | The username for Redis Sentinel if Sentinel ACL is enabled. |
+| sentinel_password | string | False | | | The password for Redis Sentinel if Sentinel ACL is enabled. |
## Examples
@@ -1380,6 +1389,142 @@ You should see an `HTTP/1.1 200 OK` response with the corresponding response bod
Send the same request to a different APISIX instance within the same 30-second time interval, you should receive an `HTTP/1.1 429 Too Many Requests` response, verifying routes configured in different APISIX nodes share the same quota.
+### Share Quota Among APISIX Nodes with Redis Sentinel
+
+The following example demonstrates rate limiting across multiple APISIX nodes using Redis with [Sentinel](https://redis.io/docs/management/sentinel/) for high availability. Sentinel monitors the Redis master and promotes a replica if the master fails. APISIX discovers the current master through the configured Sentinel nodes, so the shared quota survives a failover without configuration changes.
+
+On each APISIX instance, create a Route with the following configurations. Adjust the Admin API address, Sentinel nodes, master name, and credentials accordingly:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "limit-count-route",
+ "uri": "/get",
+ "plugins": {
+ "limit-count": {
+ "count": 1,
+ "time_window": 30,
+ "rejected_code": 429,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ { "host": "192.168.xxx.xxx", "port": 26379 },
+ { "host": "192.168.xxx.xxx", "port": 26380 },
+ { "host": "192.168.xxx.xxx", "port": 26381 }
+ ],
+ "redis_master_name": "mymaster",
+ "redis_password": "p@ssw0rd",
+ "sentinel_password": "s3ntinelp@ss",
+ "redis_database": 1
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }'
+```
+
+If Sentinel ACL is not enabled, omit `sentinel_password`. For ACL-based authentication, use `redis_username`/`redis_password` for the Redis data nodes and `sentinel_username`/`sentinel_password` for the Sentinel nodes.
+
+Send a request to an APISIX instance:
+
+```shell
+curl -i "http://127.0.0.1:9080/get"
+```
+
+You should see an `HTTP/1.1 200 OK` response. Sending the same request again within the 30-second window returns `HTTP/1.1 429 Too Many Requests`. If the Redis master fails over, Sentinel promotes a replica and APISIX continues enforcing the shared quota against the new master.
+
+### Apply Sliding Window Rate Limiting
+
+By default, `limit-count` uses a fixed window, where the counter resets at the start of each `time_window`. Around a window boundary this can allow up to twice the configured rate, since a client may exhaust the quota at the end of one window and again at the start of the next.
+
+Set `window_type` to `sliding` to use a sliding window, which weights the previous window's count to smooth enforcement across boundaries. `window_type` works with all policies (`local`, `redis`, `redis-cluster`, and `redis-sentinel`).
+
+Create a Route with the following configurations:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "limit-count-route",
+ "uri": "/get",
+ "plugins": {
+ "limit-count": {
+ "count": 10,
+ "time_window": 60,
+ "rejected_code": 429,
+ "key": "remote_addr",
+ "window_type": "sliding"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }'
+```
+
+Send requests to the Route:
+
+```shell
+curl -i "http://127.0.0.1:9080/get"
+```
+
+The first 10 requests within 60 seconds return `HTTP/1.1 200 OK` and the 11th returns `HTTP/1.1 429 Too Many Requests`. Unlike a fixed window, the quota does not fully reset at the 60-second boundary; the window slides continuously, preventing a burst of up to twice the rate around the boundary.
+
+### Reduce Redis Round Trips with Delayed Synchronization
+
+For Redis-based policies (`redis`, `redis-cluster`, and `redis-sentinel`), APISIX synchronizes the counter with Redis on every request by default. On high-traffic routes, this adds a Redis round trip to each request.
+
+Set `sync_interval` (in seconds) to synchronize in batches instead: between intervals the counter is served from local memory and reconciled with Redis once per interval. This reduces Redis round trips and tail latency, at the cost of the global count lagging by up to one interval's local delta. Set `sync_interval` to `-1` (the default behavior) to synchronize on every request.
+
+Create a Route with the following configurations. Adjust the Redis connection settings accordingly:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "limit-count-route",
+ "uri": "/get",
+ "plugins": {
+ "limit-count": {
+ "count": 1000,
+ "time_window": 60,
+ "rejected_code": 429,
+ "key": "remote_addr",
+ "policy": "redis",
+ "redis_host": "192.168.xxx.xxx",
+ "redis_port": 6379,
+ "redis_password": "p@ssw0rd",
+ "redis_database": 1,
+ "sync_interval": 1
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }'
+```
+
+`sync_interval` must be at least `0.1` and smaller than `time_window`. Delayed synchronization uses the `plugin-limit-count-lock` shared dictionary, which is provisioned by default, so no additional configuration is required.
+
+Send requests to the Route:
+
+```shell
+curl -i "http://127.0.0.1:9080/get"
+```
+
+Requests are counted locally and reconciled with Redis every second. Once the quota of 1000 requests within 60 seconds is reached, further requests return `HTTP/1.1 429 Too Many Requests`.
+
### Rate Limit with Anonymous Consumer
The following example demonstrates how you can configure different rate limiting policies for regular and anonymous Consumers, where the anonymous Consumer does not need to authenticate and has less quota. While this example uses [`key-auth`](./key-auth.md) for authentication, the anonymous Consumer can also be configured with [`basic-auth`](./basic-auth.md), [`jwt-auth`](./jwt-auth.md), and [`hmac-auth`](./hmac-auth.md).
diff --git a/docs/zh/latest/plugins/limit-count.md b/docs/zh/latest/plugins/limit-count.md
index 669a15892..25320c179 100644
--- a/docs/zh/latest/plugins/limit-count.md
+++ b/docs/zh/latest/plugins/limit-count.md
@@ -5,7 +5,7 @@ keywords:
- API 网关
- Limit Count
- 速率限制
-description: limit-count 插件使用固定窗口算法,通过给定时间间隔内的请求数量来限制请求速率。超过配置配额的请求将被拒绝。
+description: limit-count 插件使用固定窗口或滑动窗口算法,通过给定时间间隔内的请求数量来限制请求速率。超过配置配额的请求将被拒绝。
---
<!--
@@ -36,7 +36,7 @@ import TabItem from '@theme/TabItem';
## 描述
-`limit-count` 插件使用固定窗口算法,通过给定时间间隔内的请求数量来限制请求速率。超过配置配额的请求将被拒绝。
+`limit-count` 插件使用固定窗口或滑动窗口算法,通过给定时间间隔内的请求数量来限制请求速率。超过配置配额的请求将被拒绝。
你可能会在响应中看到以下速率限制标头:
@@ -50,33 +50,42 @@ import TabItem from '@theme/TabItem';
| ------------------- | ------- | ---------- | ------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| count | integer 或 string | 否 | | > 0 | 给定时间间隔内允许的最大请求数。如果未配置 `rules`,则此项必填。支持从 APISIX 3.16.0 起使用 [lua-resty-expr](https://github.com/api7/lua-resty-expr)。 |
| time_window | integer 或 string | 否 | | > 0 | 速率限制 `count` 对应的时间间隔(以秒为单位)。如果未配置 `rules`,则此项必填。支持从 APISIX 3.16.0 起使用 [lua-resty-expr](https://github.com/api7/lua-resty-expr)。 |
+| window_type | string | 否 | fixed | ["fixed","sliding"] | 插件使用的窗口算法。 |
| rules | array[object] | 否 | | | 速率限制规则列表。每个规则是一个包含 `count`、`time_window` 和 `key` 的对象。如果配置了 `rules`,则顶层的 `count` 和 `time_window` 将被忽略。 |
| rules.count | integer 或 string | 是 | | > 0 | 给定时间间隔内允许的最大请求数。支持 [lua-resty-expr](https://github.com/api7/lua-resty-expr)。 |
| rules.time_window | integer 或 string | 是 | | > 0 | 速率限制 `count` 对应的时间间隔(以秒为单位)。支持 [lua-resty-expr](https://github.com/api7/lua-resty-expr)。 |
-| rules.key | string | 是 | | | 用于统计请求的键。如果配置的键不存在,则不会执行该规则。`key` 被解释为变量的组合,例如:`$http_custom_a $http_custom_b`。|
-| rules.header_prefix | string | 否 | | | 速率限制标头的前缀。如果已配置,响应将包含 `X-{header_prefix}-RateLimit-Limit`、`X-{header_prefix}-RateLimit-Remaining` 和 `X-{header_prefix}-RateLimit-Reset` 标头。如果未配置,则使用规则数组中规则的索引作为前缀。例如,第一个规则的标头将是 `X-1-RateLimit-Limit`、`X-1-RateLimit-Remaining` 和 `X-1-RateLimit-Reset`。|
-| key_type | string | 否 | var | ["var","var_combination","constant"] | key 的类型。如果`key_type` 为 `var`,则 `key` 将被解释为变量。如果 `key_type` 为 `var_combination`,则 `key` 将被解释为变量的组合。如果 `key_type` 为 `constant`,则 `key` 将被解释为常量。 |
-| key | string | 否 | remote_addr | | 用于计数请求的 key。如果 `key_type` 为 `var`,则 `key` 将被解释为变量。变量不需要以美元符号(`$`)为前缀。如果 `key_type` 为 `var_combination`,则 `key` 会被解释为变量的组合。所有变量都应该以美元符号 (`$`) 为前缀。例如,要配置 `key` 使用两个请求头 `custom-a` 和 `custom-b` 的组合,则 `key` 应该配置为 `$http_custom_a $http_custom_b`。如果 `key_type` 为 `constant`,则 `key` 会被解释为常量值。|
-| rejected_code | integer | 否 | 503 | [200,...,599] | 请求因超出阈值而被拒绝时返回的 HTTP 状态代码。|
-| rejection_msg | string | 否 | | 非空 | 请求因超出阈值而被拒绝时返回的响应主体。|
-| policy | string | 否 | local | ["local","redis","redis-cluster"] | 速率限制计数器的策略。如果是 `local`,则计数器存储在本地内存中。如果是 `redis`,则计数器存储在 Redis 实例上。如果是 `redis-cluster`,则计数器存储在 Redis 集群中。|
-| allow_degradation | boolean | 否 | false | | 如果为 true,则允许 APISIX 在插件或其依赖项不可用时继续处理没有插件的请求。|
-| show_limit_quota_header | boolean | 否 | true | | 如果为 true,则在响应标头中包含 `X-RateLimit-Limit` 以显示总配额和 `X-RateLimit-Remaining` 以显示剩余配额。|
+| rules.key | string | 是 | | | 用于统计请求的键。如果配置的键不存在,则不会执行该规则。`key` 被解释为变量的组合,例如:`$http_custom_a $http_custom_b`。 |
+| rules.header_prefix | string | 否 | | | 速率限制标头的前缀。如果已配置,响应将包含 `X-{header_prefix}-RateLimit-Limit`、`X-{header_prefix}-RateLimit-Remaining` 和 `X-{header_prefix}-RateLimit-Reset` 标头。如果未配置,则使用规则数组中规则的索引作为前缀。 |
+| key_type | string | 否 | var | ["var","var_combination","constant"] | key 的类型。如果 `key_type` 为 `var`,则 `key` 将被解释为变量。如果 `key_type` 为 `var_combination`,则 `key` 将被解释为变量的组合。如果 `key_type` 为 `constant`,则 `key` 将被解释为常量。 |
+| key | string | 否 | remote_addr | | 用于计数请求的 key。如果 `key_type` 为 `var`,则 `key` 将被解释为变量。变量不需要以美元符号(`$`)为前缀。如果 `key_type` 为 `var_combination`,则 `key` 会被解释为变量的组合。所有变量都应该以美元符号 (`$`) 为前缀。如果 `key_type` 为 `constant`,则 `key` 会被解释为常量值。 |
+| rejected_code | integer | 否 | 503 | [200,...,599] | 请求因超出阈值而被拒绝时返回的 HTTP 状态代码。 |
+| rejected_msg | string | 否 | | 非空 | 请求因超出阈值而被拒绝时返回的响应主体。 |
+| policy | string | 否 | local | ["local","redis","redis-cluster","redis-sentinel"] | 速率限制计数器的策略。如果是 `local`,则计数器存储在本地内存中。如果是 `redis`,则计数器存储在 Redis 实例上。如果是 `redis-cluster`,则计数器存储在 Redis 集群中。如果是 `redis-sentinel`,则计数器存储在通过 Sentinel 发现的 Redis 主节点上。 |
+| allow_degradation | boolean | 否 | false | | 如果为 true,则允许 APISIX 在插件或其依赖项不可用时继续处理没有插件的请求。 |
+| show_limit_quota_header | boolean | 否 | true | | 如果为 true,则在响应标头中包含 `X-RateLimit-Limit` 以显示总配额和 `X-RateLimit-Remaining` 以显示剩余配额。 |
+| sync_interval | number | 否 | | -1 或 >= 0.1 | Redis 类策略的延迟同步间隔,单位为秒。设置为 `-1` 可显式禁用延迟同步。 |
| group | string | 否 | | 非空 | 插件的 `group` ID,以便同一 `group` 的路由可以共享相同的速率限制计数器。 |
| redis_host | string | 否 | | | Redis 节点的地址。当 `policy` 为 `redis` 时必填。 |
| redis_port | integer | 否 | 6379 | [1,...] | 当 `policy` 为 `redis` 时,Redis 节点的端口。 |
-| redis_username | string | 否 | | | 如果使用 Redis ACL,则为 Redis 的用户名。如果使用旧式身份验证方法 `requirepass`,则仅配置 `redis_password`。当 `policy` 为 `redis` 时使用。 |
-| redis_password | string | 否 | | | 当 `policy` 为 `redis` 或 `redis-cluster` 时,Redis 节点的密码。 |
-| redis_ssl | boolean | 否 | false |如果为 true,则在 `policy` 为 `redis` 时使用 SSL 连接到 Redis 集群。|
-| redis_ssl_verify | boolean | 否 | false | | 如果为 true,则在 `policy` 为 `redis` 时验证服务器 SSL 证书。|
-| redis_database | integer | 否 | 0 | >= 0 | 当 `policy` 为 `redis` 时,Redis 中的数据库编号。|
+| redis_username | string | 否 | | | 如果使用 Redis ACL,则为 Redis 的用户名。如果使用旧式身份验证方法 `requirepass`,则仅配置 `redis_password`。当 `policy` 为 `redis` 或 `redis-sentinel` 时使用。 |
+| redis_password | string | 否 | | | 当 `policy` 为 `redis`、`redis-cluster` 或 `redis-sentinel` 时,Redis 节点的密码。 |
+| redis_ssl | boolean | 否 | false | | 如果为 true,则在 `policy` 为 `redis` 时使用 SSL 连接 Redis。 |
+| redis_ssl_verify | boolean | 否 | false | | 如果为 true,则在 `policy` 为 `redis` 时验证服务器 SSL 证书。 |
+| redis_database | integer | 否 | 0 | >= 0 | 当 `policy` 为 `redis` 或 `redis-sentinel` 时,Redis 中的数据库编号。 |
| redis_timeout | integer | 否 | 1000 | [1,...] | 当 `policy` 为 `redis` 或 `redis-cluster` 时,Redis 超时值(以毫秒为单位)。 |
-| redis_keepalive_timeout | integer | 否 | 10000 | ≥ 1000 | 当 `policy` 为 `redis` 或 `redis-cluster` 时,与 `redis` 或 `redis-cluster` 的空闲连接超时时间,单位为毫秒。|
-| redis_keepalive_pool | integer | 否 | 100 | ≥ 1 | 当 `policy` 为 `redis` 或 `redis-cluster` 时,与 `redis` 或 `redis-cluster` 的连接池最大连接数。|
-| redis_cluster_nodes | array[string] | 否 | | | 具有至少一个地址的 Redis 群集节点列表。当 policy 为 redis-cluster 时必填。 |
-| redis_cluster_name | string | 否 | | | Redis 集群的名称。当 `policy` 为 `redis-cluster` 时必须使用。|
-| redis_cluster_ssl | boolean | 否 | false | | 如果为 `true`,当 `policy` 为 `redis-cluster`时,使用 SSL 连接 Redis 集群。|
-| redis_cluster_ssl_verify | boolean | 否 | false | | 如果为 `true`,当 `policy` 为 `redis-cluster` 时,验证服务器 SSL 证书。 |
+| redis_keepalive_timeout | integer | 否 | 10000 | ≥ 1000 | 当 `policy` 为 `redis` 或 `redis-cluster` 时,与 Redis 的空闲连接超时时间,单位为毫秒。 |
+| redis_keepalive_pool | integer | 否 | 100 | ≥ 1 | 当 `policy` 为 `redis` 或 `redis-cluster` 时,与 Redis 的连接池最大连接数。 |
+| redis_cluster_nodes | array[string] | 否 | | | 具有至少一个地址的 Redis 集群节点列表。当 `policy` 为 `redis-cluster` 时必填。 |
+| redis_cluster_name | string | 否 | | | Redis 集群的名称。当 `policy` 为 `redis-cluster` 时必填。 |
+| redis_cluster_ssl | boolean | 否 | false | | 如果为 `true`,当 `policy` 为 `redis-cluster` 时,使用 SSL 连接 Redis 集群。 |
+| redis_cluster_ssl_verify | boolean | 否 | false | | 如果为 `true`,当 `policy` 为 `redis-cluster` 时,验证服务器 SSL 证书。 |
+| redis_sentinels | array[object] | 否 | | | Sentinel 节点列表。当 `policy` 为 `redis-sentinel` 时必填。每一项都必须包含 `host` 和 `port`。 |
+| redis_master_name | string | 否 | | | Sentinel 监控的 Redis 主节点名称。当 `policy` 为 `redis-sentinel` 时必填。 |
+| redis_role | string | 否 | master | ["master","slave"] | 通过 Sentinel 选择的 Redis 角色。 |
+| redis_connect_timeout | integer | 否 | 1000 | [1,...] | 当 `policy` 为 `redis-sentinel` 时,Redis 连接超时值(毫秒)。 |
+| redis_read_timeout | integer | 否 | 1000 | [1,...] | 当 `policy` 为 `redis-sentinel` 时,Redis 读取超时值(毫秒)。 |
+| sentinel_username | string | 否 | | | 如果启用了 Sentinel ACL,则为 Sentinel 用户名。 |
+| sentinel_password | string | 否 | | | 如果启用了 Sentinel ACL,则为 Sentinel 密码。 |
## 示例
@@ -1381,6 +1390,142 @@ curl -i "http://127.0.0.1:9080/get"
在相同的 30 秒时间间隔内向不同的 APISIX 实例发送相同的请求,你应该会收到一个 `HTTP/1.1 429 Too Many Requests` 响应,验证在不同 APISIX 节点中配置的路由是否共享相同的配额。
+### 使用 Redis Sentinel 在 APISIX 节点之间共享配额
+
+以下示例演示如何使用带 [Sentinel](https://redis.io/docs/management/sentinel/) 的 Redis 在多个 APISIX 节点之间进行速率限制,以实现高可用。Sentinel 监控 Redis 主节点,并在主节点故障时将一个副本提升为主节点。APISIX 通过配置的 Sentinel 节点发现当前主节点,因此共享配额可以在故障转移后无需修改配置继续生效。
+
+在每个 APISIX 实例上,使用以下配置创建一个路由。请相应调整 Admin API 地址、Sentinel 节点、主节点名称和凭据:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "limit-count-route",
+ "uri": "/get",
+ "plugins": {
+ "limit-count": {
+ "count": 1,
+ "time_window": 30,
+ "rejected_code": 429,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ { "host": "192.168.xxx.xxx", "port": 26379 },
+ { "host": "192.168.xxx.xxx", "port": 26380 },
+ { "host": "192.168.xxx.xxx", "port": 26381 }
+ ],
+ "redis_master_name": "mymaster",
+ "redis_password": "p@ssw0rd",
+ "sentinel_password": "s3ntinelp@ss",
+ "redis_database": 1
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }'
+```
+
+如果未启用 Sentinel ACL,可省略 `sentinel_password`。若使用基于 ACL 的认证,请为 Redis 数据节点配置 `redis_username`/`redis_password`,为 Sentinel 节点配置 `sentinel_username`/`sentinel_password`。
+
+向某个 APISIX 实例发送请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/get"
+```
+
+你应当收到 `HTTP/1.1 200 OK` 响应。在 30 秒窗口内再次发送相同请求将返回 `HTTP/1.1 429 Too Many Requests`。如果 Redis 主节点发生故障转移,Sentinel 会提升一个副本,APISIX 将继续针对新的主节点强制执行共享配额。
+
+### 应用滑动窗口速率限制
+
+默认情况下,`limit-count` 使用固定窗口,计数器在每个 `time_window` 开始时重置。在窗口边界附近,这可能允许达到配置速率的两倍,因为客户端可能在一个窗口结束时耗尽配额,又在下一个窗口开始时再次耗尽配额。
+
+将 `window_type` 设置为 `sliding` 可使用滑动窗口,它通过对上一个窗口的计数加权来平滑边界处的限流。`window_type` 适用于所有 policy(`local`、`redis`、`redis-cluster` 和 `redis-sentinel`)。
+
+使用以下配置创建一个路由:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "limit-count-route",
+ "uri": "/get",
+ "plugins": {
+ "limit-count": {
+ "count": 10,
+ "time_window": 60,
+ "rejected_code": 429,
+ "key": "remote_addr",
+ "window_type": "sliding"
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }'
+```
+
+向路由发送请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/get"
+```
+
+60 秒内的前 10 个请求返回 `HTTP/1.1 200 OK`,第 11 个返回 `HTTP/1.1 429 Too Many Requests`。与固定窗口不同,配额不会在 60 秒边界处完全重置;窗口持续滑动,从而避免边界附近出现两倍速率的突发。
+
+### 通过延迟同步减少 Redis 往返
+
+对于基于 Redis 的 policy(`redis`、`redis-cluster` 和 `redis-sentinel`),APISIX 默认在每个请求时与 Redis 同步计数器。在高流量路由上,这会为每个请求增加一次 Redis 往返。
+
+设置 `sync_interval`(单位:秒)可改为批量同步:在两次同步之间,计数由本地内存提供,并每隔一个间隔与 Redis 对账一次。这可以减少 Redis 往返和尾延迟,代价是全局计数最多滞后一个间隔的本地增量。将 `sync_interval` 设置为 `-1`(默认行为)则在每个请求时同步。
+
+使用以下配置创建一个路由,请相应调整 Redis 连接设置:
+
+```shell
+curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
+ -H "X-API-KEY: ${admin_key}" \
+ -d '{
+ "id": "limit-count-route",
+ "uri": "/get",
+ "plugins": {
+ "limit-count": {
+ "count": 1000,
+ "time_window": 60,
+ "rejected_code": 429,
+ "key": "remote_addr",
+ "policy": "redis",
+ "redis_host": "192.168.xxx.xxx",
+ "redis_port": 6379,
+ "redis_password": "p@ssw0rd",
+ "redis_database": 1,
+ "sync_interval": 1
+ }
+ },
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "httpbin.org:80": 1
+ }
+ }
+ }'
+```
+
+`sync_interval` 必须不小于 `0.1` 且小于 `time_window`。延迟同步使用 `plugin-limit-count-lock` 共享字典,该字典默认已配置,因此无需额外配置。
+
+向路由发送请求:
+
+```shell
+curl -i "http://127.0.0.1:9080/get"
+```
+
+请求在本地计数,并每秒与 Redis 对账一次。一旦达到 60 秒内 1000 个请求的配额,后续请求将返回 `HTTP/1.1 429 Too Many Requests`。
+
### 使用匿名消费者进行速率限制
以下示例演示了如何为常规和匿名消费者配置不同的速率限制策略,其中匿名消费者不需要进行身份验证并且配额较少。虽然此示例使用 [`key-auth`](./key-auth.md) 进行身份验证,但匿名消费者也可以使用 [`basic-auth`](./basic-auth.md)、[`jwt-auth`](./jwt-auth.md) 和 [`hmac-auth`](./hmac-auth.md) 进行配置。
diff --git a/t/APISIX.pm b/t/APISIX.pm
index cf342f82c..9c04f47ff 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -628,6 +628,7 @@ _EOC_
lua_shared_dict plugin-limit-req 10m;
lua_shared_dict plugin-limit-count 10m;
+ lua_shared_dict plugin-limit-count-lock 10m;
lua_shared_dict plugin-limit-count-reset-header 10m;
lua_shared_dict plugin-limit-conn 10m;
lua_shared_dict plugin-ai-rate-limiting 10m;
diff --git a/t/control/services.t b/t/control/services.t
index b0fa844be..59c3ed52e 100644
--- a/t/control/services.t
+++ b/t/control/services.t
@@ -157,7 +157,7 @@ services:
}
}
--- response_body eval
-qr/\{"id":"5","plugins":\{"limit-count":\{"_meta":\{\},"allow_degradation":false,"count":2,"key":"remote_addr","key_type":"var","policy":"local","rejected_code":503,"show_limit_quota_header":true,"time_window":60\}\},"upstream":\{"hash_on":"vars","nodes":\[\{"host":"127.0.0.1","port":1980,"weight":1\}\],"pass_host":"pass",.*"scheme":"http","type":"roundrobin"\}\}/
+qr/\{"id":"5","plugins":\{"limit-count":\{"_meta":\{\},"allow_degradation":false,"count":2,"key":"remote_addr","key_type":"var","policy":"local","rejected_code":503,"show_limit_quota_header":true,"sync_interval":-1,"time_window":60,"window_type":"fixed"\}\},"upstream":\{"hash_on":"vars","nodes":\[\{"host":"127.0.0.1","port":1980,"weight":1\}\],"pass_host":"pass",.*"scheme":"http","type":"roundrobin"\}\}/
diff --git a/t/plugin/limit-count-redis-delayed-sync.t b/t/plugin/limit-count-redis-delayed-sync.t
new file mode 100644
index 000000000..e8ca94f1f
--- /dev/null
+++ b/t/plugin/limit-count-redis-delayed-sync.t
@@ -0,0 +1,243 @@
+#
+# 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 {
+ $ENV{REDIS_NODE_0} = "127.0.0.1:5000";
+ $ENV{REDIS_NODE_1} = "127.0.0.1:5001";
+}
+
+use t::APISIX 'no_plan';
+
+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->grep_error_log) {
+ $block->set_value("no_error_log", "[error]\n[alert]");
+ }
+
+ my $extra_init_worker_by_lua = $block->extra_init_worker_by_lua // "";
+ $extra_init_worker_by_lua .= <<_EOC_;
+ require("lib.test_redis").flush_all()
+_EOC_
+
+ $block->set_value("extra_init_worker_by_lua", $extra_init_worker_by_lua);
+});
+
+run_tests;
+
+__DATA__
+
+=== TEST 1: setup routes
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local apis = {
+ {
+ uri = "/apisix/admin/upstreams/localhost_1980",
+ body = [[{
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }]],
+ },
+ {
+ uri = "/apisix/admin/routes/hello",
+ body = [[{
+ "uri": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 60,
+ "key_type": "var",
+ "key": "arg_key",
+ "policy": "redis",
+ "redis_host": "127.0.0.1",
+ "window_type": "sliding",
+ "sync_interval": 0.2
+ }
+ },
+ "upstream_id": "localhost_1980"
+ }]],
+ },
+ {
+ uri = "/apisix/admin/routes/hello1",
+ body = [[{
+ "uri": "/hello1",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 60,
+ "policy": "redis-cluster",
+ "redis_cluster_nodes": [
+ "$ENV://REDIS_NODE_0",
+ "$ENV://REDIS_NODE_1"
+ ],
+ "redis_cluster_name": "redis-cluster-1",
+ "window_type": "sliding",
+ "sync_interval": 0.2
+ }
+ },
+ "upstream_id": "localhost_1980"
+ }]],
+ },
+ {
+ uri = "/apisix/admin/routes/hello2",
+ body = [[{
+ "uri": "/echo",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 60,
+ "rejected_code": 503,
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "window_type": "sliding",
+ "sync_interval": 0.2,
+ "redis_database": 1
+ }
+ },
+ "upstream_id": "localhost_1980"
+ }]],
+ },
+ }
+ local code, body
+ for _, api in ipairs(apis) do
+ code, body = t(api.uri, ngx.HTTP_PUT, api.body)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: sanity - delayed sync to redis
+--- pipelined_requests eval
+["GET /hello", "GET /hello", "GET /hello"]
+--- error_code eval
+[200, 200, 503]
+--- wait: 1
+
+
+
+=== TEST 3: sanity - delayed sync to redis-cluster
+--- pipelined_requests eval
+["GET /hello1", "GET /hello1", "GET /hello1"]
+--- error_code eval
+[200, 200, 503]
+--- wait: 1
+
+
+
+=== TEST 4: sanity - delayed sync to redis-sentinel
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo"]
+--- error_code eval
+[200, 200, 503]
+--- wait: 1
+
+
+
+=== TEST 5: queue full - delayed-sync drops enqueue and warns, request still succeeds
+--- config
+ location /t {
+ content_by_lua_block {
+ local delayed_syncer = require("apisix.plugins.limit-count.delayed-syncer")
+ local shd = ngx.shared["plugin-limit-count"]
+
+ -- Build a mock limiter that returns a fixed remaining value without Redis I/O.
+ local mock_limiter = {}
+ function mock_limiter:incoming(key, cost)
+ return 0, 5, 60
+ end
+
+ local conf = { sync_interval = 1 }
+ local syncer = delayed_syncer.new("plugin-limit-count", 10, 60, conf, mock_limiter)
+
+ -- Pre-seed the remote quota so _delayed_sync skips the Redis sync branch.
+ local cjson = require("cjson.safe")
+ local quota_json = cjson.encode({
+ remaining = 5,
+ reset = 60,
+ sync_at = ngx.now(),
+ })
+ local queue_key = syncer:key_local_delta_keys("test-queue-full")
+ local quota_key = syncer:key_remote_quota("testkey")
+ shd:set(quota_key, quota_json, 120)
+
+ -- Fill the queue to the cap (10000 entries).
+ shd:delete(queue_key)
+ for i = 1, 10000 do
+ local _, err = shd:lpush(queue_key, "dummy-key-" .. i)
+ if err then
+ ngx.say("lpush failed at i=" .. i .. ": " .. err)
+ return
+ end
+ end
+
+ -- Call delayed_sync: queue is full, key should be dropped with a warn.
+ local remaining, reset, err = syncer:delayed_sync("testkey", 1, "test-queue-full")
+ if err then
+ ngx.say("unexpected error: " .. tostring(err))
+ return
+ end
+ if not remaining then
+ ngx.say("remaining is nil")
+ return
+ end
+
+ -- Verify queue length is still 10000 (key was not pushed).
+ local after_len = shd:llen(queue_key)
+ if after_len ~= 10000 then
+ ngx.say("unexpected queue length: " .. tostring(after_len))
+ return
+ end
+
+ -- Clean up.
+ shd:delete(queue_key)
+ shd:delete(quota_key)
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+--- grep_error_log eval
+qr/delayed-sync queue saturated, skipping enqueue/
+--- grep_error_log_out eval
+qr/delayed-sync queue saturated, skipping enqueue/
+--- no_error_log
+[error]
diff --git a/t/plugin/limit-count-redis-delayed-sync2.t b/t/plugin/limit-count-redis-delayed-sync2.t
new file mode 100644
index 000000000..4777f33c9
--- /dev/null
+++ b/t/plugin/limit-count-redis-delayed-sync2.t
@@ -0,0 +1,273 @@
+#
+# 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 {
+ $ENV{REDIS_NODE_0} = "127.0.0.1:5000";
+ $ENV{REDIS_NODE_1} = "127.0.0.1:5001";
+}
+
+use t::APISIX 'no_plan';
+
+master_on();
+workers(2);
+no_shuffle();
+check_accum_error_log();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set route with redis (fixed window, delayed sync)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/echo',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/echo",
+ "plugins": {
+ "limit-count": {
+ "count": 7,
+ "time_window": 10,
+ "key_type": "var",
+ "key": "http_host",
+ "show_limit_quota_header": true,
+ "policy": "redis",
+ "redis_host": "127.0.0.1",
+ "redis_port": 6379,
+ "sync_interval": 0.1,
+ "rejected_code": 503
+ }
+ },
+ "host": "example-1.com",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: delayed sync to redis - remaining decreases locally
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo", "GET /echo"]
+--- more_headers
+Host: example-1.com
+--- error_code eval
+[200, 200, 200, 200]
+--- response_headers eval
+[
+ "X-RateLimit-Remaining: 6",
+ "X-RateLimit-Remaining: 5",
+ "X-RateLimit-Remaining: 4",
+ "X-RateLimit-Remaining: 3",
+]
+
+
+
+=== TEST 3: delayed sync to redis - reach the limit
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo", "GET /echo"]
+--- more_headers
+Host: example-1.com
+--- error_code eval
+[200, 200, 200, 503]
+--- response_headers eval
+[
+ "X-RateLimit-Remaining: 2",
+ "X-RateLimit-Remaining: 1",
+ "X-RateLimit-Remaining: 0",
+ "X-RateLimit-Remaining: 0",
+]
+
+
+
+=== TEST 4: set route with redis-sentinel (fixed window, delayed sync)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/echo',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/echo",
+ "plugins": {
+ "limit-count": {
+ "count": 7,
+ "time_window": 10,
+ "key_type": "var",
+ "key": "http_host",
+ "rejected_code": 503,
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "sync_interval": 0.1,
+ "redis_database": 1
+ }
+ },
+ "host": "example-4.com",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 5: delayed sync to redis-sentinel - remaining decreases locally
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo", "GET /echo"]
+--- more_headers
+Host: example-4.com
+--- error_code eval
+[200, 200, 200, 200]
+--- response_headers eval
+[
+ "X-RateLimit-Remaining: 6",
+ "X-RateLimit-Remaining: 5",
+ "X-RateLimit-Remaining: 4",
+ "X-RateLimit-Remaining: 3",
+]
+
+
+
+=== TEST 6: delayed sync to redis-sentinel - reach the limit
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo", "GET /echo"]
+--- more_headers
+Host: example-4.com
+--- error_code eval
+[200, 200, 200, 503]
+--- response_headers eval
+[
+ "X-RateLimit-Remaining: 2",
+ "X-RateLimit-Remaining: 1",
+ "X-RateLimit-Remaining: 0",
+ "X-RateLimit-Remaining: 0",
+]
+
+
+
+=== TEST 7: set route with redis-cluster (fixed window, delayed sync)
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin")
+ local code, body = t.test('/apisix/admin/routes/echo',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/echo",
+ "plugins": {
+ "limit-count": {
+ "count": 7,
+ "time_window": 10,
+ "key_type": "var",
+ "key": "http_host",
+ "rejected_code": 503,
+ "policy": "redis-cluster",
+ "redis_cluster_nodes": [
+ "$ENV://REDIS_NODE_0",
+ "$ENV://REDIS_NODE_1"
+ ],
+ "redis_cluster_name": "redis-cluster-1",
+ "sync_interval": 0.1
+ }
+ },
+ "host": "example-7.com",
+ "upstream": {
+ "type": "roundrobin",
+ "nodes": {
+ "127.0.0.1:1980": 1
+ }
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 8: delayed sync to redis-cluster - remaining decreases locally
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo", "GET /echo"]
+--- more_headers
+Host: example-7.com
+--- error_code eval
+[200, 200, 200, 200]
+--- response_headers eval
+[
+ "X-RateLimit-Remaining: 6",
+ "X-RateLimit-Remaining: 5",
+ "X-RateLimit-Remaining: 4",
+ "X-RateLimit-Remaining: 3",
+]
+
+
+
+=== TEST 9: delayed sync to redis-cluster - reach the limit
+--- pipelined_requests eval
+["GET /echo", "GET /echo", "GET /echo", "GET /echo"]
+--- more_headers
+Host: example-7.com
+--- error_code eval
+[200, 200, 200, 503]
+--- response_headers eval
+[
+ "X-RateLimit-Remaining: 2",
+ "X-RateLimit-Remaining: 1",
+ "X-RateLimit-Remaining: 0",
+ "X-RateLimit-Remaining: 0",
+]
diff --git a/t/plugin/limit-count-redis-sentinel.t b/t/plugin/limit-count-redis-sentinel.t
new file mode 100644
index 000000000..5004e514f
--- /dev/null
+++ b/t/plugin/limit-count-redis-sentinel.t
@@ -0,0 +1,509 @@
+#
+# 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();
+
+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: set route without redis master name
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_role": "master"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- error_code: 400
+
+
+
+=== TEST 2: set route without redis sentinels
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_role": "master",
+ "redis_database": 1
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- error_code: 400
+
+
+
+=== TEST 3: set route
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_username": "master",
+ "redis_password": "master-password",
+ "redis_master_name": "mymaster",
+ "redis_role": "master"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 4: up the limit
+--- pipelined_requests eval
+["GET /hello", "GET /hello", "GET /hello", "GET /hello"]
+--- error_code eval
+[200, 200, 503, 503]
+
+
+
+=== TEST 5: set route with different limit count
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "redis_database": 1
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ local code, body = t('/apisix/admin/routes/2',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/hello1",
+ "plugins": {
+ "limit-count": {
+ "count": 1,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "redis_database": 1
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 6: up the limit
+--- pipelined_requests eval
+["GET /hello","GET /hello", "GET /hello", "GET /hello1", "GET /hello1"]
+--- error_code eval
+[200, 200, 503, 200, 503]
+
+
+
+=== TEST 7: set route with authenticated redis sentinel without password
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26380}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "redis_database": 1
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 8: up the limit
+--- request
+GET /hello
+--- error_code: 500
+--- error_log
+redis connection failed, err: NOAUTH Authentication required
+
+
+
+=== TEST 9: set route with authenticated redis sentinel with password
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26380}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "redis_database": 1,
+ "sentinel_username": "admin",
+ "sentinel_password": "admin-password"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ local code, body = t('/apisix/admin/routes/2',
+ ngx.HTTP_PUT,
+ [[{
+ "uri": "/hello1",
+ "plugins": {
+ "limit-count": {
+ "count": 1,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26380}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "redis_database": 1,
+ "sentinel_username": "admin",
+ "sentinel_password": "admin-password"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 10: up the limit
+--- pipelined_requests eval
+["GET /hello","GET /hello", "GET /hello", "GET /hello1", "GET /hello1"]
+--- error_code eval
+[200, 200, 503, 200, 503]
+
+
+
+=== TEST 11: create a limit-count with broken redis sentinels
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 503},
+ {"host": "127.0.0.2", "port": 503}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 12: send test request
+--- request
+GET /hello
+--- error_code: 500
+--- error_log
+failed to limit count: redis connection failed, err: no hosts available, previous_errors: connection refused, connection refused
+
+
+
+=== TEST 13: set route with invalid username:password
+--- 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": "/hello",
+ "plugins": {
+ "limit-count": {
+ "count": 2,
+ "time_window": 2,
+ "rejected_code": 503,
+ "key": "remote_addr",
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_username": "invalid",
+ "redis_password": "invalid",
+ "redis_master_name": "mymaster",
+ "redis_role": "master"
+ }
+ },
+ "upstream": {
+ "nodes": {
+ "127.0.0.1:1980": 1
+ },
+ "type": "roundrobin"
+ }
+ }]]
+ )
+
+ if code >= 300 then
+ ngx.status = code
+ end
+ ngx.say(body)
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 14: redis authentication failure
+--- request
+GET /hello
+--- error_code: 500
+--- error_log
+invalid username-password pair
diff --git a/t/plugin/limit-count-rules.t b/t/plugin/limit-count-rules.t
index a358ac6aa..a2bee51f0 100644
--- a/t/plugin/limit-count-rules.t
+++ b/t/plugin/limit-count-rules.t
@@ -43,7 +43,7 @@ __DATA__
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -81,7 +81,7 @@ __DATA__
}
--- 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"}
+{"error_msg":"failed to check the configuration of plugin limit-count err: count/time_window and rules cannot be specified at the same time"}
@@ -90,7 +90,7 @@ __DATA__
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -138,7 +138,7 @@ __DATA__
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -221,7 +221,7 @@ apisix-count: 3
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -288,7 +288,7 @@ count: 1
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -386,7 +386,7 @@ passed
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -464,7 +464,7 @@ X-Bar-RateLimit-Reset: 60
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -540,7 +540,7 @@ X-2-RateLimit-Reset: 60
location /t {
content_by_lua_block {
local t = require("lib.test_admin").test
- local code, body = t('/apisix/admin/routes/1',
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
ngx.HTTP_PUT,
[[{
"methods": ["GET"],
@@ -583,7 +583,7 @@ GET /hello
--- more_headers
project: kubernetes
--- error_log eval
-qr/limit key: \/apisix\/routes\/1:[^:]+:kubernetes/
+qr/limit key: \/apisix\/routes\/limit-count-rules-1:[^:]+:kubernetes/
@@ -591,4 +591,61 @@ qr/limit key: \/apisix\/routes\/1:[^:]+:kubernetes/
--- request
GET /hello
--- error_log eval
-qr/limit key: \/apisix\/routes\/1:[^:]+:apisix/
+qr/limit key: \/apisix\/routes\/limit-count-rules-1:[^:]+:apisix/
+
+
+
+=== TEST 21: setup route with rules using limit-count plugin directly
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local code, body = t('/apisix/admin/routes/limit-count-rules-1',
+ ngx.HTTP_PUT,
+ [[{
+ "methods": ["GET"],
+ "plugins": {
+ "limit-count": {
+ "rejected_code": 503,
+ "rules": [
+ {
+ "key": "${http_x_user}",
+ "count": 2,
+ "time_window": 60
+ },
+ {
+ "key": "${http_x_project}",
+ "count": 5,
+ "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 22: match user rule via limit-count plugin directly
+--- request
+GET /hello
+--- more_headers
+x-user: testuser
+x-project: myproject
+--- error_code: 200
diff --git a/t/plugin/limit-count-sliding.t b/t/plugin/limit-count-sliding.t
new file mode 100644
index 000000000..2286eb1b0
--- /dev/null
+++ b/t/plugin/limit-count-sliding.t
@@ -0,0 +1,294 @@
+#
+# 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();
+
+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: set route(id: 1)
+--- 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": "remote_addr",
+ "window_type": "sliding"
+ }
+ },
+ "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
+--- wait: 0.2
+
+
+
+=== TEST 2: up the limit
+--- pipelined_requests eval
+["GET /hello", "GET /hello", "GET /hello"]
+--- error_code eval
+[200, 200, 503]
+
+
+
+=== TEST 3: headers rounded off
+# in this test we extract the rate limit header values then extract the decimal part
+# to check if the decimal part is longer than 2
+--- 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"}
+ local httpc = http.new()
+
+ local headers_to_check = {"X-RateLimit-Remaining", "X-RateLimit-Reset"}
+ for i = 1, 5, 1 do
+ local res = httpc:request_uri(uri, opt)
+ local headers = res.headers
+
+ local m, err = ngx.re.match(headers["X-RateLimit-Remaining"], "\\d(.*)")
+ if not m then
+ ngx.status = 500
+ ngx.say("error: ", err)
+ return
+ end
+
+ local value = m[1]
+ if #value > 0 then
+ ngx.status = 500
+ ngx.say("remaining should be an integer but found float")
+ return
+ end
+
+ local m, err = ngx.re.match(headers["X-RateLimit-Reset"], "\\d\\.?(.*)")
+ if not m then
+ ngx.status = 500
+ ngx.say("error: ", err)
+ return
+ end
+
+ local value = m[1]
+ if #value > 2 then
+ ngx.status = 500
+ ngx.say("x-ratelimit-recet decimal value has more than 2 digits")
+ return
+ end
+
+ if res.status == 200 then
+ ngx.sleep(1)
+ else
+ ngx.sleep(1.5)
+ end
+ end
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- timeout: 10
+--- error_code: 200
+--- response_body
+passed
+
+
+
+=== TEST 4: set route(id: 1) with redis-sentinel
+--- 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,
+ "policy": "redis-sentinel",
+ "redis_sentinels": [
+ {"host": "127.0.0.1", "port": 26379}
+ ],
+ "redis_master_name": "mymaster",
+ "redis_role": "master",
+ "window_type": "sliding",
+ "sync_interval": 0.2,
+ "redis_database": 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
+--- wait: 0.2
+
+
+
+=== TEST 5: up the limit
+--- pipelined_requests eval
+["GET /hello", "GET /hello", "GET /hello"]
+--- error_code eval
+[200, 200, 503]
+
+
+
+=== TEST 6: sliding-window commit() flushes an already-permitted delta even over the limit
+# regression: delayed sync must not drop a locally-permitted delta when the
+# remote counter is already at/over the limit. incoming() rejects before it
+# increments; commit() must still increment so the global count is not lost.
+--- config
+ location /t {
+ content_by_lua_block {
+ local sliding_window =
+ require("apisix.plugins.limit-count.sliding-window.sliding-window")
+ local redis_store =
+ require("apisix.plugins.limit-count.sliding-window.store.redis")
+ local redis_cli = require("apisix.plugins.limit-count.util").redis_cli
+ local conf = {
+ redis_host = "127.0.0.1",
+ redis_port = 6379,
+ redis_database = 1,
+ }
+ local limit, window = 2, 5
+ local lim, err = sliding_window.new_with_red_cli_factory(
+ redis_store, limit, window, redis_cli, conf)
+ if not lim then
+ ngx.say("failed to create limiter: ", err)
+ return
+ end
+
+ local key = "ut-commit-" .. ngx.now()
+ -- consume the whole quota
+ lim:incoming(key, 2)
+ -- over the limit now: incoming() must reject and NOT increment
+ local _, rejected = lim:incoming(key, 3)
+ ngx.say("incoming over limit: ", rejected)
+ -- commit() must still increment despite being over the limit
+ local delay, remaining = lim:commit(key, 3)
+ ngx.say("commit delay: ", tostring(delay), ", remaining: ", remaining)
+ }
+ }
+--- response_body
+incoming over limit: rejected
+commit delay: 0, remaining: -3
+
+
+
+=== TEST 7: sliding-window Redis counters are isolated by plugin_name
+# regression: two plugins reusing this module on the same resource with
+# identical config produce the same gen_limit_key. Without a plugin_name prefix
+# on the Redis counter key they would share a counter and double-count each
+# other. limit-count and graphql-limit-count are the real-world pair.
+--- config
+ location /t {
+ content_by_lua_block {
+ local sliding_window =
+ require("apisix.plugins.limit-count.sliding-window.sliding-window")
+ local redis_store =
+ require("apisix.plugins.limit-count.sliding-window.store.redis")
+ local redis_cli = require("apisix.plugins.limit-count.util").redis_cli
+ local conf = {
+ redis_host = "127.0.0.1",
+ redis_port = 6379,
+ redis_database = 1,
+ }
+ local limit, window = 2, 5
+
+ local function new_lim(plugin_name)
+ local lim = sliding_window.new_with_red_cli_factory(
+ redis_store, limit, window, redis_cli, conf)
+ -- production wires plugin_name onto the instance after construction
+ lim.plugin_name = plugin_name
+ return lim
+ end
+
+ local lim_a = new_lim("plugin-limit-count")
+ local lim_b = new_lim("plugin-graphql-limit-count")
+
+ -- same resolved key for both plugins
+ local key = "ut-isolate-" .. ngx.now()
+
+ -- exhaust plugin A's quota
+ lim_a:incoming(key, 2)
+ local _, a_rejected = lim_a:incoming(key, 1)
+ ngx.say("a over limit: ", a_rejected)
+
+ -- plugin B must still have its own full quota
+ local b_delay, b_remaining = lim_b:incoming(key, 1)
+ ngx.say("b independent: ", tostring(b_delay), ", remaining: ", b_remaining)
+ }
+ }
+--- response_body
+a over limit: rejected
+b independent: 0, remaining: 1
diff --git a/t/plugin/limit-count-variable.t b/t/plugin/limit-count-variable.t
index c26a858fa..a4359c63a 100644
--- a/t/plugin/limit-count-variable.t
+++ b/t/plugin/limit-count-variable.t
@@ -141,3 +141,146 @@ GET /t
--- timeout: 10
--- response_body
passed
+
+
+
+=== TEST 5: use variable in count -- dynamic updates to `count` should bring immediate effect to X-RateLimit-Remaining
+--- 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": 10,
+ "rejected_code": 503,
+ "key_type": "var",
+ "key": "remote_addr",
+ "window_type": "fixed"
+ }
+ },
+ "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: request with varying count 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 httpc = http.new()
+
+ -- request with count=3
+ local opt_3 = {method = "GET", headers = { ["count"] = 3 }}
+ local res = httpc:request_uri(uri, opt_3)
+
+ if res.status ~= 200 then
+ ngx.say("first request should return 200, but got " .. res.status)
+ return
+ end
+ if res.headers["x-ratelimit-remaining"] ~= "2" then
+ ngx.say("x-ratelimit-remaining should be 2, but got " .. core.json.encode(res.headers))
+ return
+ end
+
+ -- request with count=2
+ local opt_2 = {method = "GET", headers = { ["count"] = 2 }}
+ local res = httpc:request_uri(uri, opt_2)
+ if res.headers["x-ratelimit-remaining"] ~= "0" then
+ ngx.say("x-ratelimit-remaining should be 0, but got " .. core.json.encode(res.headers))
+ return
+ end
+
+ -- request with count=5
+ local opt_2 = {method = "GET", headers = { ["count"] = 5 }}
+ local res = httpc:request_uri(uri, opt_2)
+ if res.headers["x-ratelimit-remaining"] ~= "2" then
+ ngx.say("x-ratelimit-remaining should be 2, but got " .. core.json.encode(res.headers))
+ return
+ end
+
+ ngx.say("passed")
+ }
+ }
+--- request
+GET /t
+--- timeout: 10
+--- response_body
+passed
+
+
+
+=== TEST 7: use variable in count
+--- 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": 10,
+ "key_type": "var",
+ "key": "http_host"
+ }
+ },
+ "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: check access log contains rate_limiting_info
+--- request
+GET /hello
+--- more_headers
+host: test.com
+--- extra_yaml_config
+nginx_config:
+ http:
+ access_log_format: main '$rate_limiting_info';
+--- error_code: 200
+--- access_log eval
+qr/\{\\x22rate_limiting_key\\x22:\\x22\/apisix\/routes\/1:\d+:test\.com\\x22,\\x22rate_limiting_limit\\x22:2,\\x22rate_limiting_remaining\\x22:1,\\x22rate_limiting_reset\\x22:10}/