本文へ移動
aapp-hacks
← 記事一覧へ
著者
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節を増やす作業ではない。低レベルの失敗を分類し、再試行方針を純粋な関数へ分け、画面の文脈で通知と復旧操作を決める設計である。この分離によって、新しいステータスや画面が増えても通信処理全体を書き直さずに済む。