Skip to content

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 is has_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.

LimitCatchesConfig fieldEnv var
DepthRecursivemax_depthNESTRS_GRAPHQL__MAX_DEPTH
ComplexityWide + fanout (with per-field annotations)max_complexityNESTRS_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.

apps/api/src/module.rs — production pin
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;
.env.production
NESTRS_GRAPHQL__MAX_DEPTH=15
NESTRS_GRAPHQL__MAX_COMPLEXITY=1000

The 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.

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.

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.

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:

from the macro expansion
#[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.

#[expose(complexity = …)] overrides the auto-emitted factor:

crates/features/src/orgs/entity.rs
#[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 reserved child_complexity variable, plus the field’s own arguments. An auto-emitted HasMany takes first: Option<u64> and after: Option<String>, so first.unwrap_or(20) as usize * child_complexity is 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:

cost an expensive computed scalar
#[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,
}

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 = …)]:

the entity, then the resolver
// 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> { … }
}

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.

  1. Set max_depth = 15 from day one. Cheap, almost never a false positive, catches the recursive bomb.
  2. Set max_complexity = 5000 (generous) and log every denial at warn for a few weeks. Watch what hits the ceiling.
  3. 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.
  4. 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.

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.

  • crates/nest-rs-graphql/src/config.rs — the two Option<usize> config fields and their env parsing.
  • crates/nest-rs-graphql/src/resolver.rs — build_schema applies the limits to the SchemaBuilder before .finish().
  • crates/nest-rs-resource-macros/src/relations.rs — the DEFAULT_HAS_MANY_COMPLEXITY constant and the #[expose(complexity = …)] parsing.
  • Configuration — where max_depth and max_complexity are set.
  • Relations — the auto-emitted HasMany connection and its complexity formula.