Работа с ошибками является неотъемлемой частью разработки программного обеспечения.

Определение и наличие четких рекомендаций по обработке ошибок облегчит вашу жизнь при разработке функций, а также, и, что более важно, когда что-то пойдет не так!

В Orus (где мы пытаемся заново изобрести профессиональное страхование) со временем мы разработали стратегию ошибок, которая хорошо работает для нас и которой, по нашему мнению, было бы полезно поделиться.

Хотя этот пост в основном посвящен обработке ошибок в TypeScript, некоторые из принципов, которые мы рассмотрим, довольно общие и применимы и к другим языкам.

Без лишних слов, вот наши 5 заповедей обработки ошибок:

  • №1. Убедитесь, что ошибки… ну… ошибки
  • №2: не теряйте трассировку стека
  • №3: используйте постоянные сообщения об ошибках
  • №4. Предоставьте нужный контекст
  • №5: не выдавать ошибки для ожидаемых проблем

Это вызвало у вас интерес? Если да, читайте дальше!

Заповедь № 1: Убедитесь, что ошибки — это… ошибки

В великолепном мире JavaScript вы можете не знать, но вы можете выбрасывать что угодно, не только Error экземпляров.

function throwNumber() {
  throw 123
}

try {
  throwNumber()
} catch (err) {
  console.log(err) // 123
}

Хотя это весело и допускает некоторые умные варианты использования (например, грядущие React Suspenses построены на том факте, что мы можем бросать обещания), это не очень хорошая идея! Правда есть несколько вопросов:

  • Трассировка стека не подключена, поэтому выброшенная «ошибка» теряет большую часть своей полезности.
  • На стороне вызывающей стороны почти всегда ожидаются фактические Error экземпляра. Нередко можно увидеть наивное использование err.message в дикой природе без предварительной проверки типа err.

Чтобы убедиться, что вы пуленепробиваемы в этом вопросе, прежде всего, всегда throw Errors в вашей кодовой базе. Иметь в виду это правило — это здорово, но применять его с помощью правила ESLint (например, https://typescript-eslint.io/rules/no-throw-literal/) — еще лучше!

Что еще более важно: прежде чем использовать перехваченную ошибку, убедитесь, что это действительно Error. Для этого убедитесь, что вы включили флаг useUnknownInCatchVariables в своем tsconfig.json (обратите внимание, что он включен по умолчанию, если вы находитесь в режиме strict). Это сделает переменную ошибки в блоке catch unknown вместо any.

try {
  runFragileOperation()
} catch (err) { // err is `unknown`
  console.log(err.message) // this will fail because we're not checking the `err` type
}

Это большой шаг вперед: мы не можем неправильно использовать ошибку, не уточнив сначала ее тип. Однако из коробки использовать его нецелесообразно:

try {
  runFragileOperation()
} catch (err) {
  if (err instanceof Error) console.log(err.message)

  // what do you do if err is not an `Error`?
}

Постоянно проверять тип ошибки с блоком if в блоке catch неудобно. Более того, что делать, если это не Error?

Решение, которое мы придумали в команде Orus, состоит в том, чтобы иметь функцию ensureError, которая гарантирует, что ошибка является Error. Если это не так, выброшенное значение заворачивается в Error. Реализация выглядит так:

function ensureError(value: unknown): Error {
  if (value instanceof Error) return value

  let stringified = '[Unable to stringify the thrown value]'
  try {
    stringified = JSON.stringify(value)
  } catch {}

  const error = new Error(`This value was thrown as is, not through an Error: ${stringified}`)
  return error
}

Вооружившись этой функцией, манипулировать перехваченными ошибками будет проще:

try {
  runFragileOperation()
} catch (err) {
  const error = ensureError(err)

  console.log(error.message)
}

Это дает несколько преимуществ:

  • Он обрабатывает абсолютно все, что может быть выброшено, и пока значение является JSON-строкой, никакая информация не теряется.
  • При необходимости он создает экземпляр Error, добавляя трассировку стека как можно раньше. Таким образом, ошибка может быть легко распространена с помощью наиболее подходящей трассировки стека.
  • Использование выглядит намного чище: с одной короткой строкой вы уверены, что манипулируете Error. Поскольку это должно использоваться в каждом блоке catch, это очень важно.

Эта простая функция значительно упростила наш код обработки ошибок.

Заповедь № 2: не теряйте трассировку стека

Можете ли вы найти проблему в этом фрагменте?

try {
  runFragileOperation()
} catch (err) {
  const error = ensureError(err)

  throw new Error(`Running fragile operation failed: ${error.message}`)
}

Хорошо, название дало это довольно рано: мы теряем трассировку стека исходной ошибки.

Это не кажется чем-то большим, но наличие как можно большего количества информации может очень помочь при отладке проблемы.

Хотя сохранение трассировки стека («сцепление» ошибок) было довольно сложным в старом добром JavaScript, вам повезло: начиная с Node.js 16.9.0 и доступное в большинстве браузеров с середины 2021 года, свойство cause позволяет вам прикрепите «исходную» ошибку к Error обратно совместимым способом:

const error1 = new Error("Network error")
const error2 = new Error("The update failed", { cause: error1 })

console.log(error2)

/* Prints:

Error: The update failed
    at REPL2:1:16
    at Script.runInThisContext (node:vm:129:12)
    ... 7 lines matching cause stack trace ...
    at [_line] [as _line] (node:internal/readline/interface:892:18) {
  [cause]: Error: Network error
      at REPL1:1:16
      at Script.runInThisContext (node:vm:129:12)
      at REPLServer.defaultEval (node:repl:572:29)
      at bound (node:domain:433:15)
      at REPLServer.runBound [as eval] (node:domain:444:12)
      at REPLServer.onLine (node:repl:902:10)
      at REPLServer.emit (node:events:525:35)
      at REPLServer.emit (node:domain:489:12)
      at [_onLine] [as _onLine] (node:internal/readline/interface:422:12)
      at [_line] [as _line] (node:internal/readline/interface:892:18)
*/

Как показано, исходная ошибка полностью сохраняется вместе с трассировкой стека. Обратите внимание, что это не ограничивается только одной ошибкой, причина ошибки может иметь причину, которая может иметь причину, которая может иметь причину… Вы поняли идею!

Если вам интересно, почему мы выдаем new Error вместо того, чтобы просто повторно выдать исходную ошибку, возьмем следующий пример:

try {
  runFragileOperation()
} catch (err) {
  const error = ensureError(err)

  if (!config.fallbackEnabled) throw error

  try {
    runFallback()
  } catch {
    // for the sake of this example, we discard the `runFallback`
    // stack trace on purpose, but in course on production we shouldn't!
    throw error
  }
}

По сравнению с выбрасыванием новых ошибок мы теряем некоторые преимущества:

  • Сообщение об ошибке может быть менее понятным. Вы, вероятно, предпочтете, чтобы ваша функция завершилась ошибкой с красивым Calling API failed, а не с более неясным read ECONNRESET.
  • Мы не знаем, что в итоге бросил throw. Это тот из фолбека или другой? Результирующая трассировка стека будет выглядеть точно так же, чего не было бы, если бы мы выдали новую ошибку.

Заповедь №3: Используйте постоянные сообщения об ошибках

Большинство платформ мониторинга и отслеживания ошибок анализируют сообщения об ошибках, чтобы определить, являются ли ошибки частыми. Возьмем следующий пример:

try {
  await logRequest(requestId, { elapsedTime })
} catch (err) {
  const error = ensureError(err)

  throw new Error(`Could not log request with ID "${requestId}"`, { cause: error })
}

Представьте, что logRequest записывает запрос в базу данных, а база данных дает сбой. Если из-за этого 1000 запросов не выполняются, будет 1000 различных сообщений об ошибках:

Could not log request with ID "9r7S8_ZoobNwRKafaVeP7"
Could not log request with ID "v2zGvKj-JVdFg_vjJyUP1"
Could not log request with ID "CaU_eS8olPcbbxfIPiUWN"
[...]

Это будет проблематично, потому что:

  • Если вы небольшая компания, ваша платформа мониторинга, вероятно, предупредит вас всякий раз, когда произойдет одна непредвиденная ошибка. Вы получите 1000 предупреждений
  • Если вы крупная компания, и ваша платформа мониторинга предупреждает вас о достижении определенного порога возникновения, причем каждое сообщение об ошибке уникально, вы никогда не будете уведомлены.

Решение этой проблемы состоит в том, чтобы убедиться, что ваши сообщения об ошибках постоянны. Данные переменной должны быть установлены как часть контекста ошибки (или метаданных или любого другого имени, которое вы предпочитаете). Из соображений безопасности типов вы захотите создать свой собственный подкласс Error:

type Jsonable = string | number | boolean | null | undefined | readonly Jsonable[] | { readonly [key: string]: Jsonable } | { toJSON(): Jsonable }

export class BaseError extends Error {
  public readonly context?: Jsonable

  constructor(message: string, options: { error?: Error, context?: Jsonable } = {}) {
    const { cause, context } = options

    super(message, { cause })
    this.name = this.constructor.name

    this.context = context
  }
}

Теперь вы можете прикрепить контекст к ошибке, убедившись, что ваша ошибка сериализуема в JSON благодаря типу Jsonable. Приведенная выше ошибка теперь будет выглядеть так:

throw new BaseError('Could not log request', { cause: error, context: { requestId } })

После этого группировка ошибок будет вести себя так, как ожидалось, а сообщение об ошибке всегда будет одним и тем же! Для отладки у вас по-прежнему будет доступ к соответствующим данным в context.

Заповедь № 4: Обеспечьте правильное количество контекста

Это небольшое, но, тем не менее, важное: прикрепите соответствующие данные к контексту вашей ошибки. Предоставление слишком малого или слишком большого контекста затруднит отладку проблем. Возьмем следующий пример:

try {
  await billCustomer(customer.id, quote.amount)
} catch (err) {
  const error = ensureError(err)

  throw new BaseError('Could not bill customer', {
    cause: error,
    context: { customer, quote }
  })
}

Здесь, вероятно, слишком много контекста: скорее всего, для отладки вам понадобятся только customer.id и quote.amount. В этом случае отладка проблемы потребует от вас изучения всего контекста, который может быть огромным (например, в цитате может быть много информации). Кроме того, что, если в customer есть конфиденциальные данные? Наличие личной информации вашего клиента на вашей платформе мониторинга ошибок, вероятно, не то, что вам нужно.

В этом случае предоставления customerId и quoteAmount было бы достаточно.

Заповедь № 5: не выдавать ошибки за проблемы, которые ожидаются

Из-за природы TypeScript выбрасывание ошибок следует рассматривать как крайнюю меру, то есть то, что трудно или невозможно исправить.

Действительно, в отличие от других языков, в TypeScript невозможно гарантировать, что возникающие ошибки будут обработаны. Это связано с тем, что выбрасываемые типы ошибок не являются частью сигнатуры функции.

Как тогда мы можем гарантировать, что ошибки будут обработаны? Что ж, ошибки должны быть частью сигнатуры функции. Вы можете добиться этого, например, используя шаблон Result, заимствованный из Rust, который прост, но довольно эффективен:

type Result<T, E extends BaseError = BaseError> = { success: true, result: T } | { success: false, error: E }

С помощью этого типа теперь вы можете выразить тот факт, что функция может дать сбой. Например, функция, обращающаяся к серверу API, будет выглядеть так:

type ApiResponse = { data: string }

export async function fetchDataFromApi(): Result<ApiResponse> {
  try {
    const result = await fetch('https://api.local')

    // for the sake of this snippet we don't do it
    // but we should validate `result` matches the expected format

    return { success: true, result}
  } catch (err) {
    const error = ensureError(err)
    
    return { success: false, error }
  }
}

Теперь невозможно получить доступ к данным из API, не убедившись сначала, что это было успешно. Также обратите внимание, что эта функция никогда не будет вызывать исключение.

Приведенный выше пример немного грубый и многословный для использования, но он максимально упрощен для демонстрации. На практике существуют отличные библиотеки, предоставляющие хорошие API для эффективной и более элегантной работы с Result, но это выходит за рамки данной статьи.

Короче говоря, чтобы убедиться, что ваши пользователи получают наилучший возможный опыт, задайте себе вопрос: «Вероятно, эта функция выйдет из строя?». Если это так, использование шаблона Result, вероятно, является хорошей идеей, так как у вас есть гарантия, что случай сбоя будет обработан, поэтому ваши пользователи увидят красивое сообщение или элегантный запасной вариант вместо сбоя.

Это все для этой статьи! Я надеюсь, что эти советы, которые мы собирали в течение долгого времени в Orus, будут в какой-то степени вам полезны.

Не стесняйтесь реагировать на эту статью, если у вас есть какие-либо вопросы или отзывы, и я с радостью отвечу!