Skip to content
15 changes: 13 additions & 2 deletions apisix/plugins/limit-count/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ local schema = {
properties = {
count = {type = "integer", exclusiveMinimum = 0},
time_window = {type = "integer", exclusiveMinimum = 0},
window_type = {
type = "string",
enum = {"fixed", "sliding"},
default = "fixed",
},
group = {type = "string"},
key = {type = "string", default = "remote_addr"},
key_type = {type = "string",
Expand Down Expand Up @@ -137,6 +142,12 @@ function _M.check_schema(conf, schema_type)
return false, err
end

if (not conf.policy or conf.policy == "local")
and conf.window_type and conf.window_type ~= "fixed"
then
return false, "window_type \"sliding\" is only supported when policy is \"redis\" or \"redis-cluster\""
end

if conf.group then
-- means that call by some plugin not support
if conf._vid then
Expand Down Expand Up @@ -184,12 +195,12 @@ local function create_limit_obj(conf, plugin_name)

if conf.policy == "redis" then
return limit_redis_new("plugin-" .. plugin_name,
conf.count, conf.time_window, conf)
conf.count, conf.time_window, conf.window_type, conf)
end

if conf.policy == "redis-cluster" then
return limit_redis_cluster_new("plugin-" .. plugin_name, conf.count,
conf.time_window, conf)
conf.time_window, conf.window_type, conf)
end

return nil
Expand Down
75 changes: 69 additions & 6 deletions apisix/plugins/limit-count/limit-count-redis-cluster.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,17 @@ local redis_cluster = require("apisix.utils.rediscluster")
local core = require("apisix.core")
local setmetatable = setmetatable
local tostring = tostring
local ngx_var = ngx.var

local _M = {}
local _M = {version = 0.2}


local mt = {
__index = _M
}


local script = core.string.compress_script([=[
local script_fixed = 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
Expand All @@ -39,7 +40,56 @@ local script = core.string.compress_script([=[
]=])


function _M.new(plugin_name, limit, window, conf)
local script_sliding = core.string.compress_script([=[
assert(tonumber(ARGV[3]) >= 1, "cost must be at least 1")

local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local req_id = ARGV[5]

local window_start = now - window

redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)

local current = redis.call('ZCARD', KEYS[1])

if current + cost > limit then
local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local reset = 0
if #earliest == 2 then
reset = earliest[2] + window - now
if reset < 0 then
reset = 0
end
end
return {-1, reset}
end

for i = 1, cost do
local member = req_id .. ':' .. i
redis.call('ZADD', KEYS[1], now, member)
end
Comment on lines +70 to +73
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current implementation, each request needs to record an entry in the Redis set. This puts too much pressure on Redis and is probably not a good solution.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nic-6443

Thanks for raising this concern!

In this sliding-window implementation, we intentionally trade some Redis work per request for exact “N requests per rolling window” semantics. However, the memory and per-key load are bounded:

  • on every request we first call ZREMRANGEBYSCORE to drop all events outside of the current window and then enforce current + cost <= limit before inserting new members,
  • rejected requests do not insert new entries,
  • so for each key the ZSET cardinality is always <= limit, and Redis memory for this key is bounded by that limit.

Because of this, even though we do one ZADD per allowed request, we don’t end up with unbounded growth like in some TTL-only designs – the sliding window state per key stays O(limit). Also, window_type = "sliding" is an opt-in mode and the docs explicitly call out that it is more expensive than the default fixed window, so it’s intended for use cases that really need accurate rolling windows and can accept the extra Redis cost.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest you use the approximate sliding window mentioned in this blog https://blog.cloudflare.com/counting-things-a-lot-of-different-things/ to implement this function. You can refer to the code here: https://github.com/ElvinEfendi/lua-resty-global-throttle/blob/main/lib/resty/global_throttle/sliding_window.lua


redis.call('PEXPIRE', KEYS[1], window)

local remaining = limit - (current + cost)

local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local reset = 0
if #earliest == 2 then
reset = earliest[2] + window - now
if reset < 0 then
reset = 0
end
end

return {remaining, reset}
]=])


function _M.new(plugin_name, limit, window, window_type, conf)
local red_cli, err = redis_cluster.new(conf, "plugin-limit-count-redis-cluster-slot-lock")
if not red_cli then
return nil, err
Expand All @@ -48,6 +98,7 @@ function _M.new(plugin_name, limit, window, conf)
local self = {
limit = limit,
window = window,
window_type = window_type or "fixed",
conf = conf,
plugin_name = plugin_name,
red_cli = red_cli,
Expand All @@ -59,12 +110,24 @@ 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:eval(script, 1, key, limit, window, cost or 1)
local limit = self.limit
local c = cost or 1
local res

if self.window_type == "sliding" then
local now = ngx.now() * 1000
local window = self.window * 1000
local req_id = ngx_var.request_id

res, err = red:eval(script_sliding, 1, key, now, window, limit, c, req_id)
else
local window = self.window

res, err = red:eval(script_fixed, 1, key, limit, window, c)
end

if err then
return nil, err, ttl
Expand Down
81 changes: 72 additions & 9 deletions apisix/plugins/limit-count/limit-count-redis.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,18 @@ local core = require("apisix.core")
local assert = assert
local setmetatable = setmetatable
local tostring = tostring
local ngx_var = ngx.var


local _M = {version = 0.3}
local _M = {version = 0.4}


local mt = {
__index = _M
}


local script = core.string.compress_script([=[
local script_fixed = 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
Expand All @@ -40,12 +41,63 @@ local script = core.string.compress_script([=[
]=])


function _M.new(plugin_name, limit, window, conf)
local script_sliding = core.string.compress_script([=[
assert(tonumber(ARGV[3]) >= 1, "cost must be at least 1")

local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local req_id = ARGV[5]

local window_start = now - window

-- remove events outside of the window
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start)

local current = redis.call('ZCARD', KEYS[1])

if current + cost > limit then
local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local reset = 0
if #earliest == 2 then
reset = earliest[2] + window - now
if reset < 0 then
reset = 0
end
end
return {-1, reset}
end

for i = 1, cost do
local member = req_id .. ':' .. i
redis.call('ZADD', KEYS[1], now, member)
end

redis.call('PEXPIRE', KEYS[1], window)

local remaining = limit - (current + cost)

local earliest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
local reset = 0
if #earliest == 2 then
reset = earliest[2] + window - now
if reset < 0 then
reset = 0
end
end

return {remaining, reset}
]=])


function _M.new(plugin_name, limit, window, window_type, conf)
assert(limit > 0 and window > 0)

local self = {
limit = limit,
window = window,
window_type = window_type or "fixed",
conf = conf,
plugin_name = plugin_name,
}
Expand All @@ -59,13 +111,23 @@ function _M.incoming(self, key, cost)
return red, err, 0
end

local limit = self.limit
local window = self.window
local res
key = self.plugin_name .. tostring(key)

local ttl = 0
res, err = red:eval(script, 1, key, limit, window, cost or 1)
local limit = self.limit
local c = cost or 1
local res

if self.window_type == "sliding" then
local now = ngx.now() * 1000
local window = self.window * 1000
local req_id = ngx_var.request_id

res, err = red:eval(script_sliding, 1, key, now, window, limit, c, req_id)
else
local window = self.window
res, err = red:eval(script_fixed, 1, key, limit, window, c)
end

if err then
return nil, err, ttl
Expand All @@ -74,14 +136,15 @@ function _M.incoming(self, key, cost)
local remaining = res[1]
ttl = res[2]

local ok, err = red:set_keepalive(10000, 100)
local ok, err2 = red:set_keepalive(10000, 100)
if not ok then
return nil, err, ttl
return nil, err2, ttl
end

if remaining < 0 then
return nil, "rejected", ttl
end

return 0, remaining, ttl
end

Expand Down
37 changes: 35 additions & 2 deletions docs/en/latest/plugins/limit-count.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 limits the rate of requests by the number of requests within a given time interval. It supports both fixed window and sliding window behaviors. Requests exceeding the configured quota will be rejected.
---

<!--
Expand Down Expand Up @@ -32,7 +32,12 @@ description: The limit-count plugin uses a fixed window algorithm to limit the r

## 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 limits the rate of requests by the number of requests within a given time interval. It supports both **fixed window** and **sliding window** behaviors.

- When `window_type` is `fixed` (default), the plugin uses a fixed window algorithm.
- When `window_type` is `sliding`, and the `policy` is `redis` or `redis-cluster`, the plugin enforces an exact **N requests per rolling time window** using a sliding window algorithm.

Requests exceeding the configured quota will be rejected.

You may see the following rate limiting headers in the response:

Expand All @@ -46,6 +51,7 @@ You may see the following rate limiting headers in the response:
| ----------------------- | ------- | ----------------------------------------- | ------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| count | integer | True | | > 0 | The maximum number of requests allowed within a given time interval. |
| time_window | integer | True | | > 0 | The time interval corresponding to the rate limiting `count` in seconds. |
| window_type | string | False | fixed | ["fixed","sliding"] | The window behavior type. `fixed` uses a fixed window algorithm. `sliding` uses a sliding window algorithm to enforce an exact number of requests per rolling window. `sliding` is only supported when `policy` is `redis` or `redis-cluster`. |
| 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 a combination of two request headers `custom-a` and `custom-b`, the `key` should be configured as `$http_custom_a $http_custom_b`. If the `key_type` is `constant`, the `key` is interpreted as a constant value. |
| rejected_code | integer | False | 503 | [200,...,599] | The HTTP status code returned when a request is rejected for exceeding the threshold. |
Expand Down Expand Up @@ -401,6 +407,33 @@ 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.

### Performance considerations (sliding window)

When `window_type` is set to `sliding` and the `policy` is `redis` or `redis-cluster`, this Plugin uses a Redis ZSET to store timestamps for recent requests (a sliding log).

Roughly, the memory usage for sliding window per Redis instance can be approximated as:

\[
\text{Memory} \approx K \times C \times B
\]

Where:

- \(K\): number of active keys that receive traffic within a `time_window`
- \(C\): `count` (maximum requests allowed per key within the window)
- \(B\): bytes per ZSET entry (timestamp + member + metadata). A conservative estimate is around 100 bytes.

For example:

- \(K = 10{,}000\), \(C = 50\), \(B \approx 100\) → about 50 MB
- \(K = 100{,}000\), \(C = 100\), \(B \approx 100\) → about 1 GB

In practice, you should:

- Monitor Redis memory and CPU when enabling sliding windows.
- Prefer relatively small `count` values (tens to low hundreds) for keys with high QPS.
- Consider using `window_type = "fixed"` (or `limit-req`) for very high throughput keys with large `count` or very high key cardinality.

### Rate Limit with Anonymous Consumer

does not need to authenticate and has less quotas. 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).
Expand Down
Loading
Loading