- 著者
- app-hacks
- 公開日
TypeScript / REST API / Error Handling
フロントエンドにおけるREST APIエラーハンドリングの設計パターン
通信失敗とHTTPエラーを分類し、通知と有限回リトライを分離するフロントエンド設計を解説する。
REST APIの失敗をすべて「通信エラー」として扱うと、再試行しても直らない入力エラーを繰り返したり、サーバー障害なのにフォーム内容の修正を求めたりする。フロントエンドでは、少なくともネットワークエラー、4xx、5xx、キャンセル、レスポンス形式不正を区別し、再試行の判断とユーザー通知を分離する。
失敗を判別可能な型へ変換する
画面ごとにresponse.statusを直接調べるのではなく、APIクライアント層で共通のエラー型へ変換する。
type ApiError =
| { kind: 'network'; cause: unknown }
| { kind: 'timeout' }
| { kind: 'client'; status: number; body: unknown }
| { kind: 'server'; status: number; body: unknown }
| { kind: 'invalid-response'; cause: unknown };
type Result<T> =
| { ok: true; data: T }
| { ok: false; error: ApiError };
async function readResponse(response: Response): Promise<unknown> {
const type = response.headers.get('content-type') ?? '';
return type.includes('application/json')
? response.json()
: response.text();
}
HTTPレスポンスを受け取れた場合、4xxは要求側、5xxは提供側の失敗として分類する。401と403、404、409、422などは画面の文脈に応じた処理が必要だが、共通層ではまず範囲を保持すればよい。
async function requestJson<T>(
input: RequestInfo | URL,
init: RequestInit,
validate: (value: unknown) => value is T,
): Promise<Result<T>> {
let response: Response;
try {
response = await fetch(input, init);
} catch (cause: unknown) {
if (cause instanceof DOMException && cause.name === 'AbortError') {
return { ok: false, error: { kind: 'timeout' } };
}
return { ok: false, error: { kind: 'network', cause } };
}
let body: unknown;
try {
body = await readResponse(response);
} catch (cause: unknown) {
return { ok: false, error: { kind: 'invalid-response', cause } };
}
if (response.status >= 400 && response.status < 500) {
return { ok: false, error: { kind: 'client', status: response.status, body } };
}
if (response.status >= 500) {
return { ok: false, error: { kind: 'server', status: response.status, body } };
}
if (!validate(body)) {
return { ok: false, error: { kind: 'invalid-response', cause: body } };
}
return { ok: true, data: body };
}
fetchは4xxや5xxではrejectしないため、try/catchだけではHTTPエラーを捕捉できない。反対に、ネットワーク障害にはステータスコードが存在しない。存在しないstatusを0として扱うより、判別共用体で種類を分けた方が分岐漏れをTypeScriptで検知できる。
再試行可能性を独立した関数にする
一般に、一時的なネットワーク失敗、タイムアウト、一部の5xx、429は再試行候補になる。認証不足、権限不足、入力不正、存在しないリソースは、同じ要求を送っても改善しない。さらにPOSTのような操作は、サーバー側では完了したが応答だけ失われた可能性があり、無条件の再送で二重登録を起こし得る。
function isRetryable(error: ApiError, method: string): boolean {
const safeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase());
if (!safeMethod) return false;
if (error.kind === 'network' || error.kind === 'timeout') return true;
if (error.kind === 'server') return [502, 503, 504].includes(error.status);
if (error.kind === 'client') return error.status === 429;
return false;
}
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function withRetry<T>(
operation: () => Promise<Result<T>>,
method = 'GET',
maxAttempts = 3,
): Promise<Result<T>> {
let result = await operation();
for (let attempt = 1; attempt < maxAttempts; attempt += 1) {
if (result.ok || !isRetryable(result.error, method)) return result;
await wait(300 * 2 ** (attempt - 1));
result = await operation();
}
return result;
}
maxAttemptsは最初の実行を含む上限である。while(true)や再帰だけで終了条件を持たない実装は避ける。待機時間を段階的に増やせば障害中のサーバーへ短時間で要求を集中させにくい。多数のクライアントが同時に再試行する環境では、待機へ小さなランダム差を加える設計も有効である。
429ではRetry-Afterヘッダーを尊重したい。この情報を使う場合は、エラー型にレスポンスヘッダーから得た再試行時刻を保持し、不正な値や過大な待機時間を上限で抑える。画面が破棄された後も待機を続けないよう、実運用ではAbortSignalをoperationと待機処理へ渡す。
トースト通知を通信層から分離する
APIクライアントが直接トーストを表示すると、同じ関数をバックグラウンド更新で使っただけでも通知が出る。表示文と通知方法はUI層で決める。
function messageFor(error: ApiError): string {
switch (error.kind) {
case 'network':
return 'ネットワーク接続を確認してください。';
case 'timeout':
return '応答に時間がかかっています。再度お試しください。';
case 'client':
if (error.status === 401) return 'ログインが必要です。';
if (error.status === 403) return 'この操作を行う権限がありません。';
if (error.status === 404) return '対象のデータが見つかりません。';
return '入力内容またはリクエストを確認してください。';
case 'server':
return 'サーバーで問題が発生しました。時間をおいてお試しください。';
case 'invalid-response':
return '受信したデータを処理できませんでした。';
}
}
const result = await withRetry(loadArticles, 'GET');
if (!result.ok) {
toast.show(messageFor(result.error));
}
トーストは補助的な通知であり、重要な失敗をトーストだけに置かない。記事一覧の読み込みに失敗したなら、一覧領域にもエラー状態と再実行ボタンを表示する。フォームの422なら、可能な場合は対応する入力欄の近くへエラーを示す。どの失敗も同じ赤い通知へ変換するのではなく、利用者が次に取れる行動へ結び付ける。
自動再試行中に毎回トーストを出すと通知が重なるため、最終失敗時だけ表示する。手動の再試行ボタンは連打を防ぎ、実行中状態を示す。POSTを再試行する必要があるAPIでは、サーバー側のidempotency key対応を確認し、クライアント側だけで安全だと決めない。
エラーハンドリングはcatch節を増やす作業ではない。低レベルの失敗を分類し、再試行方針を純粋な関数へ分け、画面の文脈で通知と復旧操作を決める設計である。この分離によって、新しいステータスや画面が増えても通信処理全体を書き直さずに済む。