AlinsRan opened a new pull request, #13840:
URL: https://github.com/apache/apisix/pull/13840
### Description
`graphql-limit-count` charges the quota by the query's maximum nesting
depth. Depth ignores both fan-out and width — `users(first: 10000)` and
`users(first: 1)` have the same depth, so they cost the same — which makes it
hard to protect an upstream from the queries that actually hurt it.
This adds two cost strategies next to the existing `depth`:
| `cost_strategy` | cost of a field |
|---|---|
| `depth` | unchanged: the query's maximum nesting depth |
| `default` | `( Σ the fields selected under it ) × mul_value + add_value` |
| `node_quantifier` | only fields carrying a quantifier argument are
charged, multiplied by how many times the field is resolved |
**`depth` stays the default**, so an existing configuration keeps its
current cost after the upgrade.
### Configuring the weights
The per-field weights are configured inline on the plugin as `field_costs`:
```json
{
"cost_strategy": "node_quantifier",
"max_cost": 5000,
"field_costs": [
{"field_path": "Query.products", "mul_arguments": ["first"],
"add_value": 1},
{"field_path": "Product.reviews", "mul_arguments": ["first"],
"add_value": 1}
]
}
```
`add_value` / `mul_value` are the literals; `add_arguments` /
`mul_arguments` name query arguments whose values fold into them, which is how
a paginating argument turns into fan-out. `*_value` and `*_arguments` are the
same quantity from two sources.
A `field_path` is a chain of GraphQL name tokens, matched by walking it
token by token:
- `Product.reviews` weights `reviews` **wherever** it is selected on a
`Product`.
- `Query.products.nodes.reviews` pins the weight to **one chain** of
selections and leaves a `reviews` reached any other way alone.
Where a shallow and a deep path name the same field they merge key by key,
longer path last, so the result is a function of the configuration rather than
of the order the entries are listed in.
A field with no matching entry weighs 1 and multiplies by 1, so a route with
no `field_costs` is charged its node count.
### Schema introspection
`field_costs` addresses a field by its GraphQL type, while the query AST
carries only field names — `Person.name` cannot be told from `Vehicle.name`
without the schema. The plugin issues an introspection query to the upstream on
the first request that needs one, single-flights it with `resty.lock`, and
caches it per worker; a schema change on a live upstream takes effect after a
reload. **No introspection request is made** when `field_costs` is unset or
`cost_strategy` is `depth`, so a route that does not use the cost model is
unaffected.
`introspection_endpoint` is there for deployments where the endpoint cannot
be derived from the route's upstream. A failed introspection is remembered for
10s, so an unresponsive endpoint does not draw one upstream request per client
request, and the request path derived for the call is escaped before it reaches
the introspection request line.
### Also added
- `max_cost` — 403 once a query costs more than this, whatever quota is
left. The quota is charged first, and 403 wins over the rate-limit rejection.
- `score_factor` — scales the raw cost before it is charged, so a cost model
with large numbers still fits a sane quota.
- `resolve_variables` (default `false`) — read a quantifier passed as a
GraphQL variable, or defaulted by the schema, instead of treating it as absent.
- `pass_all_downstream_headers` — forward the downstream headers on the
introspection request; only `Authorization` is forwarded by default.
- `X-Graphql-Query-Cost` on the response, behind the existing
`show_limit_quota_header`.
### Deliberate handling worth a look
- **A quantifier that is not a number is treated as absent.** `first: "ten"`
and `first: true` are client-controlled; letting them reach the arithmetic
would let any caller fail their own request. A numeric string still multiplies.
- **A field the introspected schema does not declare counts as a plain
unweighted node.** `__typename` is in no schema's `fields` and clients add it
automatically; a schema can also drift from the running upstream. Neither
should fail a request.
- **`operationName` decides what is charged.** A document with several
operations only executes the one `operationName` selects, so that is the one
costed. Without it the whole document is costed and the dearest operation
charged, which cannot under-charge whichever one the upstream runs.
### Checklist
- [x] I have explained the need for this PR and the problem it solves
- [x] I have explained the changes or the new features added to this PR
- [x] I have added tests corresponding to this change
- [x] I have updated the documentation to reflect this change
- [x] I have verified that this change is backward compatible (`depth`
remains the default; no introspection request is made unless the cost model is
configured)
### Tests
`t/plugin/graphql-limit-count2.t`, 16 cases:
- schema validation of `cost_strategy`, `score_factor` and `field_path`,
including a four-segment path
- the engine on a paginating query under both strategies, and with no
weights at all
- fragment spreads counted per spread site, inline fragments not moving the
type cursor, variables with `resolve_variables` on and off, and a deep
`field_path`
- end to end: introspection from the upstream and the cost applied;
quantifiers multiplying across nesting; `resolve_variables` counting a variable
and a schema argument default; `max_cost` rejecting with 403; `score_factor`;
`operationName` selecting the charged operation; and `depth` still being the
default strategy
`t/lib/server.lua` gains a mock GraphQL upstream that answers both the
introspection query and normal queries from the same endpoint, which is how the
plugin derives the introspection endpoint when `introspection_endpoint` is
unset.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]