BlogBlog · typescript

Error handling trong TypeScript và Node.js: phân loại lỗi, custom error, Result type và xử lý ở boundary

Bộ khung error handling cho API Node.js/TypeScript: tách lỗi vận hành và bug, custom error có code và cause, middleware Express 5 trả problem+json, Result type, retry an toàn và error boundary trong Next.js.

Part of Developer Tips series
March 6, 2026
23 min read
By Leo Pham
typescriptnodejserror-handlingbackendreact
Share this post:

Ba giờ sáng, orders-api restart liên tục. Log chỉ có đúng một dòng Error: Payment failed: không có stack của lỗi gốc, không biết cổng thanh toán timeout hay mình gửi sai payload. Lần mò một hồi mới ra thủ phạm là đoạn catch (e) { throw new Error('Payment failed') }. Lỗi thật (một ECONNRESET từ upstream) bị vứt đi ngay tại chỗ.

Error handling tốt không có nghĩa là bọc try/catch khắp nơi. Nó là vài quyết định nhất quán: lỗi nào là chuyện bình thường của nghiệp vụ, lỗi nào là bug; lỗi được bắt ở đâu, log ở đâu (một lần thôi), và client nhận về cái gì. Bài này đi qua bộ khung mình dùng cho API Node.js + TypeScript và phần frontend React/Next.js, có code cho từng phần.

Phần log ở đây chỉ nói tới mức liên quan tới lỗi (giữ nguyên cause chain). Setup logging và observability đầy đủ (pino, correlation ID, metrics, tracing) nằm ở bài Logging và observability cho Node.js.

TL;DR:

  • Lỗi vận hành (input sai, upstream timeout) thì xử lý trong flow. Bug thì fail nhanh, log đủ ngữ cảnh rồi sửa code.
  • Mỗi lỗi có code cho máy đọc và cause trỏ về lỗi gốc. Đừng bao giờ wrap mà làm mất cause.
  • Express 5 tự chuyển rejection của async handler vào error middleware. Có đúng một middleware map lỗi sang HTTP status và application/problem+json (RFC 9457).
  • unhandledRejection/uncaughtException: log rồi thoát, để orchestrator restart.
  • Lỗi nghiệp vụ dự đoán được thì trả Result<T, E>, lỗi bất ngờ thì throw.

Hai loại lỗi: operational error và programmer error

Operational error là thứ chắc chắn sẽ xảy ra khi hệ thống chạy thật: user gửi email sai định dạng, đơn hàng không tồn tại, cổng thanh toán trả 503, connection tới Postgres bị reset, token hết hạn. Code không sai. Môi trường hoặc input không như ý, và mình biết trước nó sẽ xảy ra.

Programmer error là bug: đọc property của undefined, gọi hàm với sai kiểu, vi phạm một invariant mà code giả định luôn đúng. Không có cách "xử lý" đúng cho bug ngoài việc fail nhanh, log đủ ngữ cảnh và sửa code. Cố chạy tiếp với state đã hỏng (ví dụ một đơn đã thanh toán nhưng thiếu paymentId) còn nguy hiểm hơn crash.

Operational error Programmer error
Ví dụ 404 order, 422 validation, upstream timeout TypeError, invariant bị vi phạm
Nguyên nhân input, mạng, dịch vụ khác code sai
Phản hồi 4xx có ý nghĩa, 503, retry, fallback 500 chung chung, alert, sửa code
Có retry không đôi khi (timeout, 503) không bao giờ
Log level info/warn error/fatal
import assert from 'node:assert/strict'

type Order = { id: string; status: 'PENDING' | 'PAID' | 'CANCELLED'; paymentId: string | null }

export function assertOrderConsistent(order: Order): void {
  // Programmer error: nếu dòng này nổ thì đó là bug ở chỗ ghi dữ liệu, không phải lỗi của user
  if (order.status === 'PAID') {
    assert.ok(order.paymentId, `Invariant violated: paid order ${order.id} has no paymentId`)
  }
}

Quy tắc mình dùng để phân loại: hỏi "client có làm gì khác đi được khi nhận lỗi này không?". Nếu có (sửa input, đăng nhập lại, thử lại sau) thì đó là operational error và cần một response rõ ràng. Nếu không thì đó là bug: trả 500 chung chung và để alert báo cho mình.

Custom error: một hierarchy nhỏ, có code và cause

Đừng tạo 40 class lỗi. Một base class và vài subclass map thẳng ra HTTP status là đủ cho phần lớn API. Thứ quan trọng nhất là code (string ổn định để client và dashboard filter) và cause (ES2022, có từ Node 16.9) để không bao giờ mất lỗi gốc.

// src/errors.ts
export type ErrorCode =
  | 'VALIDATION_FAILED'
  | 'NOT_FOUND'
  | 'CONFLICT'
  | 'UNAUTHENTICATED'
  | 'UPSTREAM_UNAVAILABLE'

export abstract class AppError extends Error {
  abstract readonly code: ErrorCode
  abstract readonly httpStatus: number
  /** true: message an toàn để trả về cho client */
  readonly expose: boolean = true

  constructor(message: string, options?: ErrorOptions) {
    super(message, options)
    this.name = new.target.name
  }
}

export class ValidationError extends AppError {
  readonly code = 'VALIDATION_FAILED'
  readonly httpStatus = 422
  readonly fieldErrors: Record<string, string[]>

  constructor(fieldErrors: Record<string, string[]>) {
    super('Request body is invalid')
    this.fieldErrors = fieldErrors
  }
}

export class NotFoundError extends AppError {
  readonly code = 'NOT_FOUND'
  readonly httpStatus = 404

  constructor(resource: string, id: string) {
    super(`${resource} ${id} not found`)
  }
}

export class ConflictError extends AppError {
  readonly code = 'CONFLICT'
  readonly httpStatus = 409
}

export class UpstreamError extends AppError {
  readonly code = 'UPSTREAM_UNAVAILABLE'
  readonly httpStatus = 503
  override readonly expose = false
  readonly service: string

  constructor(service: string, options?: ErrorOptions) {
    super(`${service} is unavailable`, options)
    this.service = service
  }
}

export function isAppError(value: unknown): value is AppError {
  return value instanceof AppError
}

/** Chuẩn hoá mọi thứ bị throw (kể cả string, object) thành Error */
export function toError(value: unknown): Error {
  if (value instanceof Error) return value
  return new Error(`Non-Error thrown: ${String(value)}`, { cause: value })
}

Vài chi tiết đáng để ý:

  • new.target.name cho name đúng tên subclass mà không phải gán tay ở mỗi class.
  • Mình không dùng parameter properties (constructor(readonly service: string)) và enum. File này vì thế chạy được cả với tsc lẫn node src/server.ts qua type stripping (stable từ Node 25.2/24.12). Type stripping không hỗ trợ cú pháp cần transform; bật erasableSyntaxOnly trong tsconfig để tsc báo lỗi sớm.
  • ErrorOptions cần lib từ es2022 trở lên trong tsconfig.

Khi bọc lỗi từ tầng dưới, luôn truyền cause:

import { UpstreamError } from './errors.js'

type ChargeResult = { paymentId: string }

export async function chargeOrder(orderId: string, amountVnd: number): Promise<ChargeResult> {
  try {
    const res = await fetch(`${process.env.PAYMENT_API_URL}/charges`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'idempotency-key': `order-${orderId}` },
      body: JSON.stringify({ orderId, amount: amountVnd, currency: 'VND' }),
      signal: AbortSignal.timeout(5_000),
    })
    if (!res.ok) throw new Error(`payment-gateway responded ${res.status}`)
    return (await res.json()) as ChargeResult
  } catch (error) {
    // Giữ lỗi gốc (TimeoutError, TypeError: fetch failed + cause ECONNRESET...) trong cause
    throw new UpstreamError('payment-gateway', { cause: error })
  }
}

Narrow unknown trong catch

Từ TypeScript 4.4, useUnknownInCatchVariables (bật sẵn khi có strict) làm biến trong catch có kiểu unknown thay vì any. Đây là điều tốt: JavaScript cho phép throw 'abc', throw { status: 500 }, Promise.reject(undefined), nên error.message không có gì đảm bảo.

import { isAppError, toError } from './errors.js'

try {
  await chargeOrder('ord_123', 250_000)
} catch (error) {
  if (isAppError(error) && error.code === 'UPSTREAM_UNAVAILABLE') {
    // nhánh dự đoán được: đưa đơn vào hàng đợi thanh toán lại
  } else {
    throw toError(error)
  }
}

Edge case: instanceof fail khi có hai bản copy của cùng một package lỗi trong node_modules (monorepo lệch version), hoặc khi lỗi đến từ realm khác (vm, iframe). Nếu gặp, check theo code thay vì class. Với Error nói chung, Error.isError() (có trong Node 24+) kiểm tra được qua realm; nếu lib TypeScript của bạn chưa có type cho nó thì cứ dùng instanceof. Thêm rule @typescript-eslint/only-throw-error để không ai throw string nữa.

Async: floating promise, quên await, Promise.all hay allSettled

Ba lỗi async gây crash nhiều nhất mình từng gặp:

import express from 'express'

const app = express()

declare function createOrder(input: unknown): Promise<{ id: string; email: string }>
declare function sendConfirmationEmail(order: { id: string; email: string }): Promise<void>
declare function refundPayment(orderId: string): Promise<void>

// 1. Floating promise: email lỗi -> unhandledRejection -> process chết (Node 15+)
app.post('/orders', async (req, res) => {
  const order = await createOrder(req.body)
  sendConfirmationEmail(order) // không await, không catch
  res.status(201).json(order)
})

// 2. Quên await trong try: rejection thoát ra ngoài, catch không bao giờ chạy
async function cancelOrder(orderId: string) {
  try {
    return refundPayment(orderId) // phải là `return await refundPayment(orderId)`
  } catch (error) {
    throw new Error('Refund failed', { cause: error })
  }
}

Bản sửa: việc phụ không được làm hỏng request chính thì vẫn phải có catch, và đánh dấu rõ là cố ý không await. Tốt hơn nữa là đẩy vào job queue để có retry.

void sendConfirmationEmail(order).catch((error: unknown) => {
  logger.warn({ err: error, orderId: order.id }, 'confirmation email failed')
})

Đừng trông vào code review để bắt những lỗi này. Rule no-floating-promises của typescript-eslint cần type information và bắt được cả hai trường hợp trên:

// eslint.config.mjs
import eslint from '@eslint/js'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'

export default defineConfig(
  eslint.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
    },
    rules: {
      '@typescript-eslint/no-floating-promises': 'error',
      '@typescript-eslint/no-misused-promises': 'error', // bắt async callback trong forEach, event handler
      '@typescript-eslint/only-throw-error': 'error',
      '@typescript-eslint/return-await': ['error', 'in-try-catch'], // bắt lỗi số 2
    },
  },
)

Promise.all và Promise.allSettled không thay thế cho nhau:

  • Promise.all fail-fast: reject với lỗi đầu tiên. Các promise còn lại vẫn chạy tiếp (không bị huỷ), nên nếu chúng có side effect (ghi DB, gọi API) thì side effect vẫn xảy ra. Dùng khi mọi phần đều bắt buộc, ví dụ load order + customer + items để render hoá đơn.
  • Promise.allSettled chờ tất cả và trả trạng thái từng phần. Dùng khi các phần độc lập, ví dụ gửi thông báo cho 200 user hoặc load 5 widget dashboard.
declare function notifyUser(userId: string, message: string): Promise<void>

export async function broadcastMaintenance(userIds: string[], message: string) {
  const results = await Promise.allSettled(userIds.map((id) => notifyUser(id, message)))
  const failed = results.flatMap((result, i) =>
    result.status === 'rejected' ? [{ userId: userIds[i], reason: result.reason as unknown }] : [],
  )
  return { sent: userIds.length - failed.length, failed }
}

Cái giá cần nhớ: cả hai đều chạy song song không giới hạn. Với 10.000 phần tử, đó là 10.000 request cùng lúc. Hãy chia batch hoặc dùng p-limit.

Express 5: async handler và một error middleware duy nhất

Express 5 xử lý được thứ Express 4 không làm: handler hoặc middleware trả về Promise bị reject (kể cả async function throw) thì Express tự gọi next(err). Không cần express-async-errors hay wrapper asyncHandler nữa. Với code callback-style thì bạn vẫn phải tự gọi next(err).

Mọi lỗi đổ về một chỗ, và chỗ đó map sang Problem Details theo RFC 9457 (thay thế RFC 7807): media type application/problem+json với các field type, title, status, detail, instance, cộng thêm extension tuỳ ý.

// src/app.ts
import express, { type ErrorRequestHandler, type RequestHandler } from 'express'
import { isAppError, NotFoundError, ValidationError } from './errors.js'
import { logger } from './logger.js'

type Order = { id: string; totalVnd: number }
declare function findOrderById(id: string): Promise<Order | null>

export const app = express()
app.use(express.json({ limit: '100kb' }))

app.get('/orders/:id', async (req, res) => {
  const order = await findOrderById(req.params.id) // reject -> Express 5 tự gọi next(err)
  if (!order) throw new NotFoundError('order', req.params.id)
  res.json(order)
})

type Problem = {
  type: string
  title: string
  status: number
  detail?: string
  instance?: string
  code: string
  errors?: Record<string, string[]>
}

// Lỗi của body-parser/http-errors có `status` 4xx và `expose: true`
function isClientHttpError(value: unknown): value is { status: number; message: string; expose: boolean } {
  if (typeof value !== 'object' || value === null) return false
  const status = (value as { status?: unknown }).status
  return typeof status === 'number' && status >= 400 && status < 500
}

function toProblem(error: unknown, instance: string): Problem {
  if (isAppError(error)) {
    return {
      type: `/problems/${error.code.toLowerCase().replaceAll('_', '-')}`,
      title: error.code,
      status: error.httpStatus,
      detail: error.expose ? error.message : undefined,
      instance,
      code: error.code,
      ...(error instanceof ValidationError ? { errors: error.fieldErrors } : {}),
    }
  }
  if (isClientHttpError(error)) {
    return { type: 'about:blank', title: 'Bad Request', status: error.status, detail: error.message, instance, code: 'BAD_REQUEST' }
  }
  // Bug hoặc lỗi chưa phân loại: không lộ message, stack, câu SQL ra ngoài
  return { type: 'about:blank', title: 'Internal Server Error', status: 500, instance, code: 'INTERNAL' }
}

const notFoundRoute: RequestHandler = (req, _res, next) => {
  next(new NotFoundError('route', `${req.method} ${req.path}`))
}

const problemDetails: ErrorRequestHandler = (error, req, res, next) => {
  if (res.headersSent) return next(error) // đang stream dở: để Express đóng connection

  const problem = toProblem(error, req.originalUrl)
  const logContext = { err: error, method: req.method, url: req.originalUrl, status: problem.status }
  if (problem.status >= 500) logger.error(logContext, 'request failed')
  else logger.info(logContext, 'request rejected')

  res.status(problem.status).type('application/problem+json').json(problem)
}

app.use(notFoundRoute)
app.use(problemDetails) // luôn đăng ký cuối cùng

Response client nhận được (minh hoạ):

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8

{"type":"/problems/not-found","title":"NOT_FOUND","status":404,"detail":"order ord_404 not found","instance":"/orders/ord_404","code":"NOT_FOUND"}

Vì sao thiết kế như vậy:

  • res.headersSent: nếu lỗi xảy ra giữa lúc đang stream response, bạn không thể đổi status code được nữa. Delegate cho default handler của Express để nó đóng connection.
  • type có thể là URI tương đối (RFC 9457 cho phép); nếu có trang tài liệu lỗi thì dùng URL tuyệt đối trỏ tới đó.
  • Lỗi 4xx log ở info, chỉ 5xx mới là error. Nếu không, alert theo log level sẽ đầy 404 của bot quét.
  • code là extension member. Frontend switch theo code thay vì parse detail, nên đổi câu chữ không làm vỡ client.

Express 4 thì sao? Rejection trong async handler không đi vào error middleware mà thành unhandledRejection. Hãy nâng lên Express 5 hoặc bọc mọi handler bằng wrapper gọi .catch(next).

Lỗi không ai bắt: unhandledRejection và uncaughtException

Từ Node 15, mặc định --unhandled-rejections=throw: một promise bị reject mà không có handler sẽ thành uncaught exception và process thoát với code khác 0. Đó là hành vi đúng. Tài liệu Node nói rõ resume bình thường sau uncaughtException là không an toàn, vì bạn không biết state nào đã hỏng giữa chừng.

Việc của bạn ở tầng process: ghi log fatal có đủ ngữ cảnh, ngừng nhận request mới, cho request đang chạy vài giây để xong, rồi thoát. Kubernetes (restartPolicy), systemd (Restart=on-failure) hay PM2 sẽ dựng lại process sạch.

// src/crash-handlers.ts
import type { Server } from 'node:http'
import { logger } from './logger.js'

export function installCrashHandlers(server: Server, shutdownTimeoutMs = 10_000): void {
  let crashing = false

  const crash = (origin: string) => (error: unknown) => {
    logger.fatal({ err: error, origin }, 'fatal error, shutting down')
    if (crashing) return
    crashing = true
    process.exitCode = 1

    server.close(() => logger.flush(() => process.exit(1)))
    // DB pool, Redis... giữ process sống: hết timeout thì thoát cứng
    setTimeout(() => process.exit(1), shutdownTimeoutMs).unref()
  }

  process.on('uncaughtException', crash('uncaughtException'))
  process.on('unhandledRejection', crash('unhandledRejection'))
}

Edge case cần biết:

  • Khi đã đăng ký uncaughtException, Node không tự thoát nữa. Quên gọi process.exit là bạn vừa biến một crash thành một process zombie trả lỗi lung tung.
  • Nếu chỉ cần ghi log mà vẫn giữ hành vi crash mặc định, dùng process.on('uncaughtExceptionMonitor', ...). Nó chạy trước khi Node thoát và không ngăn việc thoát.
  • logger.flush() quan trọng khi bạn dùng pino transport hoặc destination async. Không flush thì dòng log quan trọng nhất (lý do chết) có thể mất.
  • server.close() ngừng nhận connection mới và chờ request đang chạy xong. Từ Node 19 nó tự đóng luôn các keep-alive connection đang idle; muốn cắt cả request đang chạy thì có server.closeAllConnections().

Result<T, E> cho lỗi nghiệp vụ dự đoán được

Throw có hai điểm yếu với lỗi nghiệp vụ: signature hàm không nói nó có thể throw gì, và caller dễ quên xử lý. Với những kết quả mà caller bắt buộc phải phân nhánh (mã giảm giá hết hạn, hết hàng, vượt hạn mức), mình trả về discriminated union:

// src/result.ts
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E }
export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value })
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error })
// src/coupons.ts
import { err, ok, type Result } from './result.js'

type Coupon = { code: string; percentOff: number; minOrderVnd: number; expiresAt: Date }

export type CouponError =
  | { type: 'COUPON_NOT_FOUND' }
  | { type: 'COUPON_EXPIRED'; expiredAt: Date }
  | { type: 'MIN_ORDER_NOT_MET'; minOrderVnd: number }

export function applyCoupon(
  coupon: Coupon | undefined,
  orderTotalVnd: number,
  now = new Date(),
): Result<{ discountVnd: number }, CouponError> {
  if (!coupon) return err({ type: 'COUPON_NOT_FOUND' })
  if (coupon.expiresAt <= now) return err({ type: 'COUPON_EXPIRED', expiredAt: coupon.expiresAt })
  if (orderTotalVnd < coupon.minOrderVnd) return err({ type: 'MIN_ORDER_NOT_MET', minOrderVnd: coupon.minOrderVnd })
  return ok({ discountVnd: Math.floor((orderTotalVnd * coupon.percentOff) / 100) })
}

export function couponMessage(e: CouponError): string {
  switch (e.type) {
    case 'COUPON_NOT_FOUND':
      return 'Mã giảm giá không tồn tại'
    case 'COUPON_EXPIRED':
      return `Mã đã hết hạn lúc ${e.expiredAt.toISOString()}`
    case 'MIN_ORDER_NOT_MET':
      return `Đơn tối thiểu ${e.minOrderVnd.toLocaleString('vi-VN')}đ`
    default: {
      const unreachable: never = e // thêm case mới mà quên xử lý -> lỗi compile
      return unreachable
    }
  }
}

Quy tắc chọn:

  • Dùng Result khi lỗi là một phần của domain, caller nào cũng phải xử lý, và bạn muốn compiler nhắc khi thêm loại lỗi mới.
  • Throw khi lỗi là bất ngờ (DB chết, bug) hoặc caller ngay phía trên không làm được gì ngoài đẩy lên tiếp. Bắt Result đi qua 6 tầng chỉ để tới error middleware là boilerplate vô ích.
  • Không bọc Result quanh mọi hàm async. Một Promise<Result<...>> vẫn có thể reject vì lỗi hạ tầng, và điều đó ổn: tầng boundary đã lo phần đó.

Edge case: đừng để E là Error chung chung. Result<T, Error> không cho caller biết thêm gì so với throw. Sức mạnh nằm ở union có type cụ thể.

Retry: backoff + jitter, chỉ cho thao tác idempotent

Retry sai chỗ biến một sự cố nhỏ thành sự cố lớn: 500 pod cùng retry đúng sau 1s, 2s, 4s sẽ đập upstream đang yếu thành sập hẳn. Hai nguyên tắc: thêm jitter để các client tản ra, và chỉ retry thao tác idempotent (GET, PUT theo id, hoặc POST có Idempotency-Key mà server hỗ trợ).

// src/fetch-with-retry.ts
import { setTimeout as sleep } from 'node:timers/promises'
import { UpstreamError } from './errors.js'

type RetryOptions = {
  service: string
  idempotent: boolean
  retries?: number
  baseDelayMs?: number
  maxDelayMs?: number
  attemptTimeoutMs?: number
  signal?: AbortSignal // signal của caller, ví dụ request đã bị client huỷ
}

const RETRIABLE_STATUS = new Set([408, 429, 502, 503, 504])

export async function fetchWithRetry(url: string, init: RequestInit, opts: RetryOptions): Promise<Response> {
  const { retries = 3, baseDelayMs = 200, maxDelayMs = 5_000, attemptTimeoutMs = 3_000 } = opts
  const maxAttempts = opts.idempotent ? retries + 1 : 1
  let lastError: unknown

  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const signals = [AbortSignal.timeout(attemptTimeoutMs), ...(opts.signal ? [opts.signal] : [])]
    try {
      const res = await fetch(url, { ...init, signal: AbortSignal.any(signals) })
      if (res.ok || !RETRIABLE_STATUS.has(res.status)) return res

      lastError = new Error(`${opts.service} responded ${res.status}`)
      await res.body?.cancel() // trả connection về pool
      const retryAfterSec = Number(res.headers.get('retry-after'))
      if (attempt < maxAttempts - 1) {
        await sleep(backoff(attempt, baseDelayMs, maxDelayMs, retryAfterSec), undefined, { signal: opts.signal })
      }
    } catch (error) {
      if (opts.signal?.aborted) throw error // caller huỷ: dừng ngay, không retry
      lastError = error // TimeoutError hoặc TypeError: fetch failed (xem error.cause.code)
      if (attempt < maxAttempts - 1) {
        await sleep(backoff(attempt, baseDelayMs, maxDelayMs), undefined, { signal: opts.signal })
      }
    }
  }
  throw new UpstreamError(opts.service, { cause: lastError })
}

// "Full jitter": random trong [0, min(max, base * 2^attempt)]
function backoff(attempt: number, baseMs: number, maxMs: number, retryAfterSec = Number.NaN): number {
  if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) return Math.min(retryAfterSec * 1_000, maxMs)
  return Math.random() * Math.min(maxMs, baseMs * 2 ** attempt)
}

Những chỗ dễ sai:

  • AbortSignal.timeout() là timeout mỗi lần thử. fetch không có timeout mặc định ngắn, nên thiếu nó thì một upstream treo sẽ giữ request của bạn rất lâu. AbortSignal.any() (Node 20.3+) gộp nó với signal của caller.
  • Timeout khi POST không idempotent nghĩa là bạn không biết server đã xử lý hay chưa. Retry lúc đó có thể trừ tiền khách hai lần. Vì vậy hàm trên chỉ thử một lần khi idempotent: false.
  • Retry ở nhiều tầng sẽ nhân lên: client retry 3 lần × gateway 3 lần × service 3 lần = 27 request cho một click. Chỉ retry ở một tầng, thường là tầng gần upstream nhất.
  • Lỗi 4xx (trừ 408/429) không bao giờ retry. Gửi lại đúng payload sai thì vẫn sai.

Frontend: React error boundary, error.tsx và global-error.tsx

Error boundary bắt lỗi trong lúc render, trong lifecycle và constructor của cây con. Nó không bắt lỗi trong event handler, setTimeout, promise tự chạy, hay lỗi khi SSR. Với React thuần (ví dụ app Vite), mình dùng react-error-boundary thay vì tự viết class component:

// src/components/OrderPanel.tsx
import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'
import { OrderDetails } from './OrderDetails'
import { reportClientError } from '../lib/report-client-error'

function OrderFallback({ error, resetErrorBoundary }: FallbackProps) {
  const message = error instanceof Error ? error.message : 'Unknown error'
  return (
    <div role="alert">
      <p>Không tải được đơn hàng.</p>
      {import.meta.env.DEV && <pre>{message}</pre>}
      <button onClick={resetErrorBoundary}>Thử lại</button>
    </div>
  )
}

export function OrderPanel({ orderId }: { orderId: string }) {
  return (
    <ErrorBoundary
      FallbackComponent={OrderFallback}
      onError={(error, info) => reportClientError(error, { componentStack: info.componentStack })}
      resetKeys={[orderId]} // đổi order thì tự reset boundary
    >
      <OrderDetails orderId={orderId} />
    </ErrorBoundary>
  )
}

Lỗi trong event handler thì bắt bằng try/catch rồi đặt vào state, hoặc chuyển cho boundary gần nhất qua showBoundary của hook useErrorBoundary. Từ React 19, createRoot(container, { onCaughtError, onUncaughtError }) là chỗ tập trung để gửi lỗi lên Sentry hay dịch vụ tương tự.

Trong Next.js App Router, error.tsx là một error boundary cho route segment. Phải là Client Component. Từ Next.js 16.3, prop retry là API stable để re-fetch và render lại segment; 16.2 có nó dưới tên unstable_retry, bản cũ hơn chỉ có reset. error có kiểu unknown và phải narrow trước khi đọc.

// app/orders/[id]/error.tsx
'use client'

import { useEffect } from 'react'

export default function OrderError({ error, retry }: { error: unknown; retry: () => void }) {
  const digest =
    typeof error === 'object' && error !== null && 'digest' in error ? String(error.digest) : undefined

  useEffect(() => {
    // Production: lỗi từ Server Component chỉ có message chung + digest
    console.error('order page failed', { digest })
  }, [digest])

  return (
    <div role="alert">
      <h2>Không tải được đơn hàng</h2>
      {digest && <p>Mã lỗi: {digest}</p>}
      <button onClick={() => retry()}>Thử lại</button>
    </div>
  )
}
// app/global-error.tsx
'use client'

export default function GlobalError({ retry }: { error: unknown; retry: () => void }) {
  return (
    // global-error thay thế root layout nên phải tự có <html> và <body>
    <html lang="vi">
      <body>
        <h2>Hệ thống đang gặp sự cố</h2>
        <button onClick={() => retry()}>Tải lại</button>
      </body>
    </html>
  )
}

Ba điều hay bị bỏ qua:

  1. error.tsx không bọc layout.tsx cùng cấp. Lỗi trong root layout chỉ global-error.tsx bắt được, và file này không có global CSS hay font của bạn.
  2. Ở production, message của lỗi Server Component bị thay bằng message chung để không lộ thông tin. digest là cầu nối: hiển thị nó cho user, rồi tìm đúng digest đó trong log server. Phía server, export onRequestError trong instrumentation.ts (Next.js 15+) để gửi lỗi đi kèm routePath và routeType.
  3. Lỗi dự đoán được trong Server Action (validation, hết hàng) nên được trả về như một giá trị (đúng tinh thần Result ở trên) để form hiển thị, thay vì throw rồi rơi vào error.tsx.

Log lỗi một lần, giữ nguyên cause chain

Nguyên tắc: log ở boundary (error middleware, crash handler, job runner), không log ở mỗi tầng rồi throw tiếp. Log-and-rethrow ở 4 tầng cho bạn 4 dòng log cho cùng một lỗi, và không dòng nào đủ ngữ cảnh.

Với pino, truyền lỗi dưới key err. Serializer err mặc định gộp message và stack của cause vào lỗi ngoài cùng, nhưng bỏ các property khác của cause (như code: 'ECONNRESET'). Muốn giữ cause thành object lồng nhau, đổi sang errWithCause:

// src/logger.ts
import pino from 'pino'

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  serializers: { err: pino.stdSerializers.errWithCause },
})

Output (minh hoạ, đã rút gọn stack):

{"level":50,"msg":"request failed","status":503,"err":{"type":"UpstreamError","message":"payment-gateway is unavailable","code":"UPSTREAM_UNAVAILABLE","service":"payment-gateway","stack":"UpstreamError: payment-gateway is unavailable\n    at chargeOrder (src/payments.ts:18:11)","cause":{"type":"TypeError","message":"fetch failed","stack":"TypeError: fetch failed\n    at ...","cause":{"type":"Error","message":"read ECONNRESET","code":"ECONNRESET"}}}}

Nhìn dòng này là biết ngay: lỗi không nằm ở logic thanh toán mà ở kết nối TCP bị reset. Đó chính là thông tin bị mất trong câu chuyện 3 giờ sáng ở đầu bài. Redact, correlation ID và cách ship log đi đâu có ở bài logging và observability.

Những lỗi thường gặp

Nuốt lỗi bằng catch rỗng hoặc return null:

// Sai: lỗi DB biến thành "không tìm thấy", user nhận 404 cho một sự cố 500
async function getInvoice(id: string) {
  try {
    return await db.invoice.findUniqueOrThrow({ where: { id } })
  } catch {
    return null
  }
}

// Đúng: chỉ chuyển đổi đúng loại lỗi mình hiểu, còn lại để nó bay lên
async function getInvoice(id: string) {
  const invoice = await db.invoice.findUnique({ where: { id } })
  if (!invoice) throw new NotFoundError('invoice', id)
  return invoice
}

Wrap làm mất stack và cause:

// Sai
catch (e) { throw new Error((e as Error).message) }
// Đúng
catch (error) { throw new UpstreamError('inventory-service', { cause: error }) }

Trả nguyên lỗi cho client:

// Sai: lộ câu SQL, tên bảng, đôi khi cả connection string
res.status(500).json({ error: String(err), stack: (err as Error).stack })
// Đúng: client nhận problem+json chung chung, chi tiết nằm trong log

forEach với async callback: orders.forEach(async (o) => await sync(o)) không chờ gì cả, lỗi thành unhandled rejection. Dùng for...of với await, hoặc Promise.allSettled(orders.map(sync)). Rule no-misused-promises bắt được lỗi này.

Retry POST không có idempotency key: timeout không có nghĩa là thất bại. Hoặc gửi Idempotency-Key mà server hỗ trợ, hoặc đừng retry.

Dùng process.on('uncaughtException') để "giữ server sống": process tiếp tục chạy với state không xác định (transaction dở, lock chưa nhả). Log rồi thoát.

Checklist

  • Có một AppError base với code, httpStatus, expose, và mọi chỗ wrap lỗi đều truyền cause.
  • tsconfig bật strict (kéo theo useUnknownInCatchVariables), lỗi trong catch được narrow trước khi dùng.
  • ESLint bật no-floating-promises, no-misused-promises, only-throw-error, return-await với type information.
  • Đã lên Express 5 (hoặc mọi async handler được bọc .catch(next)), và chỉ có một error middleware trả application/problem+json.
  • Response 5xx không chứa message, stack hay SQL; 4xx có code ổn định cho frontend.
  • Crash handler log fatal, flush log, đóng server có timeout rồi exit(1).
  • Lỗi nghiệp vụ mà caller bắt buộc xử lý được mô hình hoá bằng Result với union có type.
  • Mọi call ra ngoài có AbortSignal.timeout; retry chỉ cho thao tác idempotent, có jitter, ở đúng một tầng.
  • Next.js có error.tsx cho các segment quan trọng, global-error.tsx có <html>/<body>, và onRequestError gửi lỗi server đi.
  • Logger dùng errWithCause và lỗi chỉ được log một lần ở boundary.

Đọc thêm

Last updated: March 6, 2026

Comments