Skip to content
All posts

15 October 202610 min read

API w Node.js, które się nie wysypie: Fastify, walidacja, błędy i limity

Szkielet API w TypeScript i Fastify 5, który od pierwszego dnia obsługuje złe dane, nieprzewidziane wyjątki, zalew żądań i restart przy wdrożeniu: Zod, jeden error handler, rate limiting, logi pino, health checki i łagodne zamykanie.

API w Node.js, które się nie wysypie: Fastify, walidacja, błędy i limity

Większość awarii API nie wynika z egzotycznych bugów, tylko z rzeczy przyziemnych: ktoś wysłał null zamiast obiektu, nieobsłużony wyjątek zwrócił klientowi stack trace z hasłem do bazy, bot zasypał endpoint logowania tysiącami żądań, a restart przy deployu uciął połowę trwających zapytań. Poniżej pokazuję szkielet API w TypeScript i Fastify, który od pierwszego dnia obsługuje te sytuacje: walidację z Zod, centralną obsługę błędów, rate limiting, ustrukturyzowane logi, health checki i łagodne zamykanie.

Dlaczego Fastify

Wybieram Fastify, bo kilka jego cech przy takim API naprawdę pomaga:

  • walidacja i serializacja oparte na schematach są wbudowane, a nie doklejone middlewarem,

  • logger pino jest częścią frameworka i każde żądanie dostaje własne ID,

  • system pluginów z enkapsulacją ułatwia dzielenie aplikacji na moduły,

  • błędy rzucone w handlerach async trafiają do jednego error handlera – bez try/catch w każdej trasie.

Przykłady są przygotowane pod Fastify 5 i Zod 4. Kod z tego wpisu sprawdziłem 28.09.2026 w kontenerze z Node.js 22 (Fastify 5.12, Zod 4.6, fastify-type-provider-zod 7.0, @fastify/rate-limit 11.2): kompiluje się bez błędów, a scenariusze z końca wpisu dają opisane odpowiedzi.

Krok 1: projekt i zależności

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

Minimalny tsconfig.json:

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

Przy NodeNext importy lokalnych plików piszesz z rozszerzeniem .js (np. ./errors.js), mimo że źródło to .ts – TypeScript mapuje je na skompilowane pliki. Do developmentu użyj tsx watch src/server.ts, a na produkcji tsc i node dist/server.js.

Krok 2: własne błędy domenowe

Najpierw ustalam, jak wyglądają „spodziewane” błędy: brak zasobu, konflikt, brak uprawnień. Zamiast zwracać odpowiedzi z głębi logiki biznesowej, rzucasz wyjątek z kodem HTTP i kodem maszynowym.

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} nie istnieje`)
export const conflict = (message: string) => new AppError(409, 'CONFLICT', message)

Kod maszynowy (NOT_FOUND, CONFLICT) jest dla frontendu – na nim opiera się logika, a nie na treści komunikatu, który możesz dowolnie zmieniać albo tłumaczyć.

Krok 3: walidacja z Zod

Biblioteka fastify-type-provider-zod robi dwie rzeczy: zamienia schematy Zod w walidator i serializator Fastify oraz podpowiada typy w handlerach. Dzięki temu request.body ma typ wyprowadzony ze schematu, a nie 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>>() // w prawdziwym API: baza danych

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('Użytkownik o tym adresie już istnieje')
    }
    const user = { id: crypto.randomUUID(), email, name }
    users.set(user.id, user)
    request.log.info({ userId: user.id }, 'Utworzono użytkownika')
    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('Użytkownik')
    return user
  })
}

Kilka rzeczy wartych uwagi:

  • Schemat odpowiedzi to też zabezpieczenie. Serializator wysyła tylko pola zdefiniowane w User. Jeśli kiedyś do obiektu trafi passwordHash, nie wycieknie do klienta.

  • Normalizacja w schemacie. trim() i toLowerCase() sprawiają, że „ Jan ” i „Jan@Example.com” trafiają do bazy w jednolitej postaci.

  • Walidacja parametrów ścieżki. z.uuid() odrzuca /users/123 z kodem 400, zanim zapytanie dotknie bazy.

  • Limity długości. Każde pole tekstowe powinno mieć max(). Bez tego ktoś wyśle ci imię o długości 90 KB.

Typ FastifyPluginAsyncZod sprawia, że wewnątrz pluginu nie musisz ponownie wywoływać withTypeProvider().

Krok 4: centralny error handler

To serce całego rozwiązania. Każdy błąd – z walidacji, z twojego kodu, z pluginu czy z samego Fastify – przechodzi przez jedno miejsce i dostaje spójny format odpowiedzi.

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: 'Nieprawidłowe dane wejściowe',
        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) {
      // błędy klienta z Fastify i pluginów: 413, 415, 429 itd.
      return reply.code(status).send({
        error: error.code ?? 'CLIENT_ERROR',
        message: error.message,
        requestId: request.id,
      })
    }

    request.log.error({ err: error }, 'Nieobsłużony błąd')
    return reply.code(500).send({
      error: 'INTERNAL_ERROR',
      message: 'Coś poszło nie tak. Spróbuj ponownie później.',
      requestId: request.id,
    })
  })

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

Zasady, które warto tu zapamiętać:

  1. Błędy 4xx pokazujesz, 5xx ukrywasz. Klient dostaje ogólny komunikat, a szczegóły lądują w logach.

  2. Każda odpowiedź zawiera requestId. Gdy użytkownik zgłasza problem, podaje ID, a ty w sekundę znajdujesz w logach cały przebieg żądania.

  3. Błędy frameworka nie giną. Nieprawidłowy JSON (400), za duże body (413), nieobsługiwany Content-Type, np. application/xml (415) czy przekroczony limit (429) mają własny statusCode, więc gałąź status < 500 przepuszcza je dalej zamiast zamieniać w 500.

Krok 5: składanie aplikacji, rate limiting i logi

Funkcja buildApp() składa wszystko w całość. Oddzielenie jej od listen() pozwala testować API przez app.inject() bez otwierania portu.

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 z proxy/klienta, jeśli jest
    genReqId: () => crypto.randomUUID(), // w przeciwnym razie własne
    trustProxy: process.env.TRUST_PROXY === '1', // tylko za reverse proxy
    bodyLimit: 100 * 1024, // 100 KB wystarczy na JSON-a
  }).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: `Za dużo żądań. Spróbuj ponownie za ${Math.ceil(context.ttl / 1000)} s.`,
    }),
  })

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

  let ready = true // w praktyce: stan połączenia z bazą, kolejką itd.
  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 domyślnie działa globalnie. Tu każdy klient może wykonać 100 żądań na minutę, a tworzenie użytkownika ma własny, ostrzejszy limit 5 na minutę (config.rateLimit w trasie). Health checki są wyłączone z limitu przez allowList, bo monitoring i orkiestrator odpytują je często. Plugin dodaje nagłówki x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-reset i retry-after, a sam błąd 429 przechodzi przez twój error handler.

Dwie pułapki:

  • Reverse proxy. Za Nginx albo load balancerem wszystkie żądania przychodzą z tego samego IP. Włącz trustProxy, żeby Fastify czytał X-Forwarded-For, ale tylko wtedy, gdy przed aplikacją naprawdę stoi proxy – inaczej klient podrobi nagłówek i ominie limit.

  • Wiele instancji. Domyślny magazyn liczników jest w pamięci procesu. Przy kilku instancjach każda liczy osobno, więc podłącz Redis przez opcję redis.

Logi z pino

Fastify loguje każde żądanie i odpowiedź w formacie JSON, z reqId i czasem odpowiedzi. Na produkcji zostaw JSON, bo łatwo go przeszukiwać. Lokalnie przepuść wyjście przez pino-pretty:

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

Opcja redact usuwa wrażliwe pola, zanim trafią do logu. Pamiętaj jednak, że działa na ścieżkach w obiekcie, a nie na treści komunikatów. Jeśli biblioteka bazy danych wrzuci connection string do error.message, redact go nie zamaskuje.

W handlerach używaj request.log, a nie console.log. Każdy wpis zostanie wtedy automatycznie powiązany z ID żądania. Dzięki requestIdHeader ID podane przez proxy albo inny serwis w nagłówku x-request-id jest przejmowane, więc możesz śledzić żądanie przez kilka usług.

Health checki

Rozdzielam dwa endpointy:

  • /health/live (liveness) odpowiada na pytanie „czy proces żyje?”. Nie sprawdza bazy – jeśli baza leży, restart API nic nie da.

  • /health/ready (readiness) mówi „czy mogę przyjmować ruch?”. Tu sprawdzasz połączenie z bazą czy kolejką, a podczas zamykania zwracasz 503, żeby load balancer przestał kierować ruch do instancji.

Flagę ready przestawiam w hooku preClose, który Fastify wywołuje, zanim zacznie zamykać serwer. W onClose byłoby za późno, bo ten hook działa już po zamknięciu serwera. Fastify 5 i tak odpowiada 503 na nowe żądania w trakcie zamykania (opcja return503OnClosing), ale własna flaga przydaje się, gdy gotowość zależy też od bazy albo kolejki.

Krok 6: graceful shutdown

Przy każdym deployu proces dostaje SIGTERM. Jeśli zakończy się od razu, trwające żądania zostaną zerwane. Biblioteka close-with-grace przechwytuje sygnały i nieobsłużone wyjątki, a potem wywołuje twoją funkcję sprzątającą.

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 }, 'Zamykanie po błędzie')
  else app.log.info({ signal }, 'Otrzymano sygnał, zamykam serwer')
  await app.close() // przestaje przyjmować połączenia, czeka na trwające żądania
})

try {
  await app.listen({ host: '0.0.0.0', port: Number(process.env.PORT ?? 3000) })
} catch (err) {
  app.log.fatal({ err }, 'Nie udało się uruchomić serwera')
  process.exit(1)
}

app.close() przestaje przyjmować nowe połączenia, czeka na zakończenie trwających żądań i uruchamia hooki onClose (tu zamykasz pulę połączeń z bazą). Parametr delay to twardy limit: jeśli sprzątanie potrwa dłużej niż 10 sekund, proces zostanie zakończony. Ustaw go poniżej czasu, jaki daje orkiestrator – w Kubernetesie domyślny terminationGracePeriodSeconds to 30 sekund.

Krok 7: sprawdź to curl-em

Uruchom serwer i przetestuj scenariusze awaryjne:

bash
# walidacja: 400 ze szczegółami
curl -s -X POST localhost:3000/api/users \
  -H 'content-type: application/json' -d '{"email":"nie-email","name":"A"}'

# zły identyfikator w ścieżce: 400
curl -s localhost:3000/api/users/123

# rate limit: 429 z nagłówkiem retry-after po 5 żądaniach w ciągu minuty;
# limit trasy liczy też żądania odrzucone przez walidację, więc po teście
# walidacji 429 pojawi się wcześniej
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

Odpowiedź dla błędnego body wygląda tak:

JSON
{
  "error": "VALIDATION_ERROR",
  "message": "Nieprawidłowe dane wejściowe",
  "details": [
    { "path": "/email", "message": "Invalid email address" },
    { "path": "/name", "message": "Too small: expected string to have >=2 characters" }
  ],
  "requestId": "e8e09d3a-cb00-437a-914a-bec1e3b4c0d2"
}

Komunikaty z Zod są po angielsku. Jeśli trafiają do użytkowników, przekaż własne teksty w schemacie – frontend i tak powinien opierać się na polu path.

Ścieżkę błędu 500 sprawdzam w teście przez app.inject(), z tymczasową trasą rzucającą wyjątek, a nie trasą w kodzie produkcyjnym. Odpowiedź zawiera wtedy tylko ogólny komunikat i requestId, a szczegóły trafiają do logu.

Lista kontrolna przed produkcją

  • Każda trasa ma schemat dla body, params, querystring i odpowiedzi.

  • Pola tekstowe mają limity długości, a bodyLimit jest dopasowany do realnych potrzeb.

  • Error handler nigdy nie zwraca error.message dla błędów 5xx.

  • Logi są w JSON, zawierają reqId, a nagłówki authorization i cookie są zamaskowane.

  • Rate limit ma wspólny magazyn (Redis) przy wielu instancjach, a trustProxy jest włączony tylko za proxy.

  • Endpoint logowania i resetu hasła ma osobny, ostry limit.

  • Liveness i readiness to osobne endpointy, a readiness zwraca 503 podczas zamykania.

  • Proces obsługuje SIGTERM i zamyka połączenia z bazą w onClose.

Na koniec

Żadna z tych rzeczy nie wymaga dużo kodu. Najwięcej daje połączenie schematów Zod z jednym error handlerem: klient zawsze dostaje odpowiedź w tym samym formacie, a typy w handlerach wynikają z tych samych schematów, którymi walidowane jest wejście. Od takiego szkieletu warto zaczynać backend aplikacji webowej albo API dla aplikacji na Androida.

Write