Howtojs

Обработка ошибок в JavaScript и Node.js

5 февраля 2021 г. • ☕️☕️☕️ 16 мин.

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

Категории ошибок

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

Ошибки программы (Exceptions/Исключения)

Ошибки, которые происходят во время работы программы, не являющиеся багами.

  • ошибка при чтении файла (отсутствие прав на чтение или файла на диске)
  • ошибка при отправке сетевого запроса (отутствие сетевого подключения)
  • закончилась выделенная память
  • ответ HTTP сервера с кодом 500
  • парсинг JSON

Такие ошибки больше похожи на условия if/else и являются частью корректной программы. Поэтому они так же называются Exceptions (Исключения) или исключительные ситуации.

Что если файл не найден? Его можно создать

Что если сервер вернул код 500? Запрос можно повторить

Ошибки программиста (Errors)

Это баги в программе, которые можно исправить, изменив код. Они сообщают нам о том, что код написан не корректно и их нельзя правильно обработать.

  • передача аргумента не того типа в функцию
  • попытка прочитать несуществующее свойство объекта
  • обращение к несуществующей переменной
  • вызов несуществующей функции

Если ты опечатался при обращении к свойству объекта, то единственный путь это исправить - изменить код. Такое поведение не получится правильно обработать.

Перспектива взгляда на ошибки

Ошибки программы и программиста тесно связанны между собой. При обращение к несуществующему свойству, обработчик запроса не сервере падает и возвращает ответ с кодом 500.

Для сервера, это ошибка программиста.

Для клиента, это ошибка программы.

Всегда стоит рассматривать ошибки в текущем контексте.

Обработка ошибок программы (Exception)

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

Например при HTTP запросе на сервер, мы знаем, что кроме успешного ответа с кодом 200, можно получить ответ с кодом 500, либо ошибку сети.

Здесь важно перестроить свое мышление таким образом, чтобы при написании кода, всегда задавать себе вопрос, может ли здесь что-то пойти не так и что именно?

При этом мы не всегда должны обрабатывать все возможные сценарии, нужно разделять то, что надо обработать здесь и сейчас, а что можно делегировать на обработку на уровень выше.

Что именно можно сделать с ошибкой программы?

Обработать ее на месте

Мы хотим сохранить статью для блога в базу данных. В нашей таблице поле slug является уникальным. При попытке сохранить мы получили ошибку с сообщением Article with this slug already exists. Мы можем прямо на месте, добавить в наше значение slug парочку уникальных символов и сохранить еще раз.

async function saveArticle(db, article) {
  try {
    await db.saveArticle(article)
  } catch (err) {
    if (err.code === "slug_uniq") {      // обработать ошибку
      article.slug = generateUniqSlug(article.slug)
      await db.saveArticle(article)
    } else {
      // делегировать ошибку на уровень выше
      throw err
    }
  }
}

Попробовать повторить операцию

Тот же самый пример с блогом, но в этот раз мы вызываем API с клиента. У нас произошло переподключение к другой сети Wi-Fi и запрос завершился с ошибкой. Мы можем повторить операцию несколько n раз. Ведь проблема с сетью решиться в течении очень короткого времени.

function withRetry(fn, {times }) {
  // ...
}

async function saveArticleWithRetry(article) {
  await withRetry(() => api.saveArticle(article), { times: 10 })}

Передать ее на клиент

Что если пропал доступ к базе данных? Клиент не может ждать вечно, поэтому мы можем отменить текущую операцию и ответить, например с кодом 503.

async function saveArticle(db, article) {
  try {
    await db.saveArticle(article)
  } catch (err) {
    if (err.code === "slug_uniq") {
      article.slug = generateUniqSlug(article.slug)
      await db.saveArticle(article)
    } else if (err.code === "db_not_responding") {      // обработать ошибку
      return HttpError({code: 503})
    } else {
      throw err
    }
  }
}

Записать ошибку в логи

Мы не можем знать всех возможных ситуаций. Что если произошла ошибка к который мы не были готовы? Записать ошибку в логи является отличной практикой. Так мы сможем узнать о неудавшихся операциях и возможно придумать сценарий обработки на месте для таких ошибок в будующем.

Кроме логов, так же есть различные сервисы для составления отчетов об ошибках, например Sentry

Завершить процесс

Некоторые ошибки, которые возможны в редких ситуациях имеют один исход - завершить процесс.

Например мы пытаемся запустить наш веб сервер на порту 80, который уже занят другим сервисом. Залоггировать ошибку и остановить процесс - будет лучшим решением.

Обработка ошибок программиста

Здесь мало что можно сделать, так как код изначально написан, чтобы падать.

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

Лучший способ обработать эту ошибку - это создать отчет и уничтожить данный процесс/операцию, чтобы в последствии исправить код.

Здесь опять же поможет правильно настроенное логгирование либо сервисы для отчета об ошибках. Так же для анализа утечки памяти может помочь дамп памяти heapdump.

Cпособы возврата ошибки при написании функций

throw - “выбросить” ошибку или сгенерировать исключение

Для синхронных функций нужно использовать throw, это самый простой способ. Тот, кто вызывает функцию, всегда сможет обработать ошибку с помощью try/catch.

function checkUserName(username) {
  if (username.length > 50) {
    throw new Error("Username is not valid.")  }
  // ...more code
}

При использовании async/await в асинхронных функциях, тоже стоит использовать throw, так как это позволит обработать ошибку “сверху” с помощью try/catch при использовании ключевого слова await, либо метода .catch при использовании Promise.

async function checkUserName(db, username) {
  const user = await db.getUser(username)

  if (user) {
    throw new Error("Username must be uniq.")  }
}

Передать ошибку в callback для ее обработки

В асинхронных функциях с callback мы должны принимать callback последним аргументом, и передавать туда ошибку первым параметром callback(err, null), либо callback(null, result) в случае успеха.

function checkUserName(db, username, callback) {
  db.getUser(username, (err, user) => {
    // в этом случае в callback только один параметр, так как нас не интересует результат в случае успеха
    if (err) {
      return callback(err)
    }

    if (user) {
      return callback(new Error("Username must be uniq."))    }

    callback(null)
  })
}

Передать ошибку в функцию reject у Promise

Функции возвращающие Promise должны вызывать reject функцию с ошибкой. Так, вызывающий нашу функцию код, сможет перехватить ошибку с помощью try/catch при использовании ключевого слова await либо метода .catch при использовании Promise.

function checkUserName(db, username) {
  return new Promise((resolve, reject) => {
    db.getUser(username, (err, user) => {
      if (err) {
        return reject(err)
      }

      if (user) {
        return reject(new Error("Username must be uniq."))      }
      resolve()
    })
  })
}

Сгенерировать (emit) событие error у EventEmitter

В более сложных ситуациях, когда нужно сообщить о множестве результатов, ошибок или других событий, функция возвращает EventEmitter

eventEmitter.emit("error", new Error("unexpected error"))

Чаще всего это используется при моделировании каких либо сложных процессов, либо автоматов (state machine).

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

const fs = require("fs")

const fileReadStream = fs.createReadStream("./file.txt")

fileReadStream.on("data", chunk => console.log(`process chunk: ${chunk}`))
fileReadStream.on("end", () => console.log("finished"))
fileReadStream.on("error", () => console.error(err))

Рекомендации при написании функций

Определить, что именно и как делает функция

  • какие аргументы она принимает
  • какие типы могут быть у этих аргументов
  • специфичная валидация аргументов (например строка должна быть url)
  • что возращает функция
  • какие ошибки программы могут быть и как их обработать

Использовать наследование для создания собственных ошибок

class HttpError extends Error {
   name = "HttpError"
}

class NotFoundError extends HttpError {  name = "NotFoundError"
  statusCode = 404
}

Использовать поле name для корректного вывода имени в отчетах

class HttpError extends Error {
   name = "HttpError"
}

class NotFoundError extends HttpError {
  name = "NotFoundError"  statusCode = 404
}

Добавлять дополнительную информацию, которая может быть полезна

class HttpError extends Error {
   name = "HttpError"
}

class NotFoundError extends HttpError {
  name = "NotFoundError"
  statusCode = 404}

Писать понятные сообщения об ошибке

new NotFoundError("User not found.")

Создавать обертки для низкоуровеных ошибок

async function readFile(path, opts) {
  try {
    await fs.readFile(path, opts)
  } catch (err) {
    if (err.code === "ENOENT") {
      throw new FileNotFoundError(`File: ${path} not found`, {original: err})    }
    throw err
  }
}

Итог

  • задавайте вопрос, что здесь может пойти не так?
  • различайте ошибки по типам в зависимости от контекста
  • обарабатывайте ошибки в зависимости от типа и происхождения
  • обращайте внимание на возврат ошибок при проектировании функций