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
asynctrafiają do jednego error handlera – beztry/catchw 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
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:
{
"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.
// 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.
// 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 trafipasswordHash, nie wycieknie do klienta.Normalizacja w schemacie.
trim()itoLowerCase()sprawiają, że „ Jan ” i „Jan@Example.com” trafiają do bazy w jednolitej postaci.Walidacja parametrów ścieżki.
z.uuid()odrzuca/users/123z 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.
// 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ć:
Błędy 4xx pokazujesz, 5xx ukrywasz. Klient dostaje ogólny komunikat, a szczegóły lądują w logach.
Każda odpowiedź zawiera
requestId. Gdy użytkownik zgłasza problem, podaje ID, a ty w sekundę znajdujesz w logach cały przebieg żądania.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łasnystatusCode, więc gałąźstatus < 500przepuszcza 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.
// 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:
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ą.
// 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:
# 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:
{
"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,querystringi odpowiedzi.Pola tekstowe mają limity długości, a
bodyLimitjest dopasowany do realnych potrzeb.Error handler nigdy nie zwraca
error.messagedla błędów 5xx.Logi są w JSON, zawierają
reqId, a nagłówkiauthorizationicookiesą zamaskowane.Rate limit ma wspólny magazyn (Redis) przy wielu instancjach, a
trustProxyjest 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
SIGTERMi zamyka połączenia z bazą wonClose.
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.
