Zum Inhalt springen
Alle Beiträge

15. Oktober 202611 Min. Lesezeit

Node.js-API, die nicht einknickt: Fastify, Validierung, Fehler und Limits

Ein API-Grundgerüst in TypeScript und Fastify 5, das vom ersten Tag an mit fehlerhaften Daten, unerwarteten Ausnahmen, Anfrageflut und Neustarts beim Deployment zurechtkommt: Zod, ein zentraler Error-Handler, Rate Limiting, pino-Logs, Health-Checks und geordnetes Herunterfahren.

Node.js-API, die nicht einknickt: Fastify, Validierung, Fehler und Limits

Die meisten API-Ausfälle haben keine exotischen Ursachen, sondern ganz alltägliche: Jemand hat null statt eines Objekts gesendet, eine unbehandelte Exception hat dem Client einen Stack-Trace samt Datenbankpasswort zurückgegeben, ein Bot hat den Login-Endpunkt mit Tausenden Anfragen überflutet, und ein Neustart beim Deployment hat die Hälfte der laufenden Requests abgeschnitten. Im Folgenden zeige ich ein API-Grundgerüst in TypeScript und Fastify, das diese Situationen vom ersten Tag an abfängt: Validierung mit Zod, zentrale Fehlerbehandlung, Rate Limiting, strukturierte Logs, Health-Checks und geordnetes Herunterfahren.

Warum Fastify

Ich wähle Fastify, weil einige seiner Eigenschaften bei einer solchen API wirklich helfen:

  • schemabasierte Validierung und Serialisierung sind eingebaut und nicht per Middleware nachgerüstet,

  • der Logger pino ist Teil des Frameworks, und jede Anfrage bekommt eine eigene ID,

  • das Plugin-System mit Kapselung erleichtert die Aufteilung der Anwendung in Module,

  • Fehler, die in async-Handlern geworfen werden, landen in einem einzigen Error-Handler – ohne try/catch in jeder Route.

Die Beispiele sind für Fastify 5 und Zod 4 geschrieben. Den Code aus diesem Beitrag habe ich am 28.09.2026 in einem Container mit Node.js 22 geprüft (Fastify 5.12, Zod 4.6, fastify-type-provider-zod 7.0, @fastify/rate-limit 11.2): Er lässt sich fehlerfrei kompilieren, und die Szenarien am Ende des Beitrags liefern die beschriebenen Antworten.

Schritt 1: Projekt und Abhängigkeiten

bash
mkdir api && cd api
npm init -y
npm pkg set type=module
npm i fastify @fastify/rate-limit zod fastify-type-provider-zod close-with-grace
npm i -D typescript tsx @types/node pino-pretty

Eine minimale tsconfig.json:

JSON
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}

Bei NodeNext schreibst du Importe lokaler Dateien mit der Endung .js (z. B. ./errors.js), obwohl die Quelle eine .ts-Datei ist – TypeScript ordnet sie den kompilierten Dateien zu. Für die Entwicklung nutzt du tsx watch src/server.ts, in Produktion tsc und node dist/server.js.

Schritt 2: Eigene Domänenfehler

Zuerst lege ich fest, wie die „erwarteten“ Fehler aussehen: Ressource nicht gefunden, Konflikt, fehlende Berechtigung. Statt aus der Tiefe der Geschäftslogik Antworten zurückzugeben, wirfst du eine Exception mit HTTP-Statuscode und maschinenlesbarem Code.

TypeScript
// src/errors.ts
export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly code: string,
    message: string,
  ) {
    super(message)
    this.name = 'AppError'
  }
}

export const notFound = (what: string) => new AppError(404, 'NOT_FOUND', `${what} existiert nicht`)
export const conflict = (message: string) => new AppError(409, 'CONFLICT', message)

Der maschinenlesbare Code (NOT_FOUND, CONFLICT) ist für das Frontend gedacht – die Logik stützt sich auf ihn und nicht auf den Text der Meldung, den du beliebig ändern oder übersetzen kannst.

Schritt 3: Validierung mit Zod

Die Bibliothek fastify-type-provider-zod macht zwei Dinge: Sie wandelt Zod-Schemas in einen Fastify-Validator und -Serializer um und liefert Typvorschläge in den Handlern. Dadurch hat request.body einen aus dem Schema abgeleiteten Typ und nicht unknown.

TypeScript
// src/routes/users.ts
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'
import { z } from 'zod'
import { conflict, notFound } from '../errors.js'

const User = z.object({
  id: z.uuid(),
  email: z.email(),
  name: z.string(),
})

const CreateUser = z.object({
  email: z.email().max(254).transform((v) => v.toLowerCase()),
  name: z.string().trim().min(2).max(80),
})

const users = new Map<string, z.infer<typeof User>>() // in einer echten API: Datenbank

export const userRoutes: FastifyPluginAsyncZod = async (app) => {
  app.post('/users', {
    schema: { body: CreateUser, response: { 201: User } },
    config: { rateLimit: { max: 5, timeWindow: '1 minute' } },
  }, async (request, reply) => {
    const { email, name } = request.body
    if ([...users.values()].some((u) => u.email === email)) {
      throw conflict('Ein Benutzer mit dieser Adresse existiert bereits')
    }
    const user = { id: crypto.randomUUID(), email, name }
    users.set(user.id, user)
    request.log.info({ userId: user.id }, 'Benutzer angelegt')
    return reply.code(201).send(user)
  })

  app.get('/users/:id', {
    schema: { params: z.object({ id: z.uuid() }), response: { 200: User } },
  }, async (request) => {
    const user = users.get(request.params.id)
    if (!user) throw notFound('Benutzer')
    return user
  })
}

Einige Punkte verdienen Beachtung:

  • Das Antwortschema ist auch eine Schutzmaßnahme. Der Serializer sendet nur die in User definierten Felder. Sollte irgendwann passwordHash im Objekt landen, gelangt es nicht zum Client.

  • Normalisierung im Schema. trim() und toLowerCase() sorgen dafür, dass „ Jan “ und „Jan@Example.com“ in einheitlicher Form in der Datenbank landen.

  • Validierung der Pfadparameter. z.uuid() weist /users/123 mit dem Status 400 ab, bevor die Abfrage die Datenbank berührt.

  • Längenbegrenzungen. Jedes Textfeld sollte ein max() haben. Ohne das schickt dir jemand einen Namen mit 90 KB Länge.

Der Typ FastifyPluginAsyncZod sorgt dafür, dass du innerhalb des Plugins nicht erneut withTypeProvider() aufrufen musst.

Schritt 4: Zentraler Error-Handler

Das ist das Herzstück der ganzen Lösung. Jeder Fehler – aus der Validierung, aus deinem Code, aus einem Plugin oder von Fastify selbst – läuft durch eine einzige Stelle und erhält ein einheitliches Antwortformat.

TypeScript
// src/error-handler.ts
import type { FastifyError, FastifyInstance } from 'fastify'
import { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod'
import { AppError } from './errors.js'

export function registerErrorHandler(app: FastifyInstance) {
  app.setErrorHandler((error: FastifyError | AppError, request, reply) => {
    if (hasZodFastifySchemaValidationErrors(error)) {
      return reply.code(400).send({
        error: 'VALIDATION_ERROR',
        message: 'Ungültige Eingabedaten',
        details: error.validation.map((issue) => ({
          path: issue.instancePath,
          message: issue.message,
        })),
        requestId: request.id,
      })
    }

    if (error instanceof AppError) {
      return reply.code(error.statusCode).send({
        error: error.code,
        message: error.message,
        requestId: request.id,
      })
    }

    const status = error.statusCode ?? 500
    if (status < 500) {
      // Clientfehler von Fastify und Plugins: 413, 415, 429 usw.
      return reply.code(status).send({
        error: error.code ?? 'CLIENT_ERROR',
        message: error.message,
        requestId: request.id,
      })
    }

    request.log.error({ err: error }, 'Unbehandelter Fehler')
    return reply.code(500).send({
      error: 'INTERNAL_ERROR',
      message: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es später erneut.',
      requestId: request.id,
    })
  })

  app.setNotFoundHandler((request, reply) => {
    reply.code(404).send({
      error: 'NOT_FOUND',
      message: `Keine Route ${request.method} ${request.url}`,
      requestId: request.id,
    })
  })
}

Regeln, die man sich hier merken sollte:

  1. 4xx-Fehler zeigst du, 5xx-Fehler verbirgst du. Der Client erhält eine allgemeine Meldung, die Details landen in den Logs.

  2. Jede Antwort enthält requestId. Meldet ein Nutzer ein Problem, nennt er die ID, und du findest in den Logs in einer Sekunde den gesamten Ablauf der Anfrage.

  3. Framework-Fehler gehen nicht verloren. Ungültiges JSON (400), ein zu großer Body (413), ein nicht unterstützter Content-Type, z. B. application/xml (415), oder ein überschrittenes Limit (429) haben einen eigenen statusCode; der Zweig status < 500 reicht sie deshalb weiter, statt sie in einen 500er zu verwandeln.

Schritt 5: Anwendung zusammensetzen, Rate Limiting und Logs

Die Funktion buildApp() setzt alles zu einem Ganzen zusammen. Dass sie von listen() getrennt ist, erlaubt es, die API über app.inject() zu testen, ohne einen Port zu öffnen.

TypeScript
// src/app.ts
import rateLimit from '@fastify/rate-limit'
import Fastify from 'fastify'
import { serializerCompiler, validatorCompiler, type ZodTypeProvider } from 'fastify-type-provider-zod'
import { registerErrorHandler } from './error-handler.js'
import { userRoutes } from './routes/users.js'

export async function buildApp() {
  const app = Fastify({
    logger: {
      level: process.env.LOG_LEVEL ?? 'info',
      redact: ['req.headers.authorization', 'req.headers.cookie'],
    },
    requestIdHeader: 'x-request-id', // ID vom Proxy/Client, falls vorhanden
    genReqId: () => crypto.randomUUID(), // andernfalls eine eigene
    trustProxy: process.env.TRUST_PROXY === '1', // nur hinter einem Reverse-Proxy
    bodyLimit: 100 * 1024, // 100 KB genügen für JSON
  }).withTypeProvider<ZodTypeProvider>()

  app.setValidatorCompiler(validatorCompiler)
  app.setSerializerCompiler(serializerCompiler)
  registerErrorHandler(app)

  await app.register(rateLimit, {
    max: 100,
    timeWindow: '1 minute',
    allowList: (request) => request.url.startsWith('/health'),
    errorResponseBuilder: (_request, context) => ({
      statusCode: context.statusCode,
      code: 'RATE_LIMITED',
      message: `Zu viele Anfragen. Bitte versuchen Sie es in ${Math.ceil(context.ttl / 1000)} s erneut.`,
    }),
  })

  app.addHook('onSend', async (request, reply) => {
    reply.header('x-request-id', request.id)
  })

  let ready = true // in der Praxis: Zustand der Verbindung zu Datenbank, Queue usw.
  app.addHook('preClose', async () => { ready = false })

  app.get('/health/live', async () => ({ status: 'ok' }))
  app.get('/health/ready', async (_request, reply) => {
    if (!ready) return reply.code(503).send({ status: 'shutting_down' })
    return { status: 'ok', uptime: Math.round(process.uptime()) }
  })

  await app.register(userRoutes, { prefix: '/api' })
  return app
}

Rate Limiting

@fastify/rate-limit arbeitet standardmäßig global. Hier kann jeder Client 100 Anfragen pro Minute stellen, und das Anlegen von Benutzern hat ein eigenes, strengeres Limit von 5 pro Minute (config.rateLimit an der Route). Health-Checks sind per allowList vom Limit ausgenommen, weil Monitoring und Orchestrator sie häufig abfragen. Das Plugin ergänzt die Header x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset und retry-after, und der 429-Fehler selbst läuft durch deinen Error-Handler.

Zwei Stolperfallen:

  • Reverse-Proxy. Hinter Nginx oder einem Load Balancer kommen alle Anfragen von derselben IP. Aktiviere trustProxy, damit Fastify X-Forwarded-For liest, aber nur, wenn tatsächlich ein Proxy vor der Anwendung steht – sonst fälscht der Client den Header und umgeht das Limit.

  • Mehrere Instanzen. Der Standardspeicher der Zähler liegt im Arbeitsspeicher des Prozesses. Bei mehreren Instanzen zählt jede für sich, binde daher über die Option redis Redis an.

Logs mit pino

Fastify protokolliert jede Anfrage und Antwort im JSON-Format, mit reqId und Antwortzeit. In Produktion lässt du JSON, weil es sich leicht durchsuchen lässt. Lokal leitest du die Ausgabe durch pino-pretty:

bash
npx tsx watch src/server.ts | npx pino-pretty

Die Option redact entfernt sensible Felder, bevor sie ins Log gelangen. Bedenke aber, dass sie auf Pfaden im Objekt arbeitet und nicht auf dem Inhalt von Meldungen. Wenn eine Datenbankbibliothek den Connection-String in error.message schreibt, maskiert redact ihn nicht.

Verwende in Handlern request.log statt console.log. Jeder Eintrag wird dann automatisch mit der Anfrage-ID verknüpft. Dank requestIdHeader wird eine ID, die ein Proxy oder ein anderer Dienst im Header x-request-id mitgibt, übernommen, sodass du eine Anfrage über mehrere Dienste hinweg verfolgen kannst.

Health-Checks

Ich trenne zwei Endpunkte:

  • /health/live (Liveness) beantwortet die Frage „Lebt der Prozess?“. Er prüft nicht die Datenbank – wenn die Datenbank ausfällt, bringt ein Neustart der API nichts.

  • /health/ready (Readiness) sagt „Kann ich Traffic annehmen?“. Hier prüfst du die Verbindung zu Datenbank oder Queue, und beim Herunterfahren gibst du 503 zurück, damit der Load Balancer keinen Traffic mehr an die Instanz leitet.

Das Flag ready setze ich im Hook preClose um, den Fastify aufruft, bevor es den Server schließt. In onClose wäre es zu spät, weil dieser Hook erst nach dem Schließen des Servers läuft. Fastify 5 antwortet während des Herunterfahrens ohnehin mit 503 auf neue Anfragen (Option return503OnClosing), aber ein eigenes Flag ist nützlich, wenn die Bereitschaft auch von der Datenbank oder einer Queue abhängt.

Schritt 6: Graceful Shutdown

Bei jedem Deployment erhält der Prozess ein SIGTERM. Beendet er sich sofort, werden laufende Anfragen abgebrochen. Die Bibliothek close-with-grace fängt Signale und unbehandelte Exceptions ab und ruft dann deine Aufräumfunktion auf.

TypeScript
// src/server.ts
import closeWithGrace from 'close-with-grace'
import { buildApp } from './app.js'

const app = await buildApp()

closeWithGrace({ delay: 10_000 }, async ({ signal, err }) => {
  if (err) app.log.error({ err }, 'Herunterfahren nach Fehler')
  else app.log.info({ signal }, 'Signal empfangen, fahre Server herunter')
  await app.close() // nimmt keine Verbindungen mehr an, wartet auf laufende Anfragen
})

try {
  await app.listen({ host: '0.0.0.0', port: Number(process.env.PORT ?? 3000) })
} catch (err) {
  app.log.fatal({ err }, 'Server konnte nicht gestartet werden')
  process.exit(1)
}

app.close() nimmt keine neuen Verbindungen mehr an, wartet auf den Abschluss laufender Anfragen und führt die onClose-Hooks aus (hier schließt du den Verbindungspool zur Datenbank). Der Parameter delay ist ein hartes Limit: Dauert das Aufräumen länger als 10 Sekunden, wird der Prozess beendet. Setze ihn unter die Zeit, die der Orchestrator gewährt – in Kubernetes beträgt der Standardwert von terminationGracePeriodSeconds 30 Sekunden.

Schritt 7: Mit curl prüfen

Starte den Server und teste die Fehlerszenarien:

bash
# Validierung: 400 mit Details
curl -s -X POST localhost:3000/api/users \
  -H 'content-type: application/json' -d '{"email":"keine-email","name":"A"}'

# falsche Kennung im Pfad: 400
curl -s localhost:3000/api/users/123

# Rate Limit: 429 mit retry-after-Header nach 5 Anfragen innerhalb einer Minute;
# das Routenlimit zählt auch von der Validierung abgewiesene Anfragen mit,
# daher erscheint nach dem Validierungstest die 429 früher
for i in $(seq 1 6); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/api/users \
    -H 'content-type: application/json' -d "{\"email\":\"u$i@example.com\",\"name\":\"User\"}"
done

Die Antwort auf einen fehlerhaften Body sieht so aus:

JSON
{
  "error": "VALIDATION_ERROR",
  "message": "Ungültige Eingabedaten",
  "details": [
    { "path": "/email", "message": "Invalid email address" },
    { "path": "/name", "message": "Too small: expected string to have >=2 characters" }
  ],
  "requestId": "e8e09d3a-cb00-437a-914a-bec1e3b4c0d2"
}

Die Meldungen von Zod sind auf Englisch. Wenn sie bei Nutzern ankommen, übergib im Schema eigene Texte – das Frontend sollte sich ohnehin auf das Feld path stützen.

Den Fehlerpfad 500 prüfe ich im Test über app.inject() mit einer temporären Route, die eine Exception wirft, und nicht mit einer Route im Produktionscode. Die Antwort enthält dann nur die allgemeine Meldung und die requestId, die Details landen im Log.

Checkliste vor dem Produktivbetrieb

  • Jede Route hat ein Schema für body, params, querystring und die Antworten.

  • Textfelder haben Längenlimits, und bodyLimit ist an den realen Bedarf angepasst.

  • Der Error-Handler gibt bei 5xx-Fehlern niemals error.message zurück.

  • Die Logs sind in JSON, enthalten reqId, und die Header authorization und cookie sind maskiert.

  • Das Rate Limit hat bei mehreren Instanzen einen gemeinsamen Speicher (Redis), und trustProxy ist nur hinter einem Proxy aktiviert.

  • Der Endpunkt für Login und Passwort-Reset hat ein eigenes, strenges Limit.

  • Liveness und Readiness sind getrennte Endpunkte, und Readiness gibt beim Herunterfahren 503 zurück.

  • Der Prozess behandelt SIGTERM und schließt die Datenbankverbindungen in onClose.

Zum Schluss

Keine dieser Maßnahmen erfordert viel Code. Am meisten bringt die Kombination aus Zod-Schemas und einem einzigen Error-Handler: Der Client erhält immer eine Antwort im gleichen Format, und die Typen in den Handlern ergeben sich aus denselben Schemas, mit denen die Eingabe validiert wird. Mit einem solchen Grundgerüst sollte man das Backend einer Web-App oder eine API für Android-Apps beginnen.

Schreiben