Tworzenie mechanizmów obsługi błędów na poziomie produkcyjnym w aplikacjach Node.js
Dowiedz się, jak klasyfikować błędy w Node.js, zaprojektować spersonalizowaną hierarchię błędów, skoncentrować obsługę błędów asynchronicznych oraz zabezpieczyć ślady stosu w celu zapewnienia odporności systemu w produkcji.
Na wczesnym etapie tworzenia odpornego aplikacji Node.js prawdopodobnie zorganizowałeś swoją bazę kodu w moduły oparte na domenie i oddzieliłeś kwestie transportowe od logiki biznesowej. Ta dyscyplina strukturalna jest ważna, ale nie ocali cię, jeśli niezłapana wyjątek lub cicho odrzucona obietnica spowoduje awarię procesu. Ponieważ Node.js uruchamia kod aplikacji w jednowątkowym pętli zdarzeń, jeden nierozwiązany błąd może doprowadzić do awarii całego procesu lub sprawić, że będzie on działał w uszkodzonym, nieprzewidywalnym stanie. Ta część koncentruje się na budowie mechanizmów obsługi błędów oraz odporności asynchronicznej odpowiedniej dla systemów produkcyjnych.
1. Błędy operacyjne vs. błędy programistyczne: podstawowa różnica
Zanim napiszesz jakąkolwiek logikę obsługi błędów, potrzebujesz jasnego modelu mentalnego, który dzieli błędy na dwie kategorie:
┌────────────────────────┐
│ Application │
│ Encountered Error │
└───────────┬────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ Operational Error │ │ Programmer Error │
├───────────────────────┤ ├───────────────────────┤
│ • Invalid Input │ │ • Syntax/Logic Bugs │
│ • Resource Not Found │ │ • Cannot read null │
│ • DB Connection Timeout│ │ • Out of memory │
│ • External API Down │ │ • Broken invariants │
└───────────┬───────────┘ └───────────┬───────────┘
│ │
▼ ▼
Handle Gracefully Log Stack Trace, Stop
(Return HTTP 4xx/5xx) Process & Let Orchestrator
(PM2/K8s) Restart Node
Błędy operacyjne
Błędy operacyjne to awarie, z którymi zdrowa aplikacja powinna od czasu do czasu się spotykać. Są to zdarzenia nieszkodliwe, wywoływane na przykład przez błędne dane wprowadzone przez użytkownika, wyłączenie API w łańcuchu przetwarzania lub rekord, który po prostu nie znajduje się w bazie danych.
- Co robić: łapać je, przekształcać w odpowiednie kody stanu lub odpowiedzi na poziomie domeny, rejestrować z właściwym stopniem powagi i pozwalać procesowi nadal obsługiwać ruch.
Błędy programistyczne
Błędy programistyczne to prawdziwe błędy – dostęp do właściwości w obiekcie undefined, przekazywanie do funkcji niewłaściwego typu danych lub stopniowe wyciek pamięci.
- Co robić: zapisz pełny ślad błędu, poinformuj system monitoringu (Sentry, Datadog itp.), zamknij proces w sposób czysty i polegaj na orkiestratorze takim jak Kubernetes, Docker Swarm lub PM2, aby uruchomić zastępczą instancję. Nigdy nie należy próbować dalej obsługiwać żądań po wystąpieniu tego typu błędu, ponieważ stan pamięci procesu nie może już być uznawany za wiarygodny.
2. Stworzenie standaryzowanej hierarchii błędów dostosowanych do potrzeb
Zwykłe instancje JavaScript Error nie zawierają potrzebnych metadanych — brakuje kodu statusu HTTP, flagi operacyjnej ani specyficznego dla domeny kodu błędu. Użycie surowych ciągów znaków lub ogólnych wywołań new Error('Coś poszło nie tak') sprawia, że obsługa błędów staje się krucha i trudna do zrozumienia.
Rozwiązanie: klasa bazowa AppError i specjalistyczne podklasy
Zamiast tego zdefiniuj jedną rozszerzalną klasę bazową AppError, która rejestruje kontekst wykonywania i zachowuje oryginalny ślad stosu V8 za pomocą Error.captureStackTrace.
// shared/errors/AppError.js
/**
* Base Application Error
* All custom domain errors extend this class.
*/
class AppError extends Error {
constructor(message, statusCode = 500, errorCode = 'INTERNAL_ERROR', isOperational = true) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = isOperational;
// Retain clean stack trace in V8 engine (Node.js)
Error.captureStackTrace(this, this.constructor);
}
}
class ValidationError extends AppError {
constructor(message = 'Invalid request payload', details = []) {
super(message, 400, 'VALIDATION_ERROR', true);
this.details = details;
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} was not found`, 404, 'NOT_FOUND', true);
}
}
class UnauthorizedError extends AppError {
constructor(message = 'Authentication required') {
super(message, 401, 'UNAUTHORIZED', true);
}
}
class SystemBugError extends AppError {
constructor(message = 'Critical system error encountered') {
// Programmer errors are marked as non-operational (isOperational = false)
super(message, 500, 'CRITICAL_BUG', false);
}
}
module.exports = {
AppError,
ValidationError,
NotFoundError,
UnauthorizedError,
SystemBugError
};
Dlaczego to ma znaczenie w logice domeny
Dzięki taka hierarchii usługi biznesowe mogą rzucać czyste, semantycznie znaczące błędy bez konieczności znajomości frameworków HTTP lub webowych:
// components/orders/orders.service.js
const { NotFoundError, ValidationError } = require('../../shared/errors/AppError');
async function cancelOrder({ orderId, userId }) {
const order = await orderRepo.findById(orderId);
if (!order) {
throw new NotFoundError('Order');
}
if (order.userId !== userId) {
throw new ValidationError('You do not have permission to cancel this order.');
}
if (order.status === 'SHIPPED') {
throw new ValidationError('Cannot cancel an order that has already shipped.');
}
return await orderRepo.updateStatus(orderId, 'CANCELLED');
}
3. Centralizowane pakowanie błędów kontrolera i middleware
Pisanie try { ... } catch (err) { next(err); } w każdym obsługiwanym szlaku generuje wiele powtarzalnego kodu, a łatwo jest zapomnieć o bloku catch w jakimś miejscu.
Zła metoda: rozbudowany szablon try-catch
// orders.controller.js
async function getOrder(req, res, next) {
try {
const order = await orderService.getOrder(req.params.id);
return res.json(order);
} catch (err) {
// Repeated in every single handler!
next(err);
}
}
Prawidłowa metoda: obsługa asynchroniczna wyższego rzędu
Zamiast tego otocz swoje kontrolery w małą funkcję pomocniczą asynchroniczną lub skorzystaj z wbudowanego wsparcia dla tras asynchronicznych dostępnego w Express 5 i nowszych wersjach:
// shared/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// orders.controller.js
const asyncHandler = require('../../shared/utils/asyncHandler');
const orderService = require('./orders.service');
// Clean, zero try/catch boilerplate
const getOrder = asyncHandler(async (req, res) => {
const order = await orderService.getOrder(req.params.id);
res.status(200).json({ status: 'success', data: order });
});
module.exports = { getOrder };
Centralny globalny middleware do obsługi błędów
Wszystkie nieprzechwycone błędy operacyjne należy przekazywać przez jeden, centralny middleware do obsługi błędów, który przekształca je na spójny format odpowiedzi JSON dla twojej API.
// shared/middleware/errorHandler.js
const { logger } = require('../logger');
const { AppError } = require('../errors/AppError');
function globalErrorHandler(err, req, res, next) {
err.statusCode = err.statusCode || 500;
err.errorCode = err.errorCode || 'INTERNAL_SERVER_ERROR';
// Log all errors internally
if (err.isOperational) {
logger.warn(`[Operational Error] ${err.name} (${err.errorCode}): ${err.message}`);
} else {
logger.error(`[CRITICAL PROGRAMMER ERROR] ${err.stack}`);
}
// Response for Operational Errors
if (err.isOperational) {
return res.status(err.statusCode).json({
status: 'error',
code: err.errorCode,
message: err.message,
...(err.details && { details: err.details })
});
}
// Generic Response for Programmer/System Failures (Hide internal stacks in production)
return res.status(500).json({
status: 'error',
code: 'INTERNAL_SERVER_ERROR',
message: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred on our server.'
: err.message
});
}
module.exports = globalErrorHandler;
4. Zachowywanie ścieżek stosu pomiędzy operacjami asynchronicznymi
Jednym z bardziej subtelnych błędów wpływających zarówno na debugowanie, jak i wydajność w Node.js jest utrata ścieżki stosu z powodu zwrócenia obietnicy wewnątrz bloku try/catch lub funkcji asynchronicznej bez uprzedniego oczekiwania na jej wykonanie.
Pułapka ścieżki stosu: return vs return await
W zwykłym JavaScript, powrót obietnicy bezpośrednio z wnętrza funkcji async — bez jej oczekiwania — pomija kontekst wywołania async tej funkcji, jeśli obietnica ostatecznie zostanie odrzucona później w łańcuchu.
Niebezpieczne: Powrót nieoczekiwanych obietnic w Try/Catch
// order.repository.js
async function findOrderById(id) {
try {
// ❌ BAD: Returning promise directly inside try block.
// If db.query fails, the catch block in THIS function will NOT execute!
return db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
Dlaczego to powoduje problemy? Ponieważ db.query zwraca obietnicę w stanie oczekiwania, findOrderById zwraca się natychmiast, przekazując tę obietnicę dalej do tego, kto ją wywołał. Zanim obietnica faktycznie zostanie odrzucona, blok catch zdefiniowany wewnątrz findOrderById nie znajduje się już na stosie wywołań i nigdy nie jest uruchamiany.
Prawidłowe: Wyraźne oczekiwanie przed zwróceniem
// order.repository.js
async function findOrderById(id) {
try {
// ✅ GOOD: Awaiting resolves or rejects WITHIN this async frame.
return await db.query('SELECT * FROM orders WHERE id = $1', [id]);
} catch (err) {
logger.error('Failed to query order database', err);
throw new CustomDatabaseError(err.message);
}
}
Jako zasada ogólna: wewnątrz każdej funkcji async, która używa try/catch, zawsze wpisz return await przy wywoływaniu zagnieżdżonej operacji asynchronicznej. Dzięki temu ramka stosu pozostaje nienaruszona, a kod służący do lokalnego czyszczenia lub logowania faktycznie zostanie wykonyany, gdy coś pójdzie nie tak.
5. Grzeczne zakończenie procesu i mechanizmy bezpieczeństwa na poziomie czasu wykonywania
Node.js udostępnia dwa hooki na poziomie procesu, które umożliwiają reakcję w sytuacjach, gdy coś umyka wszystkim innym zabezpieczeniom: uncaughtException i unhandledRejection.
Rozwiązywanie problemów na poziomie procesu
// server.js
const app = require('./app');
const { logger } = require('./shared/logger');
const PORT = process.env.PORT || 3000;
const server = app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
});
// 1. Intercept Unhandled Promise Rejections
process.on('unhandledRejection', (reason, promise) => {
logger.error('UNHANDLED REJECTION! 💥 Shutting down...', reason);
// Trigger graceful shutdown
gracefulShutdown(1);
});
// 2. Intercept Uncaught Exceptions (Programmer Errors)
process.on('uncaughtException', (error) => {
logger.error('UNCAUGHT EXCEPTION! 💥 Shutting down...', error);
// Trigger graceful shutdown immediately
gracefulShutdown(1);
});
// 3. Graceful Shutdown Flow
function gracefulShutdown(exitCode = 0) {
logger.info('Closing HTTP server and cleaning up active connections...');
server.close(async () => {
try {
// Close Database Connections, Redis clients, Message Consumers
await db.disconnect();
await redis.quit();
logger.info('All database connections closed cleanly.');
process.exit(exitCode);
} catch (err) {
logger.error('Error during shutdown:', err);
process.exit(1);
}
});
// Force shutdown after 10 seconds if connections refuse to close
setTimeout(() => {
logger.error('Forced shutdown due to timeout.');
process.exit(1);
}, 10000);
}
Lista kontrolna odporności systemu
Zanim przejdziesz do strategii testowania, sprawdź swoją bazę kodu pod kątem następujących punktów:
- Klasyfikacja błędów: czy wyraźnie oddzielasz awarie operacyjne od błędów programisty?
AppError zawierające kody stanu oraz flagę operacyjną, zamiast zwykłych ciągów znaków?return await wewnątrz bloków try/catch, aby ramy stosu asynchronicznego pozostały nienaruszone?uncaughtException i unhandledRejection, zamyka połączenia z bazą danych i wycofuje się w sposób uporządkowany, aby menedżer procesów mógł ją ponownie uruchomić?Literatura pokrewna
- 20 patterni Node.js, które zapobiegają przerwom w serwerze produkcyjnym — Poznaj 20 praktycznych patterni Node.js – od obsługi błędów po płynne wyłączanie i zarządzanie zasobami połączeń – które zapobiegają awariom, zanim konieczne stanie się ponowne uruchomienie.
- Jak process.nextTick() cicho głodzi pętlę zdarzeń Node.js — Wyjaśnia, dlaczego rekurencyjne wywołania process.nextTick() całkowicie blokują fazę poll w libuv oraz jak naprawić ten problem za pomocą setImmediate().