API移行で画面を止めないためにやったこと: 認証エラーの揺れを潰す

認証まわりと一覧APIの移行が重なった時期に、失敗時の挙動をAPI入口へ集約し、画面ごとの差異を解消した実装ログです。

はじめに

記録時点: 2026-01〜2026-02

この時期は、認証まわりの調整と一覧APIの移行が同時に進んでいました。 新機能を足すより先に手を入れたのは、失敗時の挙動です。

当時いちばん困っていたのは「失敗すること」ではなく、 同じ失敗でも画面ごとに意味が変わってしまうことでした。

この記事で扱う範囲

  • 認証失敗時の復帰導線をどう一本化したか
  • 一覧API移行で起きた画面ごとの追従漏れをどう減らしたか
  • 実装時に見送った案と、その理由

固有の識別子や内部定数は省き、再利用しやすい粒度で書きます。

症状は「APIが落ちる」より厄介だった

現場では次のように見えていました。

  • 画面A: 同じ失敗でも再ログインへ戻る
  • 画面B: 同じ失敗なのにエラー表示だけ出て画面に残る
  • 画面C: 画面上はログイン中に見えるが、データ取得だけ失敗し続ける

問い合わせとしては「さっきまで使えていたのに急に止まった」と報告されるため、 再現条件が曖昧になりやすく、調査が長引く状態でした。

根本原因は「失敗系の責務が分散していること」

最初は 401/403 の扱いの違いを疑いましたが、 追っていくと問題はステータスコードそのものではありませんでした。

本質は次の2点です。

  • 失敗判定が画面側に分散し、復帰導線が統一されていない
  • 一覧APIの呼び出し条件が画面ごとに違い、契約変更時に追従漏れが出る

つまり、成功系のコードより失敗系の責務配置が崩れていました。

変更前後の構成

認証失敗ハンドリングの変更前後

図の左側が移行前、右側が移行後です。 やったことはシンプルで、失敗時の処理を API の入口へ寄せました。

実装で変えたポイント

1. 認証失敗の処理を API 入口へ集約した

まず、401/403 を画面で個別判定するのをやめ、共有ゲートウェイ側で処理するように変更しました。

export async function requestWithGuard(params: RequestParams): Promise<ApiResult> {
  const response = await callApi(params);
 
  if (response.status === 401 || response.status === 403) {
    clearAuthData();
    resetUserState();
    redirectToLogin({ reason: "reauth-required" });
    throw new Error("reauth-required");
  }
 
  if (!response.ok) {
    throw new Error("request-failed");
  }
 
  return response.data;
}

この変更で、失敗時の導線が画面依存ではなくなりました。

2. ユーザー特定の優先順位を固定した

認証情報が半端に残るケースで挙動がぶれやすかったため、 ユーザー特定に使う入力を優先順位付きで解決するようにしました。

function resolveUserKey(input: {
  authClaims?: { userKey?: string };
  fallbackInfo?: { userKey?: string };
}): string {
  if (input.authClaims?.userKey) return input.authClaims.userKey;
  if (input.fallbackInfo?.userKey) return input.fallbackInfo.userKey;
  throw new Error("user-key-not-found");
}

「どれか取れれば使う」ではなく、順序を固定したことが効いています。

3. 一覧取得と詳細取得の責務を分けた

一覧画面で必要な項目と詳細画面で必要な項目を分離し、 一覧API移行時に画面側の分岐が増えないように整理しました。

export function toListQuery(input: Partial<ListQuery>): ListQuery {
  return {
    page: normalizePage(input.page),
    pageSize: normalizePageSize(input.pageSize),
    filter: normalizeFilter(input.filter),
  };
}
 
export function mapListItem(raw: RawListItem): ListItem {
  return {
    id: raw.id,
    title: raw.title,
    updatedAt: raw.updatedAt,
  };
}

契約の境界を明示したことで、API変更時に直す箇所を小さく保てました。

検討して見送った案

1. 画面ごとに最小修正して逃げ切る

短期では速いですが、API変更のたびに同じ修正が再発します。 移行期間が長いほど総コストが増えるため見送りました。

2. 401 と 403 を完全に別導線で扱う

意味の違いを残す案もありましたが、利用者体験が画面ごとにぶれる副作用が大きく、 まずは復帰導線を揃える方を優先しました。

3. 一覧APIの互換ロジックを画面側に残す

移行初期は楽ですが、追従漏れの温床になります。 互換吸収は API 入口で持つ方針に統一しました。

回帰テストで固定した観点

移行期のテストは、成功系より失敗系を厚くしています。

観点 期待値
認証情報なしでAPI呼び出し ログイン導線へ戻る
401/403応答 同じ復帰導線に入る
失敗後の画面状態 「ログイン中表示」が残らない
一覧クエリの正規化 画面差分なく同じ契約で呼べる
一覧→詳細の遷移 契約変更後も表示が崩れない

今回の判断が次に効いたこと

この回で効いた「失敗系を入口で吸収する」方針は、次の改善にもそのまま使えました。

どちらも、画面個別の対症療法ではなく、境界で責務を整理したほうが再発を抑えやすいテーマです。

まとめ

今回の移行で効いたのは、APIを新しくすること自体ではありませんでした。 失敗時の扱いを1か所に寄せ、一覧契約の境界を明確にしたことで、 移行中でも画面挙動を揃えたまま変更を進められるようになりました。

移行期は成功系より失敗系がシステム全体の印象を決めるので、 先にそこを揃えるのが結果的に最短だった、というのがこの回の結論です。

公開リンク