Strona główna / Artykuły / Tworzenie mechanizmów obsługi błędów na poziomie produkcyjnym w aplikacjach Node.js

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.

1840 słów

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?
  • Błędy strukturalne: czy wywołujesz typowane instancje AppError zawierające kody stanu oraz flagę operacyjną, zamiast zwykłych ciągów znaków?
  • Zcentralizowane obsługiwanie: czy kontrolery są otoczone asynchronicznym obsługownikiem, który kieruje błędy do wspólnego middleware do obsługi błędów?
  • Niezmienność stosu: czy używasz return await wewnątrz bloków try/catch, aby ramy stosu asynchronicznego pozostały nienaruszone?
  • Dyscyplina wyłączania: czy twoja aplikacja słucha zdarzeń 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

  • Dziewięć wzorów Promise dla niezawodnego asynchronicznego JavaScript-u w produkcji — Poznaj praktyczne wzory Promise: żądania równoległe, timeouty, ponawianie prób, ograniczenia konkurencji oraz anulowanie — aby tworzyć odporny JavaScript asynchroniczny przeznaczony do użycia w produkcji.
  • Poprawianie błędów w obsłudze błędów Async/Await w kodzie produkcyjnym Node.js — Dowiedz się o pięciu częstych błędach w obsłudze błędów async/await w JavaScript i Node.js, które powodują ciche awarie i sytuacje konkurencyjne, oraz o konkretnych sposobach ich naprawy.
  • Znajdowanie prawdziwego wąskiego gardła w spowolnionym Node.js endpoint — Poznaj systematyczną metodę śledzenia opóźnień w backendzie wzdłuż ścieżki żądania — od kodu Node.js po zapytania do bazy danych — przy użyciu pomiarów czasu i EXPLAIN ANALYZE.
  • Strategia tokenów odnowienia dla systemów autoryzacji Node.js — Dowiedz się, jak projektować, rotować, cofać oraz bezpiecznie przechowywać tokeny odnowienia w Node.js, aby kradzież tokenów i wylogowanie działały zgodnie z oczekiwaniami.
  • Strukturalne logowanie w Node.js: Przekształcanie chaosu podczas debugowania w produkcji w jasność — Dowiedz się, dlaczego console.log nie działa w aplikacjach Node.js w produkcji oraz jak strukturalne logowanie, poziomy logów i identyfikatory korelacji przekształcają trudne błędy w szybkie rozwiązania.