LoginSignup
106
58

axiosのerror handling

Last updated at Posted at 2021-05-31

はじめに

JavaScript(Node.js)でHTTP通信をする上で便利なライブラリであるaxiosであるが、そのエラーハンドリングで躓いた。
また、axiosのエラーハンドリング(axios error handling)でググってもconsole.log()で出力させているようなものばかりで実際にプロダクトでは(個人開発のちょっとしたやつでも)使えなさそう1なものが多くヒットしたので、自身で開発する中で学んだ事を備忘録として残しておく。

※中にはerror.resposeをすればよいというものもあり、その覚え方ではマズいのでそこもちゃんと整理したい。

※axiosの関連ライブラリとして、request/responseのオブジェクトのキーのケース変換を行うライブラリを公開したので、そちらも見ていただけると嬉しいです。

axiosとは

axiosの仕組みを少し見てみる事で、axiosのerror handlingを理解する

error handlingを考える対象のソースコード

以下のようなソースコードで、errorHandler(res, error)の実装をする場面を考える(以下はサーバサイドでaxiosを使っている時の実装例だが、フロントエンドでも根本のエラーハンドリングの考え方は同じ)。

server.js
const express = require('express')
const app = express()

// 省略

app.listen(8081, () => console.log('listening on port 8081!'))

const axios = require('axios').default;

const instance = axios.create({
    baseURL: 'https://api.countrystatecity.in/v1/',
    timeout: 2000,
    headers: { 'X-CSCAPI-KEY': `${process.env.COUNTRYSTATECITY_API_KRY}` }
})

app.get('/allCountries', async (req, res) => {
    try {
        const countries = await instance.get('countries')
        res.send({ countries: countries.data })
    } catch (error) {
        errorHandler(res, error)
    }
})

const errorHandler = (res, error) => {
    // ここを実装したい
}

axiosのinterfaceを見て理解する

VS codeでaxiosのgetを参照すると以下のソースが見れる。

index.d.ts
export interface AxiosInstance {
  // 省略
  get<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R>;
  // 省略
}

ここからHTTP通信が成功すればAxiosResponseインスタンス(オブジェクト)が返ってくる事が分かる。
※ちなみに、AxiosResponseは以下のように定義されているので、axios.get('/hoge').then((res) => { console.log(res.data) })axios.get('/hoge').then((res) => { console.log(res.status) })などでそのオブジェクト・値が取得できる。

index.d.ts
export interface AxiosResponse<T = any> {
  data: T;
  status: number;
  statusText: string;
  headers: any;
  config: AxiosRequestConfig;
  request?: any;
}

では、エラーの時はどうなるかというと、エラーの際は以下のAxiosErrorインスタンス(オブジェクト)が返ってくる事が分かる。
これはさらにErrorインターフェースを継承しており、そのErrorインターフェースは次のように定義されている。

index.d.ts
export interface AxiosError<T = any> extends Error {
  config: AxiosRequestConfig;
  code?: string;
  request?: any;
  response?: AxiosResponse<T>;
  isAxiosError: boolean;
  toJSON: () => object;
}
lib.es5.d.ts
interface Error {
    name: string;
    message: string;
    stack?: string;
}

つまり、AxiosErrorインターフェースに基づくインスタンス(オブジェクト)(実際にエラーの際に返ってくるオブジェクト)には、

  • Error : name, message, stack
  • AxiosError : config, code, request, response, isAxiosError, toJSON

というkeyが存在する事が分かる。

エラーになる場合を場合分けしてみる

単純にエラーと言っても以下のように2パターンあるように思える。

パターン 概要 詳細
1 responseがある(undefinedでない) APIのrequestは有効でresponseも返ってくるがHTTP statusが200でない
2 responseがundefined APIのrequestは有効だが何らかの理由でresponseが返ってこない
そもそもAPIをcallしようとしたがその前にエラー

つまり、AxiosErrorresponseundefinedになる・ならないの2つで場合分けが必要という事。

ではerrorHandler(res, error)の実装はどうなるのか?

上記のインターフェースの定義+2パターンある事を踏まえて、以下のようにすれば全てのエラーパターンで対応できる。

error-handling.js
const errorHandler = (res, error) => {
    if (error.response) {
        res.status(error.response.status).send({
            error: error.response.data,
            errorMsg: error.message
        })
    } else {
        res.status(500).send({ errorMsg: error.message })
    }
}    
  1. AxiosErrorresponseundefinedでない場合、responseはAxiosResponseインターフェースに合致するオブジェクトになるのでerror.response.dataでWeb APIから返ってきたエラー内容を取得できる
    また、AxiosResponseにはstatusもあるのでそれをres.status(error.response.status)のようにして返す事ができる
    ※フロントエンドでaxiosを使っている時は、画面上でエラーに対するトースターなどを表示する時に使う事になる
  2. AxiosErrorresponseundefinedである場合、error.response.hogeはエラーになる
    代わりにErrorインターフェースで持っているmessageを利用し何が起きたのかを把握できるようにerror.messageでエラーメッセージを取得する

おまけ

ググるとよく出てくるやつ

axios-error-handling
axios.get('/user/12345')
  .catch(function (error) {
    if (error.response) {
      // The request was made and the server responded with a status code
      // that falls out of the range of 2xx
      console.log(error.response.data);
      console.log(error.response.status);
      console.log(error.response.headers);
    } else if (error.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
      console.log(error.request);
    } else {
      // Something happened in setting up the request that triggered an Error
      console.log('Error', error.message);
    }
    console.log(error.config);
  });

これのようにconsole.log()を出しているだけで、これではプロダクトの開発はできない。
単純にconsole.log()

  1. res.status(error.response.status).send(error.response.data)
  2. res.status(500).send(error.request)

のように置き換えても、1はうまくいくが少なくとも2はうまくいかない。
2がうまくいかないのは2のerror.requestはJSONに変換できるものではないからであるが、それは以下の実際のerrorの中身を全て書き出しているセクションを参照。

エラー時にaxiosが返すオブジェクト

パターン1 responseがある(undefinedでない)

以下のエラーは、先ほどerrorHandler(res, error)を実装しようとしていたソースコードの

const instance = axios.create({
    baseURL: 'https://api.countrystatecity.in/v1/',
    timeout: 2000,
    headers: { 'X-CSCAPI-KEY': `${process.env.COUNTRYSTATECITY_API_KRY}` }
})

の部分でheaders: { 'X-CSCAPI-KEY': ${process.env.COUNTRYSTATECITY_API_KRY} }をコメントアウトした時に発生したエラー。
この場合は、HTTP Requestは正常にできているのでresponseがundefinedになっておらずerror.responseAxiosResponseの中身を取得できることが分かる。

Error: Request failed with status code 401
    at createError (C:\Users\user\OneDrive\ドキュメント\travel-app\node_modules\axios\lib\core\createError.js:16:15)
    at settle (C:\Users\user\OneDrive\ドキュメント\travel-app\node_modules\axios\lib\core\settle.js:17:12)
    at IncomingMessage.handleStreamEnd (C:\Users\user\OneDrive\ドキュメント\travel-app\node_modules\axios\lib\adapters\http.js:260:11)
    at IncomingMessage.emit (events.js:327:22)
    at endReadableNT (internal/streams/readable.js:1327:12)
    at processTicksAndRejections (internal/process/task_queues.js:80:21) {
  config: {
    url: 'countries',
    method: 'get',
    headers: {
      Accept: 'application/json, text/plain, */*',
      'User-Agent': 'axios/0.21.1'
    },
    baseURL: 'https://api.countrystatecity.in/v1/',
    transformRequest: [ [Function: transformRequest] ],
    transformResponse: [ [Function: transformResponse] ],
    timeout: 3000,
    adapter: [Function: httpAdapter],
    xsrfCookieName: 'XSRF-TOKEN',
    xsrfHeaderName: 'X-XSRF-TOKEN',
    maxContentLength: -1,
    maxBodyLength: -1,
    validateStatus: [Function: validateStatus],
    data: undefined
  },
  request: <ref *1> ClientRequest {
    _events: [Object: null prototype] {
      socket: [Function (anonymous)],
      abort: [Function (anonymous)],
      aborted: [Function (anonymous)],
      connect: [Function (anonymous)],
      error: [Function (anonymous)],
      timeout: [Function (anonymous)],
      prefinish: [Function: requestOnPrefinish]
    },
    _eventsCount: 7,
    _maxListeners: undefined,
    outputData: [],
    outputSize: 0,
    writable: true,
    destroyed: false,
    _last: true,
    chunkedEncoding: false,
    shouldKeepAlive: false,
    _defaultKeepAlive: true,
    useChunkedEncodingByDefault: false,
    sendDate: false,
    _removedConnection: false,
    _removedContLen: false,
    _removedTE: false,
    _contentLength: 0,
    _hasBody: true,
    _trailer: '',
    finished: true,
    _headerSent: true,
    socket: TLSSocket {
      _tlsOptions: [Object],
      _secureEstablished: true,
      _securePending: false,
      _newSessionPending: false,
      _controlReleased: true,
      secureConnecting: false,
      _SNICallback: null,
      servername: 'api.countrystatecity.in',
      alpnProtocol: false,
      authorized: true,
      authorizationError: null,
      encrypted: true,
      _events: [Object: null prototype],
      _eventsCount: 10,
      connecting: false,
      _hadError: false,
      _parent: null,
      _host: 'api.countrystatecity.in',
      _readableState: [ReadableState],
      _maxListeners: undefined,
      _writableState: [WritableState],
      allowHalfOpen: false,
      _sockname: null,
      _pendingData: null,
      _pendingEncoding: '',
      server: undefined,
      _server: null,
      ssl: [TLSWrap],
      _requestCert: true,
      _rejectUnauthorized: true,
      parser: null,
      _httpMessage: [Circular *1],
      [Symbol(res)]: [TLSWrap],
      [Symbol(verified)]: true,
      [Symbol(pendingSession)]: null,
      [Symbol(async_id_symbol)]: 14,
      [Symbol(kHandle)]: [TLSWrap],
      [Symbol(kSetNoDelay)]: false,
      [Symbol(lastWriteQueueSize)]: 0,
      [Symbol(timeout)]: null,
      [Symbol(kBuffer)]: null,
      [Symbol(kBufferCb)]: null,
      [Symbol(kBufferGen)]: null,
      [Symbol(kCapture)]: false,
      [Symbol(kBytesRead)]: 0,
      [Symbol(kBytesWritten)]: 0,
      [Symbol(connect-options)]: [Object],
      [Symbol(RequestTimeout)]: undefined
    },
    _header: 'GET /v1/countries HTTP/1.1\r\n' +
      'Accept: application/json, text/plain, */*\r\n' +
      'User-Agent: axios/0.21.1\r\n' +
      'Host: api.countrystatecity.in\r\n' +
      'Connection: close\r\n' +
      '\r\n',
    _keepAliveTimeout: 0,
    _onPendingData: [Function: noopPendingOutput],
    agent: Agent {
      _events: [Object: null prototype],
      _eventsCount: 2,
      _maxListeners: undefined,
      defaultPort: 443,
      protocol: 'https:',
      options: [Object],
      requests: {},
      sockets: [Object],
      freeSockets: {},
      keepAliveMsecs: 1000,
      keepAlive: false,
      maxSockets: Infinity,
      maxFreeSockets: 256,
      scheduling: 'fifo',
      maxTotalSockets: Infinity,
      totalSocketCount: 1,
      maxCachedSessions: 100,
      _sessionCache: [Object],
      [Symbol(kCapture)]: false
    },
    socketPath: undefined,
    method: 'GET',
    maxHeaderSize: undefined,
    insecureHTTPParser: undefined,
    path: '/v1/countries',
    _ended: true,
    res: IncomingMessage {
      _readableState: [ReadableState],
      _events: [Object: null prototype],
      _eventsCount: 3,
      _maxListeners: undefined,
      socket: [TLSSocket],
      httpVersionMajor: 1,
      httpVersionMinor: 1,
      httpVersion: '1.1',
      complete: true,
      headers: [Object],
      rawHeaders: [Array],
      trailers: {},
      rawTrailers: [],
      aborted: false,
      upgrade: false,
      url: '',
      method: null,
      statusCode: 401,
      statusMessage: 'Unauthorized',
      client: [TLSSocket],
      _consuming: true,
      _dumped: false,
      req: [Circular *1],
      responseUrl: 'https://api.countrystatecity.in/v1/countries',
      redirects: [],
      [Symbol(kCapture)]: false,
      [Symbol(RequestTimeout)]: undefined
    },
    aborted: false,
    timeoutCb: null,
    upgradeOrConnect: false,
    parser: null,
    maxHeadersCount: null,
    reusedSocket: false,
    host: 'api.countrystatecity.in',
    protocol: 'https:',
    _redirectable: Writable {
      _writableState: [WritableState],
      _events: [Object: null prototype],
      _eventsCount: 2,
      _maxListeners: undefined,
      _options: [Object],
      _ended: true,
      _ending: true,
      _redirectCount: 0,
      _redirects: [],
      _requestBodyLength: 0,
      _requestBodyBuffers: [],
      _onNativeResponse: [Function (anonymous)],
      _currentRequest: [Circular *1],
      _currentUrl: 'https://api.countrystatecity.in/v1/countries',
      _timeout: Timeout {
        _idleTimeout: -1,
        _idlePrev: null,
        _idleNext: null,
        _idleStart: 3203,
        _onTimeout: null,
        _timerArgs: undefined,
        _repeat: null,
        _destroyed: true,
        [Symbol(refed)]: true,
        [Symbol(kHasPrimitive)]: false,
        [Symbol(asyncId)]: 21,
        [Symbol(triggerId)]: 18
      },
      [Symbol(kCapture)]: false
    },
    [Symbol(kCapture)]: false,
    [Symbol(kNeedDrain)]: false,
    [Symbol(corked)]: 0,
    [Symbol(kOutHeaders)]: [Object: null prototype] {
      accept: [Array],
      'user-agent': [Array],
      host: [Array]
    }
  },
  response: {
    status: 401,
    statusText: 'Unauthorized',
    headers: {
      date: 'Mon, 31 May 2021 14:01:16 GMT',
      'content-type': 'application/json',
      'transfer-encoding': 'chunked',
      connection: 'close',
      'cache-control': 'no-cache, private',
      'access-control-allow-origin': '*',
      'cf-cache-status': 'DYNAMIC',
      'cf-request-id': '0a6453a3a00000fcd9700ea000000001',
      'expect-ct': 'max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"',      
      'report-to': '{"endpoints":[{"url":"https:\\/\\/a.nel.cloudflare.com\\/report\\/v2?s=gzML4CT7VVjmC4HaF70JwTZ2wpR9dEItbXiXcIMSg5%2BqgegCBsN4sa9QJwHQbRrd2MuvR%2BBDODUmnVhB%2B%2B9DF0q4ncmwRLKCRRqtQNzJHwI70jiBcmOUBrCk2E%2BOSmXsk9W%2Fsz0%3D"}],"group":"cf-nel","max_age":604800}',
      nel: '{"report_to":"cf-nel","max_age":604800}',
      server: 'cloudflare',
      'cf-ray': '6580bbb29aeffcd9-KIX',
      'alt-svc': 'h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400'
    },
    config: {
      url: 'countries',
      method: 'get',
      headers: [Object],
      baseURL: 'https://api.countrystatecity.in/v1/',
      transformRequest: [Array],
      transformResponse: [Array],
      timeout: 3000,
      adapter: [Function: httpAdapter],
      xsrfCookieName: 'XSRF-TOKEN',
      xsrfHeaderName: 'X-XSRF-TOKEN',
      maxContentLength: -1,
      maxBodyLength: -1,
      validateStatus: [Function: validateStatus],
      data: undefined
    },
    request: <ref *1> ClientRequest {
      _events: [Object: null prototype],
      _eventsCount: 7,
      _maxListeners: undefined,
      outputData: [],
      outputSize: 0,
      writable: true,
      destroyed: false,
      _last: true,
      chunkedEncoding: false,
      shouldKeepAlive: false,
      _defaultKeepAlive: true,
      useChunkedEncodingByDefault: false,
      sendDate: false,
      _removedConnection: false,
      _removedContLen: false,
      _removedTE: false,
      _contentLength: 0,
      _hasBody: true,
      _trailer: '',
      finished: true,
      _headerSent: true,
      socket: [TLSSocket],
      _header: 'GET /v1/countries HTTP/1.1\r\n' +
        'Accept: application/json, text/plain, */*\r\n' +
        'User-Agent: axios/0.21.1\r\n' +
        'Host: api.countrystatecity.in\r\n' +
        'Connection: close\r\n' +
        '\r\n',
      _keepAliveTimeout: 0,
      _onPendingData: [Function: noopPendingOutput],
      agent: [Agent],
      socketPath: undefined,
      method: 'GET',
      maxHeaderSize: undefined,
      insecureHTTPParser: undefined,
      path: '/v1/countries',
      _ended: true,
      res: [IncomingMessage],
      aborted: false,
      timeoutCb: null,
      upgradeOrConnect: false,
      parser: null,
      maxHeadersCount: null,
      reusedSocket: false,
      host: 'api.countrystatecity.in',
      protocol: 'https:',
      _redirectable: [Writable],
      [Symbol(kCapture)]: false,
      [Symbol(kNeedDrain)]: false,
      [Symbol(corked)]: 0,
      [Symbol(kOutHeaders)]: [Object: null prototype]
    },
    data: { error: "Unauthorized. You shouldn't be here." }
  },
  isAxiosError: true,
  toJSON: [Function: toJSON]
}

パターン2 responseがない(undefined)

以下のエラーは、先ほどerrorHandler(res, error)を実装しようとしていたソースコードの

const instance = axios.create({
    baseURL: 'https://api.countrystatecity.in/v1/',
    timeout: 2000,
    headers: { 'X-CSCAPI-KEY': `${process.env.COUNTRYSTATECITY_API_KRY}` }
})

の部分でtimeout: 2000timeout: 500にした時に発生したエラー。
見ていくと分かるが、requestはあるがresponseがundefinedになっており、error.responseをreturnしても意味ないのは実際に中身を見てみても分かる。

ググるとよく出てくるやつで単純にerror.requestres.status(500).send({ error: error.request })のように返そうとしてもエラーになると言ったが、これは以下のrequestキーの中身を見ると分かる通り、requestは<ref *1> Writable {…となっておりJSONに変換できるオブジェクトではないから。

Error: timeout of 500ms exceeded
          at createError (C:\Users\user\OneDrive\ドキュメント\travel-app\node_modules\axios\lib\core\createError.js:16:15)
          at RedirectableRequest.handleRequestTimeout (C:\Users\user\OneDrive\ドキュメント\travel-app\node_modules\axios\lib\adapters\http.js:280:16)
          at RedirectableRequest.emit (events.js:315:20)
          at Timeout._onTimeout (C:\Users\user\OneDrive\ドキュメント\travel-app\node_modules\follow-redirects\index.js:165:12)
          at listOnTimeout (internal/timers.js:554:17)
          at processTimers (internal/timers.js:497:7) {
        config: {
          url: 'countries',
          method: 'get',
          headers: {
            Accept: 'application/json, text/plain, */*',
            'X-CSCAPI-KEY': '***************************************', ←マスキングしてます
            'User-Agent': 'axios/0.21.1'
          },
          baseURL: 'https://api.countrystatecity.in/v1/',
          transformRequest: [ [Function: transformRequest] ],
          transformResponse: [ [Function: transformResponse] ],
          timeout: 500,
          adapter: [Function: httpAdapter],
          xsrfCookieName: 'XSRF-TOKEN',
          xsrfHeaderName: 'X-XSRF-TOKEN',
          maxContentLength: -1,
          maxBodyLength: -1,
          validateStatus: [Function: validateStatus],
          data: undefined
        },
        code: 'ECONNABORTED',
        request: <ref *1> Writable {
          _writableState: WritableState {
            objectMode: false,
            highWaterMark: 16384,
            finalCalled: false,
            needDrain: false,
            ending: false,
            ended: false,
            finished: false,
            destroyed: false,
            decodeStrings: true,
            defaultEncoding: 'utf8',
            length: 0,
            writing: false,
            corked: 0,
            sync: true,
            bufferProcessing: false,
            onwrite: [Function: bound onwrite],
            writecb: null,
            writelen: 0,
            afterWriteTickInfo: null,
            buffered: [],
            bufferedIndex: 0,
            allBuffers: true,
            allNoop: true,
            pendingcb: 0,
            prefinished: false,
            errorEmitted: false,
            emitClose: true,
            autoDestroy: true,
            errored: null,
            closed: false
          },
          _events: [Object: null prototype] {},
          _eventsCount: 0,
          _maxListeners: undefined,
          _options: {
            maxRedirects: 21,
            maxBodyLength: 10485760,
            protocol: 'https:',
            path: '/v1/countries',
            method: 'GET',
            headers: [Object],
            agent: undefined,
            agents: [Object],
            auth: undefined,
            hostname: 'api.countrystatecity.in',
            port: null,
            nativeProtocols: [Object],
            pathname: '/v1/countries'
          },
          _ended: true,
          _ending: true,
          _redirectCount: 0,
          _redirects: [],
          _requestBodyLength: 0,
          _requestBodyBuffers: [],
          _onNativeResponse: [Function (anonymous)],
          _currentRequest: ClientRequest {
            _events: [Object: null prototype],
            _eventsCount: 1,
            _maxListeners: undefined,
            outputData: [],
            outputSize: 0,
            writable: true,
            destroyed: true,
            _last: true,
            chunkedEncoding: false,
            shouldKeepAlive: false,
            _defaultKeepAlive: true,
            useChunkedEncodingByDefault: false,
            sendDate: false,
            _removedConnection: false,
            _removedContLen: false,
            _removedTE: false,
            _contentLength: 0,
            _hasBody: true,
            _trailer: '',
            finished: true,
            _headerSent: true,
            socket: [TLSSocket],
            _header: 'GET /v1/countries HTTP/1.1\r\n' +
              'Accept: application/json, text/plain, */*\r\n' +
              'X-CSCAPI-KEY: *************************************\r\n' +
              'User-Agent: axios/0.21.1\r\n' +
              'Host: api.countrystatecity.in\r\n' +
              'Connection: close\r\n' +
              '\r\n',
            _keepAliveTimeout: 0,
            _onPendingData: [Function: noopPendingOutput],
            agent: [Agent],
            socketPath: undefined,
            method: 'GET',
            maxHeaderSize: undefined,
            insecureHTTPParser: undefined,
            path: '/v1/countries',
            _ended: false,
            res: null,
            aborted: true,
            timeoutCb: null,
            upgradeOrConnect: false,
            parser: [HTTPParser],
            maxHeadersCount: null,
            reusedSocket: false,
            host: 'api.countrystatecity.in',
            protocol: 'https:',
            _redirectable: [Circular *1],
            [Symbol(kCapture)]: false,
            [Symbol(kNeedDrain)]: false,
            [Symbol(corked)]: 0,
            [Symbol(kOutHeaders)]: [Object: null prototype]
          },
          _currentUrl: 'https://api.countrystatecity.in/v1/countries',
          _timeout: Timeout {
            _idleTimeout: 500,
            _idlePrev: null,
            _idleNext: null,
            _idleStart: 3646,
            _onTimeout: [Function (anonymous)],
            _timerArgs: undefined,
            _repeat: null,
            _destroyed: true,
            [Symbol(refed)]: true,
            [Symbol(kHasPrimitive)]: false,
            [Symbol(asyncId)]: 41,
            [Symbol(triggerId)]: 38
          },
          [Symbol(kCapture)]: false
        },
        response: undefined,
        isAxiosError: true,
        toJSON: [Function: toJSON]
      }

参考文献

  1. ブラウザ・サーバに限らず、プロダクトを作る上ではconsole.log()に出してもそれの情報に基づいた処理ができないので意味ない

106
58
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
106
58