Аксиос и обработка ошибок как босс 😎


Введение

Мне очень нравится подход «проблема/решение». Мы видим какую-то проблему, а затем действительно хорошее решение. Но для этого разговора, я думаю, нам нужно некоторое введение.

Когда вы разрабатываете веб-приложение, вы обычно хотите разделить фронтенд и бэкенд. Для этого вам нужно что-то, что обеспечит связь между ними.

Для примера, вы можете построить фронтенд (обычно называемый GUI или пользовательский интерфейс), используя ванильный HTML, CSS и Javascript, или, часто, используя несколько фреймворков, таких как Vue, React и многие другие, доступные в Интернете. Я отметил Vue, потому что это мое личное предпочтение.

Почему? Я действительно не изучал другие настолько глубоко, что не могу заверить вас, что Vue — лучший, но мне понравилось, как он работает, синтаксис и так далее. Это как ваша влюбленность, это личный выбор.

Но, помимо этого, какой бы фреймворк вы ни использовали, вы столкнетесь с одной и той же проблемой: _как общаться с бэкендом_ (который может быть написан на стольких языках, что я не осмелюсь упомянуть некоторые. Мое нынешнее увлечение? Python и Flask).

Одним из решений является использование AJAX (Что такое AJAX? Асинхронный JavaScript и XML). Вы можете использовать XMLHttpRequest напрямую, чтобы делать запросы к бэкенду и получать нужные вам данные, но недостатком является то, что код получается многословным. Вы можете использовать Fetch API, который сделает абстракцию поверх XMLHttpRequest, с мощным набором инструментов. Другим важным изменением является то, что Fetch API будет использовать Promises, избегая обратных вызовов из XMLHttpRequest (предотвращая ад обратных вызовов).

В качестве альтернативы у нас есть потрясающая библиотека Axios, которая имеет хороший API (для любопытства, под капотом используется XMLHttpRequest, что обеспечивает очень широкую поддержку браузеров). Axios API оборачивает XMLHttpRequest в Promises, отличающийся от Fetch API. Кроме того, в настоящее время Fetch API хорошо поддерживается доступными движками браузеров и имеет полифиллы для старых браузеров. Я не буду обсуждать, кто из них лучше, потому что я действительно думаю, что это личное предпочтение, как и любая другая библиотека или фреймворк. Если у вас нет своего мнения, я предлагаю вам найти несколько сравнений и глубоко погрузиться в статьи. У меня есть хорошая статья, которую я упомяну, написанная Фаразом Келхини.

Мой личный выбор — Axios, потому что у него хороший API, есть таймаут ответа, автоматическое преобразование JSON, перехватчики (мы будем использовать их в предлагаемом решении) и многое другое. Нет ничего, что нельзя было бы реализовать с помощью Fetch API, но есть другой подход.

Проблема

Говоря о Axios, простой GET HTTP запрос может быть сделан с помощью этих строк кода:

import axios from 'axios'

//here we have an generic interface with basic structure of a api response:
interface HttpResponse<T> {
  data: T[]
}

// the user interface, that represents a user in the system
interface User {
  id: number
  email: string
  name: string
}

//the http call to Axios
axios.get<HttpResponse<User>>('/users').then((response) => {
  const userList = response.data
  console.log(userList)
})
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы использовали Typescript (интерфейсы и generics), модули ES6, Promises, Axios и Arrow Functions. Мы не будем глубоко их затрагивать и предположим, что вы уже знаете о них.

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

У нас, разработчиков, есть миссия:

Сделать жизнь пользователей проще!

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

Axios, как и Fetch API, использует Promises для обработки асинхронных вызовов и избежания обратных вызовов, о которых мы упоминали ранее. Promises — это действительно хороший API и не слишком сложный для понимания. Мы можем выстраивать цепочки действий (then) и обработчиков ошибок (catch) друг за другом, и API будет вызывать их по порядку. Если в Promise произойдет ошибка, будет найден и выполнен ближайший catch.

Таким образом, код выше с базовым обработчиком ошибок станет:

import axios from 'axios'

//..here go the types, equal above sample.

//here we call axios and passes generic get with HttpResponse<User>.
axios
  .get<HttpResponse<User>>('/users')
  .then((response) => {
    const userList = response.data
    console.log(userList)
  })
  .catch((error) => {
    //try to fix the error or
    //notify the users about somenthing went wrong
    console.log(error.message)
  })
Войти в полноэкранный режим Выход из полноэкранного режима

Хорошо, и в чем же тогда проблема? Ну, у нас есть сотня ошибок, которые в каждом вызове API имеют одно и то же решение/сообщение. Ради любопытства Axios показывает нам их небольшой список: ERR_FR_TOO_MANY_REDIRECTS, ERR_BAD_OPTION_VALUE, ERR_BAD_OPTION, ERR_NETWORK, ERR_DEPRECATED, ERR_BAD_RESPONSE, ERR_BAD_REQUEST, ERR_CANCELED, ECONNABORTED, ETIMEDOUT. У нас есть HTTP Status Codes, где мы находим множество ошибок, например 404 (Page Not Found), и так далее. В общем, вы поняли. У нас слишком много общих ошибок, чтобы элегантно обрабатывать их в каждом запросе API.

Очень уродливое решение

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

function httpErrorHandler(error) {
  if (error === null) throw new Error('Unrecoverable error!! Error is null!')
  if (axios.isAxiosError(error)) {
    //here we have a type guard check, error inside this if will be treated as AxiosError
    const response = error?.response
    const request = error?.request
    const config = error?.config //here we have access the config used to make the api call (we can make a retry using this conf)

    if (error.code === 'ERR_NETWORK') {
      console.log('connection problems..')
    } else if (error.code === 'ERR_CANCELED') {
      console.log('connection canceled..')
    }
    if (response) {
      //The request was made and the server responded with a status code that falls out of the range of 2xx the http status code mentioned above
      const statusCode = response?.status
      if (statusCode === 404) {
        console.log('The requested resource does not exist or has been deleted')
      } else if (statusCode === 401) {
        console.log('Please login to access this resource')
        //redirect user to login
      }
    } else if (request) {
      //The request was made but no response was received, `error.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in Node.js
    }
  }
  //Something happened in setting up the request and triggered an Error
  console.log(error.message)
}
Вход в полноэкранный режим Выход из полноэкранного режима

С нашей волшебной функцией «badass» на месте, мы можем использовать ее вот так:

import axios from 'axios'

axios
  .get('/users')
  .then((response) => {
    const userList = response.data
    console.log(userList)
  })
  .catch(httpErrorHandler)
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы должны помнить о необходимости добавления этого catch в каждый вызов API, и для каждой новой ошибки, которую мы можем любезно обработать, мы должны увеличить наш мерзкий httpErrorHandler с некоторым количеством кода и уродливыми if's.

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

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

Элегантное и рекомендуемое решение

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

С другой стороны, если сам код может решать эти проблемы на общем уровне, делайте это! Разработчики не могут совершать ошибки, если им ничего не нужно делать!

Прежде чем мы перейдем к коду (а именно этого мы ожидаем от этой статьи), мне нужно кое-что рассказать, чтобы вы поняли, что делают коды.

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

У нас есть два типа Interceptors. До (запрос) и после (ответ) вызова AJAX.

Их использование очень простое:

//Intercept before request is made, usually used to add some header, like an auth
const axiosDefaults = {}
const http = axios.create(axiosDefaults)
//register interceptor like this
http.interceptors.request.use(
  function (config) {
    // Do something before request is sent
    const token = window.localStorage.getItem('token') //do not store token on localstorage!!!
    config.headers.Authorization = token
    return config
  },
  function (error) {
    // Do something with request error
    return Promise.reject(error)
  }
)
Войти в полноэкранный режим Выход из полноэкранного режима

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

Простым использованием перехватчика ответа является вызов нашей большой уродливой функции для обработки всех видов ошибок.

Как и любой другой автоматический обработчик, нам нужен способ обойти его (отключить), когда мы захотим. Мы расширим интерфейс AxiosRequestConfig и добавим две опциональные опции raw и silent. Если raw имеет значение true, мы ничего не будем делать. silent служит для отключения уведомлений, которые мы показываем при работе с глобальными ошибками.

declare module 'axios' {
  export interface AxiosRequestConfig {
    raw?: boolean
    silent?: boolean
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

export class HttpError extends Error {
  constructor(message?: string) {
    super(message) // 'Error' breaks prototype chain here
    this.name = 'HttpError'
    Object.setPrototypeOf(this, new.target.prototype) // restore prototype chain
  }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь давайте напишем перехватчики:

// this interceptor is used to handle all success ajax request
// we use this to check if status code is 200 (success), if not, we throw an HttpError
// to our error handler take place.
function responseHandler(response: AxiosResponse<any>) {
  const config = response?.config
  if (config.raw) {
    return response
  }
  if (response.status == 200) {
    const data = response?.data
    if (!data) {
      throw new HttpError('API Error. No data!')
    }
    return data
  }
  throw new HttpError('API Error! Invalid status code!')
}

function responseErrorHandler(response) {
  const config = response?.config
  if (config.raw) {
    return response
  }
  // the code of this function was written in above section.
  return httpErrorHandler(response)
}

//Intercept after response, usually to deal with result data or handle ajax call errors
const axiosDefaults = {}
const http = axios.create(axiosDefaults)
//register interceptor like this
http.interceptors.response.use(responseHandler, responseErrorHandler)
Вход в полноэкранный режим Выход из полноэкранного режима

Нам не нужно помнить о нашей волшебной функции badass в каждом вызове ajax. И мы можем отключить ее, когда захотим, просто передав raw в конфиг запроса.

import axios from 'axios'

// automagically handle error
axios
  .get('/users')
  .then((response) => {
    const userList = response.data
    console.log(userList)
  })
  //.catch(httpErrorHandler) this is not needed anymore

// to disable this automatic error handler, pass raw
axios
  .get('/users', {raw: true})
  .then((response) => {
    const userList = response.data
    console.log(userList)
  }).catch(() {
    console.log("Manually handle error")
  })
Вход в полноэкранный режим Выход из полноэкранного режима

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

Можем ли мы улучшить ее еще больше? О да.

Усовершенствованное и элегантное решение

Мы разработаем класс Registry, используя паттерн проектирования Registry. Класс позволит вам регистрировать обработку ошибок по ключу (мы углубимся в это в ближайшее время) и действию, которое может быть строкой (сообщением), объектом (который может делать некоторые неприятные вещи) или функцией, которая будет выполняться, когда ошибка соответствует ключу. Реестр будет иметь родителя, которого можно поместить, чтобы позволить вам переопределять ключи для пользовательских сценариев обработки.

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

// this interface is the default response data from ours api
interface HttpData {
  code: string
  description?: string
  status: number
}

// this is all errrors allowed to receive
type THttpError = Error | AxiosError | null

// object that can be passed to our registy
interface ErrorHandlerObject {
  after?(error?: THttpError, options?: ErrorHandlerObject): void
  before?(error?: THttpError, options?: ErrorHandlerObject): void
  message?: string
  notify?: QNotifyOptions
}

//signature of error function that can be passed to ours registry
type ErrorHandlerFunction = (error?: THttpError) => ErrorHandlerObject | boolean | undefined

//type that our registry accepts
type ErrorHandler = ErrorHandlerFunction | ErrorHandlerObject | string

//interface for register many handlers once (object where key will be presented as search key for error handling
interface ErrorHandlerMany {
  [key: string]: ErrorHandler
}

// type guard to identify that is an ErrorHandlerObject
function isErrorHandlerObject(value: any): value is ErrorHandlerObject {
  if (typeof value === 'object') {
    return ['message', 'after', 'before', 'notify'].some((k) => k in value)
  }
  return false
}
Вход в полноэкранный режим Выход из полноэкранного режима

Итак, с типами разобрались, давайте посмотрим на реализацию класса. Мы будем использовать Map для хранения объектов/ключей и родителя, которого мы будем искать, если ключ не найден в текущем классе. Если parent равен null, поиск будет завершен. При конструировании мы можем передать родителя и, по желанию, экземпляр ErrorHandlerMany, чтобы зарегистрировать некоторые обработчики.

class ErrorHandlerRegistry {
  private handlers = new Map<string, ErrorHandler>()

  private parent: ErrorHandlerRegistry | null = null

  constructor(parent: ErrorHandlerRegistry = undefined, input?: ErrorHandlerMany) {
    if (typeof parent !== 'undefined') this.parent = parent
    if (typeof input !== 'undefined') this.registerMany(input)
  }

  // allow to register an handler
  register(key: string, handler: ErrorHandler) {
    this.handlers.set(key, handler)
    return this
  }

  // unregister a handler
  unregister(key: string) {
    this.handlers.delete(key)
    return this
  }

  // search a valid handler by key
  find(seek: string): ErrorHandler | undefined {
    const handler = this.handlers.get(seek)
    if (handler) return handler
    return this.parent?.find(seek)
  }

  // pass an object and register all keys/value pairs as handler.
  registerMany(input: ErrorHandlerMany) {
    for (const [key, value] of Object.entries(input)) {
      this.register(key, value)
    }
    return this
  }

  // handle error seeking for key
  handleError(
    this: ErrorHandlerRegistry,
    seek: (string | undefined)[] | string,
    error: THttpError
  ): boolean {
    if (Array.isArray(seek)) {
      return seek.some((key) => {
        if (key !== undefined) return this.handleError(String(key), error)
      })
    }
    const handler = this.find(String(seek))
    if (!handler) {
      return false
    } else if (typeof handler === 'string') {
      return this.handleErrorObject(error, { message: handler })
    } else if (typeof handler === 'function') {
      const result = handler(error)
      if (isErrorHandlerObject(result)) return this.handleErrorObject(error, result)
      return !!result
    } else if (isErrorHandlerObject(handler)) {
      return this.handleErrorObject(error, handler)
    }
    return false
  }

  // if the error is an ErrorHandlerObject, handle here
  handleErrorObject(error: THttpError, options: ErrorHandlerObject = {}) {
    options?.before?.(error, options)
    showToastError(options.message ?? 'Unknown Error!!', options, 'error')
    return true
  }

  // this is the function that will be registered in interceptor.
  resposeErrorHandler(this: ErrorHandlerRegistry, error: THttpError, direct?: boolean) {
    if (error === null) throw new Error('Unrecoverrable error!! Error is null!')
    if (axios.isAxiosError(error)) {
      const response = error?.response
      const config = error?.config
      const data = response?.data as HttpData
      if (!direct && config?.raw) throw error
      const seekers = [
        data?.code,
        error.code,
        error?.name,
        String(data?.status),
        String(response?.status),
      ]
      const result = this.handleError(seekers, error)
      if (!result) {
        if (data?.code && data?.description) {
          return this.handleErrorObject(error, {
            message: data?.description,
          })
        }
      }
    } else if (error instanceof Error) {
      return this.handleError(error.name, error)
    }
    //if nothings works, throw away
    throw error
  }
}
// create ours globalHandlers object
const globalHandlers = new ErrorHandlerRegistry()
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте углубимся в код resposeErrorHandler. Мы решили использовать key в качестве идентификатора для выбора лучшего обработчика ошибки. Когда вы смотрите на код, вы видите, что в нем есть порядок, по которому key будет искаться в реестре. Правило таково: искать от самого специфического к самому общему.

const seekers = [
  data?.code, //Our api can send an error code to you personalize the error messsage.
  error.code, //The AxiosError has an error code too (ERR_BAD_REQUEST is one).
  error?.name, //Error has a name (class name). Example: HttpError, etc..
  String(data?.status), //Our api can send an status code as well.
  String(response?.status), //respose status code. Both based on Http Status codes.
]
Вход в полноэкранный режим Выход из полноэкранного режима

Это пример ошибки, отправленной API:

{
  "code": "email_required",
  "description": "An e-mail is required",
  "error": true,
  "errors": [],
  "status": 400
}
Вход в полноэкранный режим Выход из полноэкранного режима

Другой пример:

{
  "code": "no_input_data",
  "description": "You doesnt fill input fields!",
  "error": true,
  "errors": [],
  "status": 400
}
Войти в полноэкранный режим Выход из полноэкранного режима

Итак, в качестве примера мы можем зарегистрировать нашу общую обработку ошибок:

globalHandlers.registerMany({
  //this key is sent by api when login is required
  login_required: {
    message: 'Login required!',
    //the after function will be called when the message hides.
    after: () => console.log('redirect user to /login'),
  },
  no_input_data: 'You must fill form values here!',
  //this key is sent by api on login error.
  invalid_login: {
    message: 'Invalid credentials!',
  },
  '404': { message: 'API Page Not Found!' },
  ERR_FR_TOO_MANY_REDIRECTS: 'Too many redirects.',
})

// you can registre only one:
globalHandlers.register('HttpError', (error) => {
  //send email to developer that api return an 500 server internal console.error
  return { message: 'Internal server errror! We already notify developers!' }
  //when we return an valid ErrorHandlerObject, will be processed as whell.
  //this allow we to perform custom behavior like sending email and default one,
  //like showing an message to user.
})
Вход в полноэкранный режим Выход из полноэкранного режима

Мы можем зарегистрировать обработчик ошибок в любом месте, сгруппировать самые общие в одном файле typescript, а специфические — в строке. Выбирайте сами. Но, чтобы это работало, нам нужно прикрепить к нашему экземпляру http axios. Это делается следующим образом:

function createHttpInstance() {
  const instance = axios.create({})
  const responseError = (error: any) => globalHandlers.resposeErrorHandler(error)
  instance.interceptors.response.use(responseHandler, responseError)
  return instance
}

export const http: AxiosInstance = createHttpInstance()
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем делать ajax-запросы, и обработчик ошибок будет работать, как ожидалось:

import http from '/src/modules/http'

// automagically handle error
http.get('/path/that/dont/exist').then((response) => {
  const userList = response.data
  console.log(userList)
})
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Настройка для одного http-вызова

На этом решение не заканчивается. Предположим, что в одном, только одном http-запросе, вы хотите обрабатывать 404 по-разному, но только 404. Для этого мы создадим функцию dealsWith, приведенную ниже:

export function dealWith(solutions: ErrorHandlerMany, ignoreGlobal?: boolean) {
  let global
  if (ignoreGlobal === false) global = globalHandlers
  const localHandlers = new ErrorHandlerRegistry(global, solutions)
  return (error: any) => localHandlers.resposeErrorHandler(error, true)
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Эта функция использует родительский ErrorHandlerRegistry для персонализации одной клавиши, но для всех остальных использует глобальные обработчики (если вы хотели этого, ignoreGlobal есть, чтобы этого не делать).

Итак, мы можем написать код следующим образом:

import http from '/src/modules/http'

// this call will show the message 'API Page Not Found!'
http.get('/path/that/dont/exist')

// this will show custom message: 'Custom 404 handler for this call only'
// the raw is necessary because we need to turn off the global handler.
http.get('/path/that/dont/exist', { raw: true }).catch(
  dealsWith({
    404: { message: 'Custom 404 handler for this call only' },
  })
)

// we can turn off global, and handle ourselves
// if is not the error we want, let the global error take place.
http
  .get('/path/that/dont/exist', { raw: true })
  .catch((e) => {
    //custom code handling
    if (e.name == 'CustomErrorClass') {
      console.log('go to somewhere')
    } else {
      throw e
    }
  })
  .catch(
    dealsWith({
      404: { message: 'Custom 404 handler for this call only' },
    })
  )
Войти в полноэкранный режим Выйти из полноэкранного режима

Заключительные размышления

Все эти объяснения хороши, но код, ах, код, намного лучше. Поэтому я создал репозиторий на github со всем кодом из этой статьи, чтобы вы могли его опробовать, улучшить и настроить.

  • Нажмите здесь, чтобы получить доступ к репозиторию на github.

СНОСКИ:

  • Этот пост получился намного больше, чем я предполагал, но я люблю делиться своими мыслями.
  • Если у вас есть какие-то улучшения в коде, пожалуйста, дайте мне знать в комментариях.
  • Если вы видите, что что-то не так, пожалуйста, исправьте меня!

Оцените статью
devanswers.ru
Добавить комментарий