Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
71 changes: 65 additions & 6 deletions apisix/plugins/limit-count/limit-count-redis-cluster.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ local core = require("apisix.core")
local setmetatable = setmetatable
local tostring = tostring

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 +39,54 @@ 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 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
redis.call('ZADD', KEYS[1], now, now .. ':' .. i)
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)
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 +95,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 +107,23 @@ 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

res, err = red:eval(script_sliding, 1, key, now, window, limit, c)
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
77 changes: 68 additions & 9 deletions apisix/plugins/limit-count/limit-count-redis.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,15 @@ local setmetatable = setmetatable
local tostring = tostring


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 +40,61 @@ 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 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
redis.call('ZADD', KEYS[1], now, now .. ':' .. i)
Copy link
Contributor

Choose a reason for hiding this comment

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

In high-concurrency scenarios, uniqueness may not be guaranteed. I think we can utilize ngx.var.request_id

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 +108,22 @@ 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

res, err = red:eval(script_sliding, 1, key, now, window, limit, c)
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 +132,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