Eine schnelle GraphQL-API mit Fastify und Yoga · Teil 8 von 8 der Serie
Teil 8: Query-Limits und Persisted Operations
Schütz die API mit einem Tiefenlimit und einem Kostenlimit vor Operationen, die zu tief oder zu teuer sind, und beschränk sie mit Persisted Operations auf die Operationen deiner eigenen Clients.

Quest-Ziele
- Sehen, wie eine kurze Query nach einer riesigen Antwort fragen kann
- Begrenzen, wie tief eine Operation gehen darf
- Abschätzen, was eine Operation kostet, bevor sie läuft
- In der Produktion nur Persisted Operations erlauben
- Wissen, was du prüfst, bevor die API live geht
Die Freiheit, die GraphQL für Clients so angenehm macht, ist auch seine Schwachstelle. Das Schema hat Kreise: Eine Gilde hat Abenteurer, ein Abenteurer hat eine Gilde, und ein Client kann diesem Kreis folgen, so lange er will:
$ curl -s -o /dev/null -w '%{size_download} bytes\n' localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guilds { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { name } } } } } } } } } } }"}'22378 bytes
139 Zeichen, und die Antwort ist 22 KB groß, bei einem Brett mit drei Gilden zu je drei Abenteurern. Mit 50 Abenteurern pro Gilde fragt dieselbe Query nach 3 × 50⁵ Namen, fast einer Milliarde. DataLoader hält die Datenbank ruhig, weil es immer wieder dieselben wenigen Zeilen sind, aber der Server baut diese Antwort trotzdem Objekt für Objekt, bis ihm der Speicher ausgeht. Aliase machen es schlimmer: Ein Client kann in einer Query hundertmal nach demselben teuren Feld fragen, jedes Mal unter einem anderen Namen.
Zwei Limits fangen das ab, bevor etwas läuft: eins für die Tiefe und eins für die Kosten.
Das Tiefenlimit
$ pnpm add @envelop/depth-limit graphql-query-complexity[WARN] 4 deprecated subdependencies found: @esbuild-kit/core-utils@3.3.2, @esbuild-kit/esm-loader@2.6.5, lodash.get@4.4.2, node-domexception@1.0.0Packages: +5dependencies:+ @envelop/depth-limit 8.0.0+ graphql-query-complexity 2.0.0Done in 569ms using pnpm v12.16.1
Das Tiefenlimit ist eine Zeile in den Plugins. Die zweite Zeile ist das Kostenlimit, das du als Nächstes schreibst:
import { useDepthLimit } from '@envelop/depth-limit';
import { useCostLimit } from './cost.ts';
// …
plugins: [
useDepthLimit({ maxDepth: 6 }),
useCostLimit(5_000),Das Tiefenlimit zählt die Felder ganz oben als Tiefe 0, maxDepth: 6 erlaubt also sieben Ebenen. Das reicht für jede Query auf dem Brett und ist viel zu flach für die Spirale.
Das Kostenlimit
Die Tiefe allein reicht nicht: quests(first: 100) mit ein paar Ebenen darunter ist nicht tief und trotzdem teuer. Ein Kostenlimit schätzt ab, was eine Operation zurückgeben wird, bevor sie läuft. graphql-query-complexity geht die Operation durch und fragt Schätzer, was jedes Feld kostet. Leg src/cost.ts an:
import { GraphQLError, getNullableType, isListType, type ExecutionArgs } from 'graphql';
import type { Plugin } from 'graphql-yoga';
import {
getComplexity,
simpleEstimator,
type ComplexityEstimator,
} from 'graphql-query-complexity';
/** A list costs its size times one item: `first` where it is given, a guess of 10 elsewhere. */
const listEstimator: ComplexityEstimator = ({ field, args, childComplexity }) => {
if (!isListType(getNullableType(field.type))) return undefined;
const size = typeof args.first === 'number' ? args.first : 10;
return size * (1 + childComplexity);
};
export function useCostLimit(maxCost: number): Plugin {
/** An error result for an operation over the limit, and nothing for one within it. */
function check(args: ExecutionArgs) {
const cost = getComplexity({
schema: args.schema,
query: args.document,
variables: args.variableValues as Record<string, unknown>,
operationName: args.operationName ?? undefined,
estimators: [listEstimator, simpleEstimator({ defaultComplexity: 1 })],
});
if (cost <= maxCost) return undefined;
return {
errors: [
new GraphQLError(`This operation costs ${cost} points, the limit is ${maxCost}`, {
extensions: { code: 'OPERATION_TOO_EXPENSIVE', cost, maxCost },
}),
],
};
}
return {
onExecute({ args, setResultAndStopExecution }) {
const rejected = check(args);
if (rejected) setResultAndStopExecution(rejected);
},
onSubscribe({ args, setResultAndStopExecution }) {
const rejected = check(args);
if (rejected) setResultAndStopExecution(rejected);
},
};
}Die Regel ist kurz: Jedes Feld kostet einen Punkt, und eine Liste kostet ihre Größe mal einen Eintrag. Die Größe ist first, wo ein Feld es hat, und eine Schätzung von 10 für Listen ohne, wie die Abenteurer einer Gilde. So kostet { quests { title guild { name } } } 20 Quests × (1 + 3) = 80 Punkte.
Die Prüfung läuft in onExecute und onSubscribe, nicht als Validierungsregel. Die Kosten hängen von den Variablen ab, first: $count kann alles sein, und Yoga cacht die Validierung einer Operation ohne sie. Probier es aus:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"query Spiral { guilds { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { guild { adventurers { name } } } } } } } } } } }"}'{"errors":[{"message":"'Spiral' exceeds maximum operation depth of 6","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 100) { title guild { adventurers { name quests { title } } } } }"}'{"errors":[{"message":"This operation costs 22300 points, the limit is 5000","extensions":{"code":"OPERATION_TOO_EXPENSIVE","cost":22300,"maxCost":5000}}]}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ quests(first: 2) { title reward guild { name } assignee { name } } }"}'{"data":{"quests":[{"title":"Slay the cellar rats","reward":20,"guild":{"name":"Ember Lions"},"assignee":{"name":"Brakka"}},{"title":"Escort the spice caravan","reward":120,"guild":{"name":"Ember Lions"},"assignee":{"name":"Ilse"}}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}
Die Spirale scheitert an ihrer Tiefe, die breite Query kostet 22300 Punkte und wird abgewiesen, und das Brett kommt durch. Die Gildenübersicht aus Teil 3 kostet 2220, und das Questbrett, das gleich ein echter Client schickt, kostet 180, 5000 lässt also Luft, ohne etwas Riesiges durchzulassen. Auch Subscriptions werden geprüft. Mit einem Limit von kurz 100 bekommt eine Subscription auf neue Quests mit den Abenteurern ihrer Gilde und deren Quests ein Fehler-Event und endet:
event: nextdata: {"errors":[{"message":"This operation costs 212 points, the limit is 100","extensions":{"code":"OPERATION_TOO_EXPENSIVE","cost":212,"maxCost":100}}]}event: completedata:
Persisted Operations
Limits schützen vor Queries, mit denen du nicht gerechnet hast. Bedient die API nur dein eigenes Frontend, kannst du weiter gehen, denn dann kennst du jede Operation, die es schickt. Dein Build sammelt sie in einer Liste, jede Operation unter dem Hash ihres Texts. Der Client schickt nur den Hash und die Variablen, und der Server führt nur Operationen aus seiner Liste aus. Die Query von jemand anderem läuft nie, die Requests sind klein, und ein GET mit einem Hash ergibt eine kurze Adresse, die ein CDN cachen kann.
$ pnpm add @graphql-yoga/plugin-persisted-operationsPackages: +1dependencies:+ @graphql-yoga/plugin-persisted-operations 4.0.1Done in 271ms using pnpm v12.16.1
Die Operationen kommen in einen Ordner operations, je eine Datei, so wie dein Frontend sie schreiben würde. Leg operations/QuestBoard.graphql an:
query QuestBoard($status: QuestStatus) {
quests(status: $status, first: 20) {
id
title
reward
status
guild {
name
}
assignee {
name
}
}
}Und operations/AcceptQuest.graphql:
mutation AcceptQuest($questId: ID!, $adventurerId: ID!) {
acceptQuest(questId: $questId, adventurerId: $adventurerId) {
id
status
assignee {
name
}
}
}Ein kleines Skript macht daraus Hashes in persisted-operations.json. Leg scripts/persist-operations.ts an:
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
const operations: Record<string, string> = {};
for (const file of readdirSync('operations')) {
const document = readFileSync(`operations/${file}`, 'utf8');
const hash = createHash('sha256').update(document).digest('hex');
operations[hash] = document;
console.log(`${hash.slice(0, 12)}… ${file}`);
}
writeFileSync('persisted-operations.json', `${JSON.stringify(operations, null, 2)}\n`);Ergänz das Skript in der package.json und führ es aus:
"persist": "node scripts/persist-operations.ts"$ pnpm persistbde9c83149a0… AcceptQuest.graphqlea91ca7a237e… QuestBoard.graphql
{
"bde9c83149a07f4ca734c542eff70efe7895c7017185941b80ad368ce1a0817b": "mutation AcceptQuest($questId: ID!, $adventurerId: ID!) {\n acceptQuest(questId: $questId, adventurerId: $adventurerId) {\n id\n status\n assignee {\n name\n }\n }\n}\n",
…
}Jetzt liest der Server die Liste und führt nur aus, was darauf steht. Das ist src/server.ts, wie es am Ende der Serie aussieht:
import { readFileSync } from 'node:fs';
import Fastify, { LogController } from 'fastify';
import { createYoga } from 'graphql-yoga';
import { useExtendContextValuePerExecuteSubscriptionEvent } from '@envelop/execute-subscription-event';
import { useResponseCache } from '@graphql-yoga/plugin-response-cache';
import { useDepthLimit } from '@envelop/depth-limit';
import { usePersistedOperations } from '@graphql-yoga/plugin-persisted-operations';
import { Logger } from '@graphql-hive/logger';
import { PinoLogWriter } from '@graphql-hive/logger/writers/pino';
import { cache } from './cache.ts';
import { useCostLimit } from './cost.ts';
import { createContext, type Context, type ServerContext } from './context.ts';
import { db } from './db/index.ts';
import { createLoaders } from './loaders.ts';
import { schema } from './schema.ts';
const app = Fastify({
logger: true,
logController: new LogController({ disableRequestLogging: true }),
});
/** The operations our clients may send, by their SHA-256 hash. */
const persistedOperations: Record<string, string> = JSON.parse(
readFileSync(new URL('../persisted-operations.json', import.meta.url), 'utf8'),
);
const yoga = createYoga<ServerContext>({
schema,
context: createContext,
plugins: [
usePersistedOperations({
getPersistedOperation: (hash) => persistedOperations[hash] ?? null,
allowArbitraryOperations: process.env.ALLOW_ARBITRARY_OPERATIONS === 'true',
}),
useDepthLimit({ maxDepth: 6 }),
useCostLimit(5_000),
// A subscription keeps its context for every event: give each event fresh loaders.
useExtendContextValuePerExecuteSubscriptionEvent<Context>(() => ({
contextPartial: { loaders: createLoaders(db) },
})),
useResponseCache({
session: () => null,
cache,
ttl: 60_000,
includeExtensionMetadata: process.env.NODE_ENV !== 'production',
}),
],
logging: new Logger({ writers: [new PinoLogWriter(app.log)] }),
});
app.route({
url: yoga.graphqlEndpoint,
method: ['GET', 'POST', 'OPTIONS'],
handler: (req, reply) => yoga.handleNodeRequestAndResponse(req, reply, { req, reply }),
});
await app.listen({ port: Number(process.env.PORT) });allowArbitraryOperations lässt andere Operationen durch, wenn ALLOW_ARBITRARY_OPERATIONS auf true steht. Setz es in der .env, damit GraphiQL und curl beim Entwickeln weiter funktionieren. In der Produktion ist es nicht gesetzt:
PORT=4000
DATABASE_URL=postgres://guild:guild@localhost:5432/guild
LOG_SQL=true
REDIS_URL=redis://localhost:6379
ALLOW_ARBITRARY_OPERATIONS=trueUm zu sehen, was die Produktion macht, start den Server ohne. Eine Variable aus der Shell gewinnt gegen die .env: ALLOW_ARBITRARY_OPERATIONS=false pnpm dev. Dann schick eine Query, das Brett über seinen Hash und einen Hash, der nicht auf der Liste steht:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"query":"{ guilds { name } }"}'{"errors":[{"message":"PersistedQueryOnly","extensions":{"code":"CANNOT_SEND_PQ_ID_AND_BODY"}}]}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"variables":{"status":"DONE"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"ea91ca7a237e13e307d29bce8b5dfffea83cd5947fb2816a5215fb65401edd22"}}}'{"data":{"quests":[{"id":"1","title":"Slay the cellar rats","reward":20,"status":"DONE","guild":{"name":"Ember Lions"},"assignee":{"name":"Brakka"}},{"id":"6","title":"Calm the storm spirit","reward":300,"status":"DONE","guild":{"name":"Tidecallers"},"assignee":{"name":"Nerys"}}]},"extensions":{"responseCache":{"hit":false,"didCache":true,"ttl":60000}}}$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"extensions":{"persistedQuery":{"version":1,"sha256Hash":"0000"}}}'{"errors":[{"message":"PersistedQueryNotFound","extensions":{"code":"PERSISTED_QUERY_NOT_IN_LIST"}}]}
Die Query wird abgewiesen, der unbekannte Hash auch. Das Brett läuft mit seinen Variablen, und Mutations funktionieren genauso:
$ curl -s localhost:4000/graphql -H 'content-type: application/json' -d '{"variables":{"questId":"4","adventurerId":"1"},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"bde9c83149a07f4ca734c542eff70efe7895c7017185941b80ad368ce1a0817b"}}}'{"data":{"acceptQuest":{"id":"4","status":"ACCEPTED","assignee":{"name":"Brakka"}}},"extensions":{"responseCache":{"invalidatedEntities":[{"typename":"Quest","id":"4"},{"typename":"Adventurer","id":"1"}]}}}
Die Mutation hat den Abenteurer ausgewählt, also hat der Cache auch Adventurer:1 von selbst abgeräumt. Zuletzt das Brett als GET-Request, den ein CDN cachen könnte, mit dem etag aus dem letzten Teil:
$ curl -si -G localhost:4000/graphql --data-urlencode 'extensions={"persistedQuery":{"version":1,"sha256Hash":"ea91ca7a237e13e307d29bce8b5dfffea83cd5947fb2816a5215fb65401edd22"}}' --data-urlencode 'variables={"status":"DONE"}' | grep -iE '^(HTTP|etag)'HTTP/1.1 200 OKetag: b80ed0c4ca18e946411ee91bcc31cf0aa1c3d2d42cba881f0c78033d371c8e58
Bevor du live gehst
Setz
NODE_ENV=production, das nimmt die Cache-Informationen aus den Antworten.Lass
LOG_SQLundALLOW_ARBITRARY_OPERATIONSweg. Ohne das zweite sind auch Introspection und GraphiQL zu, weil sie Operationen schicken, die nicht auf der Liste stehen.Starte
pnpm start, nichtpnpm dev: reine JSON-Logs, die ein Log-Sammler lesen kann.Lausch in einem Container auf allen Schnittstellen:
app.listen({ port, host: '0.0.0.0' }). Fastify lauscht standardmäßig auflocalhost, und das erreicht nichts außerhalb des Containers.Jede Instanz öffnet bis zu zehn Verbindungen zu PostgreSQL, das standardmäßig 100 erlaubt. Mit vielen Instanzen setz
maxam Pool, oder stell einen Pooler wie PgBouncer dazwischen.
Was du gebaut hast
So sieht das Projekt am Ende aus:
guild-api/
├── compose.yml PostgreSQL and Valkey
├── codegen.ts GraphQL Codegen: types for the resolvers
├── drizzle.config.ts drizzle-kit: where the schema and migrations are
├── drizzle/ the migrations
├── operations/ the operations your clients send
├── persisted-operations.json those operations by hash, written by pnpm persist
├── scripts/persist-operations.ts
└── src/
├── server.ts Fastify, Yoga and the plugins
├── schema.graphql the schema
├── schema.ts schema file plus resolvers
├── resolvers.ts queries, mutations, subscriptions, fields
├── context.ts what every resolver gets
├── loaders.ts the DataLoaders
├── pubsub.ts events through Valkey
├── cache.ts the response cache in Valkey
├── cost.ts the cost limit
├── generated/graphql.ts written by pnpm codegen
└── db/ Drizzle tables, connection and seedEine GraphQL-API mit Fastify und Yoga, in TypeScript ohne Build-Schritt, typisiert aus ihrem Schema. DataLoader hat aus 16 Abfragen für das Questbrett 3 gemacht. Subscriptions erreichen jedes Brett über Server-Sent Events, über jede Instanz hinweg durch Valkey, und überstehen einen Neustart von Valkey. Wiederholte Lesezugriffe kommen in einer Millisekunde aus einem geteilten Cache, und die API weist ab, was zu tief, zu teuer oder nicht von deinen eigenen Clients ist.
Ergänz von hier aus, was deine eigene API braucht: Authentifizierung im Kontext, Sessions im Cache und weitere Tabellen mit ihren Loadern. Die Muster bleiben dieselben.