Plugins

Complexity plugin

Score fields by cost and cap the complexity, depth, and breadth of incoming queries.

A single GraphQL request can ask for an unbounded amount of work: deep nesting, wide selection sets, list fields that fan out into more list fields. The complexity plugin scores every field in a query and rejects requests that cost too much, before any resolver runs. You set the ceilings once on the builder (or per-request in toSchema), and tune the cost of individual fields with a complexity option where the defaults are wrong.

Install

npm install --save @pothos/plugin-complexity

Capping query cost

Register the plugin and pass a complexity option to the builder. limit sets the three ceilings a query must stay under; defaultComplexity and defaultListMultiplier set the baseline cost every field starts from.

const builder = new SchemaBuilder({
  plugins: [ComplexityPlugin],
  complexity: {
    defaultComplexity: 1,
    defaultListMultiplier: 10,
    limit: {
      complexity: 100,
      depth: 5,
      breadth: 30,
    },
  },
});

The three limits guard against different shapes of abuse:

  • complexity is the maximum total cost, summed across every selected field.
  • depth is the maximum nesting depth of the selection set.
  • breadth is the total number of fields selected across the entire query.

limit can also be a function that receives the context, so you can raise or lower the ceilings per request (a higher budget for an authenticated internal service, a tighter one for anonymous traffic):

complexity: {
  limit: (ctx) => ({
    complexity: ctx.trusted ? 5000 : 500,
    depth: 10,
    breadth: 50,
  }),
},

When the limits belong to the server that builds the schema rather than the schema definition itself, pass the same options to toSchema (or buildSchema) instead of the builder:

const schema = builder.toSchema({
  complexity: {
    limit: {
      complexity: 500,
      depth: 10,
      breadth: 50,
    },
  },
});

A query with no complexity limit set is never rejected, no matter how expensive; the plugin only enforces the ceilings you provide. Set at least a complexity limit before shipping a public endpoint.

How complexity is calculated

Complexity is computed before any root-level field (query, mutation, or subscription) resolves, from the shape of the query alone; no resolver runs during scoring.

The cost of a query is the sum of the cost of each selected field. When a field has sub-selections, the cost of those sub-selections is multiplied by the field's multiplier, then added to the field's own cost. The default multiplier is 1 for scalar fields and defaultListMultiplier (10 above) for list fields, a rough stand-in for the n+1 fan-out a list implies.

The default query costs 121, with a depth of 3 and a breadth of 4:

query Roster {
  teams {           # 121 = teams(1) + 10 * (name 1 + roster 11)
    name            # 1
    roster {        # 11 = roster(1) + 10 * name(1)
      name          # 1, at depth 3
    }
  }
}

That's over the complexity limit of 100, so the plugin throws before resolving teams. Delete the roster block and the cost drops to 11, under the limit, and the data comes back.

Scoring individual fields

The defaults treat every field the same, but some fields are more expensive to resolve than others. Set a complexity option on any field to override its baseline. It takes three forms:

// A flat base cost for an expensive aggregate; its list sub-selections
// still use the default multiplier of 10.
leaderboard: t.field({
  type: [Player],
  complexity: 20,
  resolve: () => [...Players.values()],
}),

A plain number sets the field's own cost. To override the multiplier applied to its sub-selections as well, pass an object with field and multiplier:

// A roster is cheap to load once the team is in memory: override both the
// field cost and the default list multiplier of 10 with hand-tuned values.
roster: t.field({
  type: [Player],
  complexity: { field: 2, multiplier: 5 },
  resolve: (team) => [...Players.values()].filter((p) => p.teamId === team.id),
}),

complexity can also be a function of the field's arguments and the context, so a field that returns more rows costs more:

// Cost scales with how many rows the caller asks for.
players: t.field({
  type: [Player],
  args: {
    first: t.arg.int(),
  },
  complexity: (args) => ({ field: 5, multiplier: args.first ?? 5 }),
  resolve: (_parent, { first }) => [...Players.values()].slice(0, first ?? undefined),
}),

To change the baseline for every field at once, set fieldComplexity on the builder, a function (args, ctx, field) => number | { field, multiplier } that runs for any field without its own complexity option. When it's set, defaultComplexity and defaultListMultiplier are ignored.

Customizing the limit error

By default the plugin throws a PothosValidationError naming the ceiling that was exceeded: Query exceeds maximum complexity (complexity: 121, max: 100). Provide complexityError on the builder to return your own error or message:

complexity: {
  complexityError: (kind, result, info) => {
    // kind is 'Complexity', 'Depth', or 'Breadth'
    return `Query too expensive (${kind}): ${result.complexity}/${result.maxComplexity}`;
  },
},

The function receives the error kind, a result carrying the query's complexity, depth, and breadth alongside the maxComplexity, maxDepth, and maxBreadth limits it was checked against, and the GraphQL info object. Return (or throw) an Error, or return a string to have the plugin throw it for you.

Set disabled: true on the builder's complexity option to skip the check entirely, which turns limits off in a trusted environment without pulling the plugin out of the build.

Measuring a query yourself

complexityFromQuery scores a query outside of execution: log costs, reject requests at the edge, or assert on complexity in tests.

import { complexityFromQuery } from '@pothos/plugin-complexity';

const complexity = complexityFromQuery(query, {
  schema,
  // Complexity can depend on the context and arguments, so pass valid values
  // when a field's cost function reads them. Both are optional and default to
  // empty objects.
  ctx: {},
  variables: {},
});

Options

Every option lives under the builder's complexity object (or the one passed to toSchema/buildSchema):

OptionPurpose
limitThe { complexity, depth, breadth } ceilings a query must stay under. Either an object or a function that receives the context. Fields you omit are not enforced.
defaultComplexityBaseline cost for every field without its own complexity option. Defaults to 1.
defaultListMultiplierBaseline multiplier applied to a list field's sub-selections. Defaults to 10.
fieldComplexity(args, ctx, field) => number | { field, multiplier }, a default cost calculation for all fields. Overrides defaultComplexity and defaultListMultiplier when set.
complexityError(kind, result, info) => Error | string, the error to throw when a limit is exceeded. Defaults to a PothosValidationError.
disabledSet true to skip the complexity check entirely. Unlike the other options, this is read only from the builder's complexity option; passing it to toSchema/buildSchema has no effect.

The per-field complexity option accepts a number, an object { field?, multiplier? }, or a function (args, ctx, field) => number | { field?, multiplier? }.