はじめに
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を使っている時の実装例だが、フロントエンドでも根本のエラーハンドリングの考え方は同じ)。
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を参照すると以下のソースが見れる。
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) })
などでそのオブジェクト・値が取得できる。
export interface AxiosResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: any;
config: AxiosRequestConfig;
request?: any;
}
では、エラーの時はどうなるかというと、エラーの際は以下のAxiosError
インスタンス(オブジェクト)が返ってくる事が分かる。
これはさらにError
インターフェースを継承しており、そのError
インターフェースは次のように定義されている。
export interface AxiosError<T = any> extends Error {
config: AxiosRequestConfig;
code?: string;
request?: any;
response?: AxiosResponse<T>;
isAxiosError: boolean;
toJSON: () => object;
}
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しようとしたがその前にエラー |
つまり、AxiosError
のresponse
がundefined
になる・ならないの2つで場合分けが必要という事。
ではerrorHandler(res, error)
の実装はどうなるのか?
上記のインターフェースの定義+2パターンある事を踏まえて、以下のようにすれば全てのエラーパターンで対応できる。
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 })
}
}
-
AxiosError
のresponse
がundefined
でない場合、responseはAxiosResponse
インターフェースに合致するオブジェクトになるのでerror.response.data
でWeb APIから返ってきたエラー内容を取得できる
また、AxiosResponseにはstatus
もあるのでそれをres.status(error.response.status)
のようにして返す事ができる
※フロントエンドでaxiosを使っている時は、画面上でエラーに対するトースターなどを表示する時に使う事になる -
AxiosError
のresponse
がundefined
である場合、error.response.hoge
はエラーになる
代わりにError
インターフェースで持っているmessage
を利用し何が起きたのかを把握できるようにerror.message
でエラーメッセージを取得する
おまけ
ググるとよく出てくるやつ
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()
を
res.status(error.response.status).send(error.response.data)
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.response
でAxiosResponse
の中身を取得できることが分かる。
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: 2000
をtimeout: 500
にした時に発生したエラー。
見ていくと分かるが、request
はあるがresponse
がundefinedになっており、error.response
をreturnしても意味ないのは実際に中身を見てみても分かる。
※ググるとよく出てくるやつで単純にerror.request
をres.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]
}
参考文献
-
ブラウザ・サーバに限らず、プロダクトを作る上では
console.log()
に出してもそれの情報に基づいた処理ができないので意味ない ↩