カミナシのソフトウェアエンジニア佐藤です。カミナシレポートの開発に携わっています。
フロントエンドのエラーは「画面リロードやブラウザ再起動で復旧できる(かもしれない)」「クラッシュしてもユーザーの端末に閉じる」などの理由から、バックエンドよりは精緻に扱われない傾向があると個人的には感じています。
その一方、カミナシレポートは、ノンデスクワーカー向けの不安定なネットワーク環境で利用されることも多々あるアプリです。そのため、デジタルツールに不慣れな方のために精緻なフィードバックが必要とされる、リロードに頼ることが難しいケースがある、などの理由でエラーの扱いにも慎重になる必要があります。
本記事では、カミナシレポートのフロントエンド開発をする中で、
- バックエンドの API コール時にエラーが発生する条件とその内容(型・クラス)
- これらエラーをハンドリングする箇所
について、把握しておきたいと感じたポイントをまとめました。
本記事で主に取り扱う API やライブラリは以下です。
- Fetch API
- typescript-fetch
- React(Client Side Rendering の Single Page Application 前提)
- TanStack Query
本記事の全体構成
やや長めの記事になるため、始めに本記事の全体構成を示します。
本記事は大まかに2段階で構成しています。
まず、始めに API コールで用いる Fetch API と typescript-fetch にて、エラーが発生する条件やエラーの内容について把握します。
次に、これらエラーをハンドリングするための React や TanStack Query の仕組みについて確認していきます。(末尾には Appendix として、いくつかのお役立ち情報も載せています)
Fetch API と typescript-fetch のエラー条件・内容
バックエンドの API コールをするときに使用する Fetch API と typescript-fetch の動作を確認し、エラーが発生する条件やその内容について把握していきます。
Fetch API
まずは、言わずと知れたブラウザ標準の Fetch API の動作を確認します。エラーハンドリングの観点では以下の点を把握しておくと良いでしょう。
- ステータスコード 400 や 500 のエラー系レスポンスが返っても Promise が reject されるわけではない
- ネットワーク切断などサーバーと疎通不可のエラーの場合 Promise が reject される
- レスポンスボディの parse に失敗した場合も Promise が reject される
表にまとめると以下の通りです。
エラーの種類 | Promise | throw されるエラーの型 |
---|---|---|
400・500 のエラー系のステータスコード | reject されない | - |
ネットワーク・CORS・CSP などの疎通できないエラー | reject | TypeError |
parse に失敗した場合のエラー(response.json() の呼び出しでエラー) | reject | SyntaxError |
(「Promise が reject される」がピンとこない方は、(厳密に異なる概念ですが)「エラーが throw される」と読み替えていただいて差し支えありません)
以下、順番に見ていきます。
📝 ステータスコード 400 や 500 のエラー系レスポンスが返っても Promise が reject されるわけではない
突然ですが問題です。以下のようにバックエンドの API コールの際に、4xx や 5xx のエラー系ステータスコードが返った場合、コンソールにはどのように表示されるでしょうか?
fetch(`${API_BASE_URL}/messages`) .then((response) => response.json()) .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
正解は…
Success: {data: {...}}
です。
Fetch API ではステータスコードに応じて Promise の成功・失敗を判定するわけではありません。エラー系のステータスコードの場合に Promise を reject したい場合、以下のように明示的に分岐させる必要があります。
fetch(`${API_BASE_URL}/messages`) .then((response) => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); // ↓こちらでも同様 // return Promise.reject(new Error(`...`)); } return response.json(); }) .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
Failed: ...
「!response.ok」 の場合(エラー系のレスポンスの場合)、Error を throw しているので、「Failed: …」が出力されます。
📝 ネットワーク切断などサーバーと疎通不可のエラーの場合 Promise が reject される
先ほどはバックエンドの API サーバーなどに接続ができ、HTTP リクエストが行える(ステータスコードなどは受け取れる)状況でしたが、今度はサーバーと疎通できない状況を考えます。
このような状況で先ほどと同様の API コールを行うとどうなるでしょうか?
fetch(`${API_BASE_URL}/messages`) .then((response) => response.json()) .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
この場合は、Promise が reject されます。
Failed: {error: TypeError: Failed to fetch .... }
また、以下のような場合でも、同様に Promise が reject されます。
- CORS (Cross Origin Resource Sharing)ポリシーにより、エンドポイントとして指定された オリジンへのアクセスが許可されていない場合のエラー
- Content Security Policy の connect-src などの制限により、指定された origin へのアクセスが許可されていない場合のエラー
📝 レスポンスボディの parse に失敗した場合も Promise が reject される
上記で見てきたエンドポイントは JSON (application/json)でのレスポンスボディを想定していました。以下のようなケースではどのように振る舞うでしょうか?
- API サーバーの前段にあるリバースプロキシにおいて、502 BadGateway が返された
- 502 BadGateway の場合、html 形式のエラー画面が返却される
すなわち、
fetch(`${API_BASE_URL}/messages`) .then((response) => response.json()) .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
のような API コールにおいて、以下のようなレスポンスボディが返された時を考えます。
<!DOCTYPE html> <html> <head> <title>502 Bad Gateway</title> </head> <body> <h1>502 Bad Gateway</h1> <p>.....</p> </body> </html>
この場合 response.json() が失敗します。より正確には response.json() が返す Promise が reject されます。reject される理由は、エラー内容からもわかる通り、html 形式のテキストは、JSON 形式には parse できないためです。
Failed: {error: SyntaxError: Unexpected token '<', " <!DOCTYPE "... is not valid JSON}
1つ1つ見ていくと直感的に理解可能な動作であると思いますが、何気なく使っていると嵌ったりすることがあるので注意が必要です。
typescript-fetch
typescript-fetch とは OpenAPI Generator の typescript-fetch のことを指しています。
typescript-fetch を用いることにより、OpenAPI 定義から API client ライブラリ(SDK)を自動生成できます。
例えば、以下のような OpenAPI 定義から
paths: /messages: get: tags: - messages operationId: getMessages responses: '200': content: application/json: schema: type: array items: $ref: '#/components/schemas/Message'
以下のような API client ライブラリを生成してフロントエンドなどで利用することができます。
export declare class MessagesApi extends runtime.BaseAPI { getMessagesRaw(...): Promise<runtime.ApiResponse<Array<Message>>>; getMessages(...): Promise<Array<Message>>; }
特別な実装をしない限り、typescript-fetch は、Fetch API をラップする形で HTTP リクエストを実行します。そのため、ラップすることによって Fetch API の動作がどのように変わるのか?に注目して動作を確認していきます。
(以降の例では v7.12.0 を用いています)
先ほど Fetch API にて確認したのと同様のケースを typescript-fetch で試してみると以下の通りになります。
- ステータスコード 400 や 500 のエラー系レスポンスが返った場合、Promise が reject される(→ここが Fetch API と異なる!)
- ネットワーク切断などサーバーと疎通不可のエラーの場合 Promise が reject される
- レスポンスボディの parse に失敗した場合も Promise が reject される
表にまとめると以下の通りです。
エラーの種類 | Promise | throw されるエラーの型 |
---|---|---|
400・500 のエラー系のステータスコード | reject 👀 | ResponseError |
ネットワーク・CORS・CSP などの疎通できないエラー | reject | FetchError |
parse に失敗した場合のエラー(response.json() の呼び出しでエラー) | reject | SyntaxError |
以下、順番に見ていきます。
📝 400 や 500 のエラー系レスポンスが返った場合、Promise が reject される
先ほどと同様、4xx や 5xx のエラー系ステータスコードが返った場合を確認します。
MessagesApi は typescript-fetch により自動生成されたコードで、 GET /messages
を呼び出しています。
new MessagesApi() .getMessages() .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
結果は以下の通りです。
Failed: {error: ResponseError: ... }
このように「400・500 のエラー系のステータスコード」が返った場合、Promise が reject されます。(厳密には typescript-fetch では 2xx 系以外のステータスコードの場合 Promise が reject されます)
自動生成された API client のコードに以下のような箇所があり、この箇所で明示的に、エラー系のステータスコードの場合、エラーが throw されています。そのため、素の Fetch API を呼び出した場合と異なり、Promise が reject されます。
protected async request(context: RequestOpts, initOverrides?: RequestInit | InitOverrideFunction): Promise<Response> { const { url, init } = await this.createFetchParams(context, initOverrides); const response = await this.fetchApi(url, init); if (response && (response.status >= 200 && response.status < 300)) { return response; } throw new ResponseError(response, 'Response returned an error code'); }
自動生成される runtime.ts より抜粋(生成元のコードテンプレート)
ResponseError は、typescript-fetch で定義されているカスタムエラークラスで、Error を継承しています。
export class ResponseError extends Error { override name: "ResponseError" = "ResponseError"; constructor(public response: Response, msg?: string) { super(msg); } }
自動生成される runtime.ts より抜粋(生成元のコードテンプレート)
📝 ネットワーク切断などサーバーと疎通不可のエラーの場合 Promise が reject される
続いて、サーバーと疎通できない場合の動作も確認していきます。先ほどと同様の以下を実行します。
new MessagesApi() .getMessages() .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
結果は以下の通りです。
Failed: {error: FetchError: ... }
このパターンでは、Fetch API 同様 Promise が reject されます。ただしエラーの型は typescript-fetch で定義されているカスタムエラークラスの FetchError になっています。
export class FetchError extends Error { override name: "FetchError" = "FetchError"; constructor(public cause: Error, msg?: string) { super(msg); } }
自動生成される runtime.ts より抜粋(生成元のコードテンプレート)
📝 レスポンスボディの parse に失敗した場合も Promise が reject される
上記で確認した通り、エラー系のステータスコードが返った場合、Promise が reject されるので、Fetch API で確認した以下のケースでは、parse 時のエラーは発生しません。
- API サーバーの前段にあるリバースプロキシにおいて、502 BadGateway が返された
- 502 BadGateway の場合、html 形式のエラー画面が返却される
そこで
- OpenAPI 定義上は JSON を返すはずのエンドポイントが、html 形式のレスポンスを正常終了のステータスコードで返してしまった
というケースで確認します(現実には発生する可能性は低いですが…)。これまでと同様、以下の処理を実行すると…
new MessagesApi() .getMessages() .then((data) => { console.log("Success: ", { data, }); }) .catch((error) => { console.error("Failed: ", { error }); });
以下の結果が得られます。
Failed: {error: SyntaxError: .... }
エラー系ステータスコードや疎通のエラーの場合には、typescrpt-fetch によりラップされたエラーが得られましたが、このケースのエラーは素の Fetch API と同様 SyntxError が得られます。
エラー系のステータスコードの場合に、Promise が reject される点や、typescript-fetch で定義されているカスタムエラークラスのエラーが throw される点など、Fetch API との相違が確認できました。
📝 補足情報:OpenAPI Generator v6 未満の場合 Response オブジェクトがそのまま throw されている
古いバージョンの typescript-fetch では、エラー系のステータスコードの場合、以下のように Response オブジェクトがそのまま throw されています。
protected async request(context: RequestOpts, initOverrides?: RequestInit): Promise<Response> { const { url, init } = this.createFetchParams(context, initOverrides); const response = await this.fetchApi(url, init); if (response.status >= 200 && response.status < 300) { return response; } throw response; }
v6 系よりこの動作が変更され、ResponseError でラップされて throw されるようになりました。古いバージョンの typescript-fetch を使用している場合、注意が必要です。
TanStack Query と React でのエラーハンドリング
ここまで、HTTP リクエストを行った際に、どのような条件でどの様なエラーが発生するかを中心に見てきました。ここからは、これらのエラーをどこでハンドリングするかを確認していきます。
カミナシレポートでは React + TanStack Query をよく使っています。これらにおける HTTP リクエスト時のエラーハンドリングについて見ていきます。
React の ErrorBoundary について
はじめに、前提知識として React の ErrorBoundary について見ていきます。
ErrorBoundary とは React における、「コンポーネントのレンダー時に発生したエラーを捕捉し、フォールバック UI の表示などを行うための特別なコンポーネント」です。
エラー時のフォールバック処理の責務を個々のコンポーネントから分離でき、宣言的に一貫性があるエラーハンドリングが実現しやすくなります。以下簡単な例で説明します。
以下のようなコンポーネントを考えます。
function ComponentThrowsError({ isError }: { isError?: boolean }): JSX.Element { if (isError) { throw new Error("This is an error"); } return <p>No error</p>; } export function ErrorBoundaryExample() { return ( <ErrorBoundary FallbackComponent={({ error }) => <p>orz: {String(error)}</p>}> <ComponentThrowsError /> </ErrorBoundary> ); }
ErrorBoundaryExample をレンダリングすると、以下のようなUIが表示されます。
No error
ここで、以下のように書き換えてレンダリングしてみます。
function ComponentThrowsError({ isError }: { isError?: boolean }): JSX.Element { if (isError) { throw new Error("This is an error"); } return <p>No error</p>; } export function ErrorBoundaryExample() { return ( <ErrorBoundary FallbackComponent={({ error }) => <p>orz: {String(error)}</p>}> <ComponentThrowsError isError /> -> !!! ここを書き換え !!! </ErrorBoundary> ); }
すると、表示は以下のようになります。
orz: Error: This is an error
エラーが throw された上で、FallbackComponent に指定した UI が表示されていることが見て取れます。
TanStack Query について
TanStack Query についても簡単に説明します。TanStack Query はよく「server state」を管理するためのライブラリなどと言われます。
(API)サーバー側のリソースの参照や更新を実行する際に用いることで、これらに関するフロントエンド側の状態管理が非常に楽に行えるようになります。以下で利用例を示します。
基本的に
- 参照系の API コールをする時: useQuery
- 更新系の API コールをする時: useMutation
を用います。
function QuerySample() { const { data } = useQuery({ queryKey: ["messages"], queryFn: () => api.getMessages() }); return data?.map((m) => <p key={m.id}>{m.message}</p>); }
const { mutate } = useMutation({ mutationFn: postMessage, }); .... <button type="button" onClick={() => mutate("Hello!") } > send message </button>
以降では TanStack Query のエラーハンドリングについて見ていきます。
TanStack Query での ErrorBoundary 利用
📝 throwOnError = true にすると ErrorBoundary でエラーが捕捉される
TanStack Query を用いて API コールを行う時のエラーハンドリングも、ErrorBoundary の仕組みに乗せることが可能です。前述の ErrorBoundary の知識を活かして、API コールのエラーを効率的に処理できます。以下で順を追って確認していきます。
ErrorBoundary の仕組みを用いない場合、以下のようにエラー時の考慮を各コンポーネントで明示的に行う必要があります。
function QuerySample() { const { data, isError, error } = useQuery({ queryKey: ["messages"], queryFn: async () => await api.getMessages(), }); if (isError) { return <p>QuerySample orz: {String(error)}</p>; } return data?.map((m) => <p key={m.id}>{m.message}</p>); }
このケースにおいて、getMessages による API コールでエラーとなった場合、表示される UI は以下です。
QuerySample orz: ResponseError: ...
ここで、throwOnError を true にすると queryFn でエラーが発生した場合(*) ErrorBoundary にエラーを伝播することができるようになります。
(*正確には「queryFn が返却する Promise が reject された場合」ですが、冗長なのでこの表現で説明していきます)
function QuerySample() { const { data } = useQuery({ queryKey: ["messages"], queryFn: async () => await api.getMessages(), throwOnError: true, }); return data?.map((m) => <p key={m.id}>{m.message}</p>); } function ErrorBoundaryExample() { return ( <ErrorBoundary FallbackComponent={({ error }) => <p>orz: {String(error)}</p>}> <QuerySample /> </ErrorBoundary> ); }
表示されるUIは以下の通りです
orz: ResponseError: ...
上記の例では useQuery のオプションとして throwOnError を有効化しましたが、QueryClient のデフォルトのオプションとして指定し、アプリケーション全体で有効にすることも可能です。
const queryClient = new QueryClient({ defaultOptions: { queries: { throwOnError: true, }, }, }); ... <QueryClientProvider client={queryClient}> ... </QueryClientProvider>
また、以下のように指定することで、useMutation も同様のことが実現できます。
const queryClient = new QueryClient({ defaultOptions: { mutations: { throwOnError: true, // defaultOptions の指定も可能 }, }, });
const { mutate } = useMutation({ mutationFn: postMessage, throwOnError: true, });
ちなみに上記の例では割愛していますが、実際のアプリケーションでは HTTP リクエスト実行中のローディング中を示す UI の表示なども考慮しなければなりません。
isLoading などのフラグを見て各コンポーネントで明示的に取り扱うことも可能ですが、Suspense を用いて各コンポーネントからローディング中の処理の責務を分離することもできます。(本記事の主題とは若干ズレるため詳細は割愛します)
TanStack Query の「onError」コールバックの利用
ここまでで、ErrorBoundary によるエラーハンドリングを見てきました。ErrorBoundary はエラー時の UI コンポーネントのレンダリングに便利ですが、エラー時に以下のような副作用が必要となることがあります。
- 監視サービスへのログ送信
- 何らかの state の更新
このような場合 onError コールバック関数を利用するのが便利です。以下では、これらの動作を確認していきます。
動作はやや煩雑なので表にもまとめておきます。
種別 | onError の箇所 | 実行条件 | 主な用途 |
---|---|---|---|
Query | queryCache | 定義されている場合、必ず実行 | アプリケーション共通のエラーハンドリング |
Mutation | mutationCache | 定義されている場合、必ず実行 | アプリケーション共通のエラーハンドリング |
Mutation | defaultOptions.mutations | useMutation の onError が定義されていない場合、実行 | アプリケーション共通のエラーハンドリング |
Mutation | useMutation | 定義されている場合、必ず実行 | Mutation を実行するコンポーネント固有ののエラーハンドリング |
Mutation | mutate | 定義されている場合、必ず実行 | mutate 呼び出し固有のエラーハンドリング |
📝 useQueryの場合 queryCache.onError を使用できる
以下のように、QueryClient を生成する時のオプションとして queryCache に onError を設定することができます。
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { console.error("QueryCache Error: ", { error }); }, }) });
先ほど紹介した throwOnError の値を問わず、エラーが起きた際に、この onError コールバックは呼ばれます。
QueryClient 単位で設定するもののため、主にアプリケーション共通のエラーハンドリングで利用されます。
useQuery の場合、後述する useMutation とは異なり defaultOptions や useQuery のコールバックとして onError を定義することはできません。
当記事執筆時点の TanStack Query 最新バージョンは v5 ですが、v5 において useQuery からこれらのコールバックが削除されています。(参考)
📝 useMutation に関する onError は指定できる箇所が複数あり、どれが実行されるかに注意を要する
useMutation の場合も onError を利用できますが、指定できる箇所が複数あり、注意が必要です。
唐突に問題です。以下のように onError が仕込まれている場合に、mutationFn がエラーとなった場合、どの onError が実行されるでしょうか?
const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: (error) => console.error("MutationCache Error: ", { error }), }), defaultOptions: { mutations: { onError: (error) => console.error("defaultOptions Error: ", { error }), }, }, }); function Mutation() { const { mutate } = useMutation({ mutationFn: postMessage, onError: (error) => console.error("useMutation error:", { error }), }); return ( <ErrorBoundary FallbackComponent={FallbackComponent}> <button type="button" onClick={() => mutate("Hello!", { onError: (error) => console.error("mutate error:", { error }), }) } > mutate </button> </ErrorBoundary> ); }
このコード例では、以下の 4 箇所に onError が仕込まれています
- mutationCache の onError
- defaultOptions.mutations の onError
- useMutation の options の onError
- mutate 関数の options の onError
正解は以下の通りです。
MutationCache Error: ... useMutation error: ... mutate error: ...
ここで useMutation の onError を消去すると
const { mutate } = useMutation({ mutationFn: postMessage, });
以下のように defaultOptions.mutations の onError の方が実行されるようになります。
MutationCache Error: ... defaultOptions Error: ... mutate error: ...
つまり、mutationCache の onError と mutate 関数の options の onError は必ず実行され、useMutation の options の onError の指定がない場合に、defaultOptions.mutations の onError が実行されます。
📝 補足情報: onError の引数で与えられる error は Error 型であることを期待して良いか?
「ほぼ期待して良いが、一定の注意は必要」です。
TanStack Query では v5 より、エラーのデフォルトの型が Error 型に切り替えられています
Even though in JavaScript, you can throw anything (which makes unknown the most correct type), almost always, Errors (or subclasses of Error) are thrown.
上記で述べられている通り、ほとんどのケースでは Error 型であることを期待して良いと思います。
ただし、JavaScript・TypeScript では「どのような型でも throw できる」ため一定の注意は要します。
以下は先ほどの queryCache.onError の例を書き換えたものです。 このように文字列を throw することもできてしまいます。
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { if (typeof error === "string") { console.error("QueryCache Error as string: ", { error }); } else { console.error("QueryCache Error: ", { error }); } }, }) }); function QuerySample() { const { data } = useQuery({ queryKey: ["messages"], queryFn: async () => await api.getMessages().catch(() => { throw "Error as string"; }), throwOnError: true, }); return data?.map((m) => <p key={m.id}>{m.message}</p>); }
エラーが発生すると以下のように出力され、onError に与えられる error は文字列であることがわかります。
QueryCache Error as string: ...
万全を期すのであれば、以下のように error の型 unknown とみなして扱うと良いでしょう。
onError: (error: unknown) => { if (error instanceof Error) { const msg = error.message; ... } }
カミナシレポートでの TanStack Query エラーハンドリングの方針
参考までに、以上で紹介した仕組みをカミナシレポート(「カミナシレポート 記録アプリ(Web)」)ではどのような整理で利用しているかを紹介します。
📝 Query(参照系)のエラーハンドリング
- QueryClient の defaultOptions.queries で throwOnError を true にし、基本的にグローバルな ErrorBoundary でフォールバック用の UI を表示している
- QueryClient の queryCache の onError を指定し、監視システム(Sentry)へのログ送信などを行なっている
📝 Mutation(更新系)のエラーハンドリング
- エラー時のフォールバック処理は機能毎に異なるケースが多いので、defaultOptions.mutations の throwOnError は false
- 同様の理由から、onError もオーバーライド可能な作りとするため mutationCache の onError は指定せず、defaultOptions.mutations の onError のみを指定。ここでは queryCache と同様、監視システム(Sentry)へのログ送信などを実行している
上記の設定をコードで表現すると以下の通りです。
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error) => { /* Sentry へのログ送信など */ }, }), defaultOptions: { queries: { throwOnError: true, }, mutations: { throwOnError: false, onError: (error) => { /* Sentry へのログ送信など */ }, }, }, });
Appendix: その他知っていると役立つ雑多なこと
最後に、以上までのエラーハンドリングの周辺知識として覚えておくと役に立ちそうなことをいくつか紹介します。
📝 エラーハンドリング「漏れ」を検知したい場合 unhandledrejection をハンドリングする
これまで見てきたような API コールに関するエラーをハンドリングし忘れた場合はどうなるのでしょうか?
具体的には、以下のように reject された Promise を catch などで処理しなかった場合はどうなるのでしょうか?
<button type="button" onClick={() => { fetch(`${API_BASE_URL}/messages`) .then((response) => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }) .then((data) => { console.log("Success: ", { data, }); }); }} > call api </button>
この場合、グローバル(window や Worker)なスコープに対して unhandledrejection イベントが発生します。
このイベントは以下のようにして明示的にハンドリングすることもできます。
window.addEventListener("unhandledrejection", (event) => { console.error("Unhandled promise rejection:", { event, }); });
カミナシレポートではフロントエンドの監視に Sentry を利用しており @sentry/browser によってログの送信などを行っています。@sentry/browser では、デフォルトで有効となる GlobalHandlers により unhandledrejection がキャプチャーされるようになっています。
📝 Fetch API のレスポンスボディをロギングしたい時は parse したものを使用する
Sentry などの監視システムに HTTP レスポンスの内容も情報として送信したいこともあります。
以下のように Fetch API の Response オブジェクトの情報をそのまま含めるとどうなるでしょうか?
void fetch('...') .then(res => { if (res.ok) { return res.json() } throw new MyResponseError(res) }) .catch(async e => { if (e instanceof MyResponseError) { Sentry.captureException(e, { extra: { response: e.response, }, }) } })
このようにして送信した場合、「response」の値は以下のように {}
となってしまいます。
response {}
レスポンスボディの内容も見えるようにしたい場合、例えば、以下のように parse したものを与える必要があります。
void fetch('...') .then(res => { if (res.ok) { return res.json() } throw new MyResponseError(res) }) .catch(async e => { if (e instanceof MyResponseError) { Sentry.captureException(e, { extra: { response: await e.response .text() .catch(e => `Failed to parse: ${e}`), }, }) } })
このコードで送信した「response」の値は以下のようになります。
response {"errorMessage":"Internal Server Error occurred"}
📝 エラーレスポンスボディを複数箇所で参照したい場合 queryFn 内で parse した後に throw する
先ほど例示したレスポンスボディの内容もロギングしたい場合などは特に、複数箇所でレスポンスボディを参照したくなることがあります。例えば、
- エラーが発生した時、レスポンスボディの内容を Sentry に送信する
- ErrorBoundary によるフォールバック UI はレスポンスボディの中身に応じて出し分ける
のようなケースです。以下、例を示します。
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: async (error) => { if (error instanceof ResponseError) { sendResponseBodyToSentry({ responseBodyText: await error.response.text() }); } }, }) }); function FallbackComponent({ error }: FallbackProps) { const [errorCode, setErrorCode] = useState<ErrorResponseErrorCodeEnum | undefined>(undefined); useEffect(() => { if (error instanceof ResponseError) { error.response .json() .then((data: ErrorResponse) => setErrorCode(data.errorCode)) .catch((error) => console.error("Error parsing JSON: ", { error })); } }, [error]); return errorCode && <p>{`Error ${errorCode}`}</p>; } export function ResponseCloneSample() { return ( <QueryClientProvider client={queryClient}> <ErrorBoundary FallbackComponent={FallbackComponent}> ... </ErrorBoundary> </QueryClientProvider> ); }
基本的に1つの Response オブジェクトについてレスポンスボディを読み出せるのは1回のみのため、上記のコードを実行すると、以下のように FallbackComponent でエラーになってしまいます。
Error parsing JSON: {error: TypeError: Failed to execute 'json' on 'Response': body stream already read ...
ここで以下のように Response#clone を用いると、複数回リクエストボディを読み込むことができ、エラーを回避することが可能です。
queryCache: new QueryCache({ onError: async (error) => { if (error instanceof ResponseError) { sendResponseBodyToSentry({ responseBodyText: await error.response.clone().text() }); } }, }),
しかし、Response#clone を使っても以下の懸念があります。
- queryCache.onError と FallbackComponent の useEffect の実行順が保証されているのか懸念がある。
- 両方で、Response#clone を使うと実行順の問題は回避できそうだが、今度はオリジナルの Response のボディが読み取られないことになりメモリリークの懸念がある
前者に関しては、手元での動作確認の結果は常に queryCache.onError → FallbackComponent の useEffect となりましたが、非同期処理のコールバック関数や useEffect の実行タイミングに強く依存する書き方は避けるのが無難と考えます。
後者については、現状調査してもメモリリークが起きない確証が得られなかったので、慎重な姿勢をとっています。
今回のユースケースにおいては、以下の様に queryFn で parse した後に throw するのが無難でしょう。
const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: async (error) => { if (error instanceof ParsedResponseError) { sendResponseBodyToSentry({ responseBodyText: JSON.stringify(error.body) }); } }, }) }); function FallbackComponent({ error }: FallbackProps) { if (error instanceof ParsedResponseError) { return error.body.errorCode && <p>{`Error ${error.body.errorCode}`}</p>; } } function ComponentUsesQuery() { const { data } = useQuery({ queryKey: ["messages"], queryFn: async () => await api.getMessages().catch(async (error) => { if (error instanceof ResponseError) { throw new ParsedResponseError(await error.response.json()); } throw error; }), }); return <>{JSON.stringify(data)}</>; } export function ResponseNotCloneSample() { return ( <QueryClientProvider client={queryClient}> <ErrorBoundary FallbackComponent={FallbackComponent}> <ComponentUsesQuery /> </ErrorBoundary> </QueryClientProvider> ); }
おわりに
フロントエンド API コール時のエラーハンドリングについて、カミナシレポートでの経験を踏まえて整理してきました。
非同期処理や、フロントエンドのエコシステムの複雑さなどが相まって、エラーハンドリングを整備するのは地味に大変ではありますが、面白くもあります!
最後に宣伝ですが、カミナシでは絶賛採用中です!
私が所属するカミナシレポートチームでも SWE FE, SWE BE を募集してます!
- ENG-1021. シニアソフトウェアエンジニア (現場帳票システム「カミナシ レポート」 - フロントエンド)
- ENG-1022. シニアソフトウェアエンジニア (現場帳票システム「カミナシ レポート」 - バックエンド)
フロントエンドのエラーハンドリングにも妥協しない隙のないサービスを作ることに興味のある方、是非一緒に働きましょう!