Query limits
Bound query depth and complexity at the schema level — two ceilings that catch the recursive bomb, the wide sweep, and volumetric fanout before a resolver runs.
A GraphQL endpoint accepts whatever the client asks for. Without a ceiling, three shapes of query can take a production app down before auth even runs:
- Recursive —
{ a { a { a { … 50 levels … } } } }. Tiny payload, massive resolve work. - Wide / flat — a single selection with hundreds of fields.
- Volumetric fanout —
{ users { posts { comments { … } } } }where every step ishas_many. Three levels of page-sized fanout multiply out per parent.
nestrs wraps async-graphql’s two validation visitors that cover the three vectors. Both are on by default; tune the ceilings to your traffic.
The two ceilings
Section titled “The two ceilings”| Limit | Catches | Config field | Env var |
|---|---|---|---|
| Depth | Recursive | max_depth | NESTRS_GRAPHQL__MAX_DEPTH |
| Complexity | Wide + fanout (with per-field annotations) | max_complexity | NESTRS_GRAPHQL__MAX_COMPLEXITY |
Both are on by default (max_depth = 15, max_complexity = 2000).
Keep both — depth alone misses wide queries, complexity alone misses
queries that depth-bomb the parser before the visitor even runs.
use nest_rs::core::module;use nest_rs::graphql::{GraphqlConfig, GraphqlModule};
#[module(imports = [ GraphqlModule::for_root(GraphqlConfig { max_depth: Some(15), max_complexity: Some(1000), ..Default::default() }),])]pub struct AppModule;NESTRS_GRAPHQL__MAX_DEPTH=15NESTRS_GRAPHQL__MAX_COMPLEXITY=1000The validation runs before any resolver, so a query that exceeds
either limit costs the parse pass plus one visit — no DB round-trip,
no dataloader batch, no Repo call.
What each ceiling does
Section titled “What each ceiling does”The visitor walks the AST and tracks the deepest selection chain.
A query nesting deeper than max_depth returns a validation error.
Typical production value: 10 to 20. Real queries rarely go past 6 even with multi-level relation traversal; 15 leaves headroom for admin tooling.
Complexity
Section titled “Complexity”The visitor sums a score across every selected field. The default
scoring is async-graphql’s: each field contributes 1 + child_complexity (one for the field itself, plus the sum of its
selected sub-fields). A flat wide query of 500 fields scores 500;
a 4-level relation chain scores roughly 4.
The per-field override is where complexity becomes a real
anti-DoS mechanism. The #[expose] macro applies one
automatically — see below.
Typical production value: 1000 to 5000, tuned from observed
legitimate queries. The check is strict > — max_complexity = 1000 admits a query scoring exactly 1000; pin 999 to reject it.
Setting max_depth = Some(0) or max_complexity = Some(0) fails at
boot with a config error: 0 would block every query, so None is
the way to disable a check.
The auto-emitted HasMany factor
Section titled “The auto-emitted HasMany factor”A HasMany relation resolves a page of children per parent. With
async-graphql’s default scoring, that field costs 1 — no penalty for the
fanout, even though a hundred rows resolve.
#[expose] patches that automatically, using the page the client asked
for rather than a guessed constant:
#[graphql(complexity = "first.unwrap_or(20) as usize * child_complexity")]async fn users(&self, …, first: Option<u64>, after: Option<String>) -> Connection<String, User> { … }So users(first: 5) scores a twentieth of users(first: 100), and three
levels of HasMany (organizations → teams → members) left at the
default page size score 20³. Tune the ceiling, not the formula — the
formula is hard-wired across the framework so the calibration story stays
the same app-wide. The 20 mirrors nest_rs_seaorm::DEFAULT_PAGE_SIZE.
BelongsTo resolvers keep async-graphql’s default 1 + child_complexity — they load at most one parent row, and an ability
denial drops that to zero, so no fanout penalty is needed. That means
HasMany and BelongsTo use asymmetric base scoring by default
(multiplicative vs additive); the asymmetry is the point.
Override on the field
Section titled “Override on the field”#[expose(complexity = …)] overrides the auto-emitted factor:
#[expose(name = "Org", service = …, graphql)]#[sea_orm::model]pub struct Model { #[expose] pub id: Uuid, #[expose] pub name: String,
#[sea_orm(has_many)] #[expose(complexity = "20 * child_complexity")] pub users: HasMany<crate::users::Entity>,}Two shapes:
- Literal —
#[expose(complexity = 5)]pins a fixed score. - Expression —
#[expose(complexity = "20 * child_complexity")]uses async-graphql’s expression grammar with the reservedchild_complexityvariable, plus the field’s own arguments. An auto-emittedHasManytakesfirst: Option<u64>andafter: Option<String>, sofirst.unwrap_or(20) as usize * child_complexityis available there; every other field has no arguments to name, and an unknown name is an unbound identifier that fails the expansion.
The override applies to any exposed field, scalar or relation:
#[expose(name = "Report", service = …, graphql)]pub struct Model { #[expose] pub id: Uuid, /// Aggregates 24h of metric points server-side. #[expose(complexity = 50)] pub rolling_avg: f64,}Argument-aware expressions
Section titled “Argument-aware expressions”An expression that reads a GraphQL argument only resolves when the field
declares that argument. The auto-emitted HasMany declares first
and after, which is why its default formula can be argument-aware at
all. A hand-written #[field_resolver] taking its own arguments does the
same with a regular #[graphql(complexity = …)]:
// No #[expose] => no auto-emission; own it on the resolver.#[sea_orm(has_many)]pub posts: HasMany<crate::posts::Entity>,
// in the resolver file:#[ComplexObject]impl User { #[graphql(complexity = "limit as usize * child_complexity")] async fn recent_posts(&self, limit: u32) -> Vec<Post> { … }}On the wire
Section titled “On the wire”Validation failures come back as a regular errors[] array with a
200 OK:
{ "errors": [{ "message": "Query is nested too deep." }], "data": null}The message comes straight from async-graphql. Inspect it as you would any other validation error.
Calibration walkthrough
Section titled “Calibration walkthrough”- Set
max_depth = 15from day one. Cheap, almost never a false positive, catches the recursive bomb. - Set
max_complexity = 5000(generous) and log every denial atwarnfor a few weeks. Watch what hits the ceiling. - Once denials cluster around abusive queries (deep relation chains on public endpoints, scrapers requesting hundreds of fields), tighten to the lowest value that doesn’t trip legitimate traffic.
- Override on individual fields only when a relation’s realistic cost differs from its page size — a known-bounded set can use a smaller literal, and an expensive computed scalar a larger one.
Underlying library
Section titled “Underlying library”The depth and complexity visitors live in
async-graphql. The framework
just exposes them through GraphqlConfig and threads the
#[graphql(complexity = "…")] attribute through #[expose]. The
expression grammar — child_complexity plus field arguments —
is async-graphql’s; see its
query complexity docs
for the full reference.
Reference
Section titled “Reference”crates/nest-rs-graphql/src/config.rs— the twoOption<usize>config fields and their env parsing.crates/nest-rs-graphql/src/resolver.rs—build_schemaapplies the limits to theSchemaBuilderbefore.finish().crates/nest-rs-resource-macros/src/relations.rs— theDEFAULT_HAS_MANY_COMPLEXITYconstant and the#[expose(complexity = …)]parsing.
Going further
Section titled “Going further”- Configuration — where
max_depthandmax_complexityare set. - Relations — the auto-emitted
HasManyconnection and its complexity formula.