Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3611ddb
feat(plugin): add graphql-limit-count plugin
AlinsRan May 14, 2026
2618714
fix: add license header, update config.yaml.example and docs format
AlinsRan May 14, 2026
aa1a1d0
docs: rewrite graphql-limit-count docs to follow open-source format
AlinsRan May 14, 2026
c49acd3
fix: address review comments for graphql-limit-count plugin
AlinsRan May 14, 2026
3019135
fix: fix typo unparseable -> unparsable in test description
AlinsRan May 15, 2026
ef7e21d
fix: fix luacheck warning - avoid overwriting err variable
AlinsRan May 15, 2026
ce2f301
refactor: rename max_query_depth to node_depth
AlinsRan May 15, 2026
a400d7c
fix: remove manual http_config shared dict declarations from test
AlinsRan May 15, 2026
d8ef31e
fix: add plugin-graphql-limit-count shared dicts to test framework
AlinsRan May 17, 2026
a2506b4
fix(tests): use pipelined_requests for rate limit quota exhaustion test
AlinsRan May 17, 2026
34fafe7
fix(tests): reindex test cases after merging TEST 2+3
AlinsRan May 17, 2026
637fe1c
fix(graphql-limit-count): expand fragment spreads and inline fragment…
AlinsRan May 22, 2026
9cfb56b
fix(graphql-limit-count): localize ipairs to satisfy lj-releng
AlinsRan May 22, 2026
69ed2d1
fix: use name.value for fragment AST name nodes; add charset and cycl…
AlinsRan May 22, 2026
bdf7eeb
test(graphql-limit-count): make inline fragment depth test independent
AlinsRan May 25, 2026
9730ccf
fix(graphql-limit-count): coerce graphql.max_size to number with tonu…
AlinsRan May 25, 2026
039f47b
test(graphql-limit-count): add E2E test for graphql.max_size body siz…
AlinsRan May 25, 2026
0421cf9
fix(graphql-limit-count): validate max_size > 0 and clamp depth to mi…
AlinsRan May 25, 2026
464c0c7
fix(graphql-limit-count): localize tonumber to satisfy lj-releng lint
AlinsRan May 25, 2026
1d443b0
test(graphql-limit-count): assert response body in max_size rejection…
AlinsRan May 25, 2026
68067a4
fix(graphql-limit-count): avoid logging full request body in error paths
AlinsRan May 25, 2026
f7f3f38
fix(graphql-limit-count): remove request body from error log messages
AlinsRan May 25, 2026
2baff86
test(graphql-limit-count): add error_log assertion to TEST 24 for max…
AlinsRan May 25, 2026
ec942c1
test(graphql-limit-count): add chained fragment spread depth test
AlinsRan May 25, 2026
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
3 changes: 3 additions & 0 deletions apisix/cli/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ local _M = {
["plugin-limit-req-redis-cluster-slot-lock"] = "1m",
["plugin-limit-count-redis-cluster-slot-lock"] = "1m",
["plugin-limit-conn-redis-cluster-slot-lock"] = "1m",
["plugin-graphql-limit-count"] = "10m",
["plugin-graphql-limit-count-reset-header"] = "10m",
["plugin-ai-rate-limiting"] = "10m",
["plugin-ai-rate-limiting-reset-header"] = "10m",
tracing_buffer = "10m",
Expand Down Expand Up @@ -241,6 +243,7 @@ local _M = {
"proxy-rewrite",
"workflow",
"api-breaker",
"graphql-limit-count",
"limit-conn",
"limit-count",
"limit-req",
Expand Down
8 changes: 8 additions & 0 deletions apisix/cli/ngx_tpl.lua
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,14 @@ http {
lua_shared_dict plugin-limit-count-reset-header {* http.lua_shared_dict["plugin-limit-count"] *};
{% end %}

{% if enabled_plugins["graphql-limit-count"] then %}
lua_shared_dict plugin-graphql-limit-count {* http.lua_shared_dict["plugin-graphql-limit-count"] *};
lua_shared_dict plugin-graphql-limit-count-reset-header {* http.lua_shared_dict["plugin-graphql-limit-count-reset-header"] *};
{% if not enabled_plugins["limit-count"] then %}
lua_shared_dict plugin-limit-count-redis-cluster-slot-lock {* http.lua_shared_dict["plugin-limit-count-redis-cluster-slot-lock"] *};
{% end %}
{% end %}
Comment thread
AlinsRan marked this conversation as resolved.

{% if enabled_plugins["prometheus"] and not enabled_stream_plugins["prometheus"] then %}
lua_shared_dict prometheus-metrics {* http.lua_shared_dict["prometheus-metrics"] *};
{% end %}
Expand Down
160 changes: 160 additions & 0 deletions apisix/plugins/graphql-limit-count.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
--
-- 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 limit_count = require("apisix.plugins.limit-count.init")
local core = require("apisix.core")
local config_local = require("apisix.core.config_local")
local gq_parse = require("graphql").parse
local limit_count_ver = require("resty.limit.count")._VERSION

local type = type
local pairs = pairs
local pcall = pcall
local max = math.max

local GRAPHQL_DEFAULT_MAX_SIZE = 1048576

local plugin_name = "graphql-limit-count"
local _M = {
version = 0.1,
priority = 1004,
name = plugin_name,
schema = limit_count.schema,
}


function _M.check_schema(conf)
return limit_count.check_schema(conf)
end


local GRAPHQL_REQ_QUERY = "query"
local GRAPHQL_REQ_MIME_JSON = "application/json"
local GRAPHQL_REQ_MIME_GQL = "application/graphql"


local fetch_graphql_body = {
["POST"] = function(ctx, max_size)
local body, err = core.request.get_body(max_size, ctx)
if not body then
return nil, "failed to read graphql data, " .. (err or "request body has zero size")
end

return body
end
}


local check_graphql_request = {
["POST"] = function(ctx, body)
local content_type = core.request.header(ctx, "Content-Type") or ""

if core.string.has_prefix(content_type, GRAPHQL_REQ_MIME_JSON) then
local res, err = core.json.decode(body)
if not res then
return false, "invalid graphql request, " .. err
end

if not res[GRAPHQL_REQ_QUERY] then
return false, "invalid graphql request, json body[" ..
GRAPHQL_REQ_QUERY .. "] is nil"
end

return true, res[GRAPHQL_REQ_QUERY]
end

if core.string.has_prefix(content_type, GRAPHQL_REQ_MIME_GQL) then
return true, body
end

return false, "invalid graphql request, error content-type: " .. content_type
end
}


-- Returns the maximum selection nesting depth of the GraphQL query AST.
local function node_depth(t)
if type(t) ~= "table" then
return 0
end

local depth = 0
for k, v in pairs(t) do
local child
if k == "selections" then
child = 1 + node_depth(v)
else
child = node_depth(v)
end
depth = max(depth, child)
end

return depth
end


function _M.access(conf, ctx)
if limit_count_ver < '1.0.0' then
core.log.error("need to build APISIX-Base to support GraphQL limit count")
return 501
end

local method = core.request.get_method()
if method ~= "POST" then
return 405
end

local max_size = GRAPHQL_DEFAULT_MAX_SIZE
local local_conf = config_local.local_conf()
if local_conf then
local size = core.table.try_read_attr(local_conf, "graphql", "max_size")
if size then
max_size = size
end
end

local body, err = fetch_graphql_body[method](ctx, max_size)
if not body then
core.log.error(err)
return 400, {message = "Invalid graphql request: can't get graphql request body"}
end

local is_graphql_req, query_or_err = check_graphql_request[method](ctx, body)
if not is_graphql_req then
core.log.error(query_or_err)
return 400, {message = query_or_err}
end

local ok, res = pcall(gq_parse, query_or_err)
if not ok then
core.log.error("failed to parse graphql: ", res, ", body: ", body)
return 400, {message = "Invalid graphql request: failed to parse graphql query"}
end

local n = #res.definitions
if n == 0 then
core.log.error("failed to parse graphql: empty query, body: ", body)
return 400, {message = "Invalid graphql request: empty graphql query"}
end

local depth = node_depth(res)
core.log.info("graphql query depth: ", depth)

return limit_count.rate_limit(conf, ctx, plugin_name, depth)
Comment thread
AlinsRan marked this conversation as resolved.
end


return _M
3 changes: 3 additions & 0 deletions conf/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ nginx_config: # Config for render the template to generate n
plugin-limit-req-redis-cluster-slot-lock: 1m
plugin-limit-count-redis-cluster-slot-lock: 1m
plugin-limit-conn-redis-cluster-slot-lock: 1m
plugin-graphql-limit-count: 10m
plugin-graphql-limit-count-reset-header: 10m
tracing_buffer: 10m
plugin-api-breaker: 10m
etcd-cluster-health-check: 10m
Expand Down Expand Up @@ -524,6 +526,7 @@ plugins: # plugin list (sorted by priority)
- proxy-rewrite # priority: 1008
- workflow # priority: 1006
- api-breaker # priority: 1005
- graphql-limit-count # priority: 1004
- limit-conn # priority: 1003
- limit-count # priority: 1002
- limit-req # priority: 1001
Expand Down
1 change: 1 addition & 0 deletions docs/en/latest/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"plugins/limit-req",
"plugins/limit-conn",
"plugins/limit-count",
"plugins/graphql-limit-count",
"plugins/proxy-cache",
"plugins/request-validation",
"plugins/oas-validator",
Expand Down
179 changes: 179 additions & 0 deletions docs/en/latest/plugins/graphql-limit-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
title: graphql-limit-count
keywords:
- Apache APISIX
- API Gateway
- Plugin
- graphql-limit-count
- Rate Limiting
- GraphQL
description: The graphql-limit-count Plugin limits the rate of GraphQL requests based on the query AST depth within a given time window, using the same counting mechanism as the limit-count Plugin.
---

<!--
#
# 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.
#
-->

Comment thread
AlinsRan marked this conversation as resolved.
<head>
<link rel="canonical" href="https://docs.api7.ai/hub/graphql-limit-count" />
</head>

## Description

The `graphql-limit-count` Plugin limits the rate of GraphQL requests using a fixed window algorithm. Unlike [limit-count](./limit-count.md), which counts each request as a cost of 1, this Plugin uses the **depth of the GraphQL query AST** as the cost. This allows you to enforce stricter limits on deeply nested queries that are more expensive to process.

Only `POST` requests are supported. The Plugin accepts two content types:

- `application/json`: request body must contain a `query` field with the GraphQL query string.
- `application/graphql`: request body is the raw GraphQL query starting with `query`.

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

- `X-RateLimit-Limit`: the total quota
- `X-RateLimit-Remaining`: the remaining quota
- `X-RateLimit-Reset`: number of seconds left for the counter to reset

## Attributes

This Plugin shares the same schema as the [limit-count](./limit-count.md) Plugin. Refer to that page for the full attribute reference. Key attributes are listed below.

| Name | Type | Required | Default | Valid values | Description |
|------|------|----------|---------|--------------|-------------|
| count | integer or string | False | | > 0 | The maximum allowed accumulated query AST depth within the time window. Required if `rules` is not configured. |
| time_window | integer or string | False | | > 0 | The time interval in seconds for the rate limiting window. Required if `rules` is not configured. |
| key_type | string | False | var | ["var", "var_combination", "constant"] | The type of key. `var` treats `key` as an NGINX variable. `var_combination` combines multiple variables. `constant` uses `key` as a fixed value. |
| key | string | False | remote_addr | | The key to count requests by. |
| rejected_code | integer | False | 503 | [200,...,599] | HTTP status code returned when a request is rejected for exceeding the quota. |
| rejected_msg | string | False | | non-empty | Response body returned when a request is rejected. |
| policy | string | False | local | ["local", "redis", "redis-cluster"] | Counter storage policy. `local` stores the counter in memory on the current APISIX node. `redis` and `redis-cluster` share counters across instances. |
| allow_degradation | boolean | False | false | | When true, APISIX continues handling requests if the Plugin or its dependencies become unavailable. |
| show_limit_quota_header | boolean | False | true | | When true, include `X-RateLimit-Limit` and `X-RateLimit-Remaining` headers in the response. |
| group | string | False | | non-empty | Group ID to share a single rate limiting counter across multiple routes. |
| redis_host | string | False | | | Address of the Redis node. Required when `policy` is `redis`. |
| redis_port | integer | False | 6379 | [1,...] | Port of the Redis node. Used when `policy` is `redis`. |
| redis_username | string | False | | | Username for Redis ACL authentication. Used when `policy` is `redis`. |
| redis_password | string | False | | | Password of the Redis node. Used when `policy` is `redis` or `redis-cluster`. |
| redis_ssl | boolean | False | false | | When true, use SSL to connect to Redis. Used when `policy` is `redis`. |
| redis_ssl_verify | boolean | False | false | | When true, verify the Redis server SSL certificate. Used when `policy` is `redis`. |
| redis_database | integer | False | 0 | >= 0 | The Redis database number. Used when `policy` is `redis`. |
| redis_timeout | integer | False | 1000 | [1,...] | Redis timeout in milliseconds. Used when `policy` is `redis` or `redis-cluster`. |
| redis_cluster_nodes | array[string] | False | | | List of Redis cluster node addresses. Required when `policy` is `redis-cluster`. |
| redis_cluster_name | string | False | | | Name of the Redis cluster. Required when `policy` is `redis-cluster`. |
| redis_cluster_ssl | boolean | False | false | | When true, use SSL to connect to the Redis cluster. Used when `policy` is `redis-cluster`. |
| redis_cluster_ssl_verify | boolean | False | false | | When true, verify the Redis cluster server SSL certificate. Used when `policy` is `redis-cluster`. |

## Examples

The examples below demonstrate how you can configure `graphql-limit-count` in different scenarios.

:::note

```bash
admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"//g')
```

:::

### Limit Requests by Query Depth per Client

The following example demonstrates how to rate limit GraphQL requests based on the accumulated query AST depth per client IP address. A shallow query like `{ foo { bar } }` (depth 2) consumes 2 out of the quota, while a deeply nested query like `{ foo { bar { baz { id } } } }` (depth 4) consumes 4.

Create a Route with `graphql-limit-count` that allows a cumulative query depth of 10 per minute per client IP:

```shell
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-d '{
"id": "graphql-limit-count-route",
"uri": "/graphql",
"plugins": {
"graphql-limit-count": {
"count": 10,
"time_window": 60,
"rejected_code": 429,
"key_type": "var",
"key": "remote_addr",
"policy": "local"
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"127.0.0.1:1980": 1
}
}
}'
```

Send a depth-4 GraphQL query:

```shell
curl -i "http://127.0.0.1:9080/graphql" \
-H "Content-Type: application/json" \
-d '{"query": "query { foo { bar { baz { id } } } }"}'
```

You should receive an `HTTP/1.1 200 OK` response with the following headers:

```text
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 6
```

The depth-4 query consumed 4 out of the 10 quota. After the quota is exhausted within the time window, you will receive `HTTP/1.1 429 Too Many Requests`.

### Share Quota Among APISIX Nodes with a Redis Server

The following example demonstrates how to use a Redis-backed counter so that the rate limiting quota is shared across multiple APISIX instances.

```shell
curl "http://127.0.0.1:9180/apisix/admin/routes" -X PUT \
-H "X-API-KEY: ${admin_key}" \
-d '{
"id": "graphql-limit-count-route",
"uri": "/graphql",
"plugins": {
"graphql-limit-count": {
"count": 100,
"time_window": 60,
"rejected_code": 429,
"key_type": "var",
"key": "remote_addr",
"policy": "redis",
"redis_host": "127.0.0.1",
"redis_port": 6379
}
},
"upstream": {
"type": "roundrobin",
"nodes": {
"127.0.0.1:1980": 1
}
}
}'
```

Send a request to verify:

```shell
curl -i "http://127.0.0.1:9080/graphql" \
-H "Content-Type: application/json" \
-d '{"query": "query { foo { bar } }"}'
```

You should receive an `HTTP/1.1 200 OK` response. The counter is now shared across all APISIX nodes connected to the same Redis instance.
Loading
Loading