第22章:HTTP失敗を分ける(通信 vs HTTPステータス)🌐🚦
この章でやりたいことはシンプル👇✨ 「HTTPが失敗したっぽい」を、ちゃんと“別の失敗”に分けることです😊
- ✅ 通信に失敗した(そもそも届いてない)
- ✅ HTTPとして返ってきたけど失敗(404/500 など)
- ✅ タイムアウト(待つのをやめた)
- ✅ キャンセル(ユーザーが戻った等で中断)
- ✅ JSON壊れてる / 期待と違う(パース or 検証で失敗)
これが分けられると、UIも運用もめっちゃ強くなります💪💖
1) まず知っておく「fetchの罠」🕳️😱
fetch() は ネットワーク系の失敗だけ Promise を reject します。
404 や 500 は reject しません(普通に resolve して Response が返ってきます)😵💫
だから catch だけ見てると事故るんだよね💥 (MDN Web Docs)
そして「成功かどうか」は response.ok(200〜299)で判断します✅ (MDN Web Docs)
2) “失敗の種類”を地図にする🗺️🏷️
![HTTP信号機:通信エラー、HTTP失敗、正常応答を振り分ける[(./picture/err_model_ts_study_022_http_traffic_light.png)
ここから先、HTTPクライアントの結果を いつも同じ形に揃えます✨
A. 通信失敗(ネットワーク)📡❌
例:
- オフライン、DNS失敗、接続拒否、TLS失敗
- ブラウザだと CORSもネットワークエラーっぽく見える(詳細は隠されがち) (MDN Web Docs)
B. タイムアウト⏳💥
fetch はタイムアウトを自動でやってくれないので、自分で中断する必要あり。
AbortSignal.timeout(ms) が使える環境なら超ラク(Node.js でも定義あり)⏱️ (Node.js)
C. キャンセル(Abort)🛑👋
画面遷移・検索入力の連打などで「前の通信いらない!」ってなるやつ。
D. HTTPステータス失敗(4xx/5xx)🚦
返ってきてるのがポイント。届いてる。だから“通信”とは別。
- 4xx = クライアント側がミスってそう (RFCエディタ)
- 5xx = サーバー側が死んでそう (IETF Datatracker)
- ステータス一覧はMDNが見やすいよ📚 (MDN Web Docs)
さらに、429/503 は Retry-After で「何秒待って」って来ることがあるので、リトライ設計と相性がいい✨ (MDN Web Docs)
E. パース失敗 / 形式違い🧩😵
- 204 No Content なのに
json()呼んで爆死 - JSON壊れてる
Content-Typeが想定と違う
3) “統一結果”の設計(Resultで返す)🎁🌈
ここでは Promise<Result<T, HttpError>> を返す形にします😊
(第19章の AsyncResult のノリだね⚡)
エラー型の例(判別可能ユニオン)🏷️
export type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
export const Ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
export const Err = <E>(error: E): Result<never, E> => ({ ok: false, error });
export type HttpError =
| {
kind: "Network";
message: string;
url: string;
retryable: true;
cause?: unknown;
}
| {
kind: "Timeout";
message: string;
url: string;
retryable: true;
timeoutMs: number;
cause?: unknown;
}
| {
kind: "Aborted";
message: string;
url: string;
retryable: false;
cause?: unknown;
}
| {
kind: "HttpStatus";
message: string;
url: string;
status: number;
retryable: boolean;
retryAfterMs?: number;
// 取り出せた範囲で(安全な範囲で)ボディを少しだけ持つのもアリ
bodyText?: string;
}
| {
kind: "Parse";
message: string;
url: string;
retryable: false;
contentType?: string | null;
cause?: unknown;
};
4) fetchラッパー実装(成功/失敗を統一する)🧰✨
ポイントは3つ👇
- タイムアウト or Abort を用意
fetchのtry/catchは 通信系を拾うresponse.okで HTTP失敗を拾う (MDN Web Docs)
const getRetryAfterMs = (res: Response): number | undefined => {
const v = res.headers.get("Retry-After");
if (!v) return undefined;
// Retry-After は seconds か HTTP-date
const seconds = Number(v);
if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000;
const dateMs = Date.parse(v);
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
return undefined;
};
const isAbortError = (e: unknown): boolean =>
typeof e === "object" &&
e !== null &&
"name" in e &&
(e as any).name === "AbortError";
const toNetworkLikeError = (url: string, e: unknown): HttpError => {
// ブラウザの fetch はネットワークエラーを TypeError で返すことが多い
// CORS もここに寄ることがある :contentReference[oaicite:9]{index=9}
return {
kind: "Network",
message: "通信に失敗したよ…(ネットワーク)",
url,
retryable: true,
cause: e,
};
};
export async function fetchJson<T>(
url: string,
init: RequestInit & { timeoutMs?: number } = {}
): Promise<Result<T, HttpError>> {
const { timeoutMs = 10_000, ...rest } = init;
// AbortSignal.timeout がある環境なら使う(Node.js でも定義あり) :contentReference[oaicite:10]{index=10}
const controller = new AbortController();
let timeoutId: number | undefined;
const signal =
typeof (AbortSignal as any)?.timeout === "function"
? (AbortSignal as any).timeout(timeoutMs)
: undefined;
// 2つの signal をまとめたい時は AbortSignal.any が便利だけど、
// 環境差や既知の挙動差もありえるので、ここでは “手動で中断” の安定版に寄せる🙆♀️
// (=タイマーで controller.abort())
timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...rest, signal: controller.signal });
// HTTPとして返ってきたけど失敗(404/500など)
if (!res.ok) {
const retryAfterMs = getRetryAfterMs(res); // 429/503 で来ることがある :contentReference[oaicite:11]{index=11}
// 可能ならボディを少し読む(読みすぎ注意)
let bodyText: string | undefined;
try {
bodyText = await res.text();
if (bodyText.length > 500) bodyText = bodyText.slice(0, 500) + "…";
} catch {
// 読めないなら無視でOK
}
const retryable =
res.status === 429 || res.status === 503 || res.status === 502 || res.status === 504;
return Err({
kind: "HttpStatus",
message: `サーバーがエラーを返したよ(HTTP ${res.status})`,
url,
status: res.status,
retryable,
retryAfterMs,
bodyText,
});
}
// 成功:JSONを読む(Content-Typeチェックは好みで強化)
try {
const data = (await res.json()) as T;
return Ok(data);
} catch (e) {
return Err({
kind: "Parse",
message: "JSONの読み取りに失敗したよ…(形式が想定と違うかも)",
url,
retryable: false,
contentType: res.headers.get("Content-Type"),
cause: e,
});
}
} catch (e) {
if (isAbortError(e)) {
// タイムアウト or キャンセルを区別したいなら、別のフラグを持つとさらに良い
return Err({
kind: "Timeout",
message: "タイムアウトしたよ…(待ち時間オーバー)",
url,
retryable: true,
timeoutMs,
cause: e,
});
}
return Err(toNetworkLikeError(url, e));
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
}
※
fetch自体の仕様は Fetch Standard(Living Standard)で定義されてて、更新も続いてるよ📜✨(最終更新 2026-01-13) (Fetch Standard)
5) 使う側:UIで“反応”を変える😊🎀
const r = await fetchJson<{ name: string }>("/api/me");
if (r.ok) {
console.log("こんにちは", r.value.name);
} else {
switch (r.error.kind) {
case "Network":
// 例:トースト「通信環境を確認してね」
break;
case "Timeout":
// 例:再試行ボタンを出す
break;
case "Aborted":
// 例:何もしない(検索の途中キャンセルとか)
break;
case "HttpStatus":
// 例:401ならログイン誘導、429/503なら少し待って再試行など
break;
case "Parse":
// 例:障害報告導線(ログは詳しく、表示は簡潔に)
break;
}
}
6) “リトライしていい?”の目安🔁🧠
HTTPの意味としてはざっくりこんな感じ👇(超実用だけに絞るね😊)
-
✅ リトライしがち:Network / Timeout / 502 / 503 / 504 / 429
- 503 は “一時的に無理” が多い (MDN Web Docs)
- 429/503 は
Retry-Afterが来ることがある (MDN Web Docs)
-
❌ だいたいリトライしない:400 / 401 / 403 / 404 / 422 (入力・認証・権限・URLミスのことが多い)
-
⚠️ POST等はリトライ注意:二重購入みたいな事故が起きるので、やるなら「冪等性キー」等が必要(第29章でやる予定の領域だよ🧷🙂)
7) ミニ演習📝💖
演習1:失敗をわざと起こして分類する🧪
- 404 を返すURL(存在しない)
- 500 を返すURL(テスト用)
- オフラインにして Network
- timeoutMs を短くして Timeout
「どれがどの kind になる?」を表にしてね📋✨
演習2:HttpStatus の扱いを丁寧にする🎀
- 401 → ログイン導線
- 403 → 権限なし表示
- 404 → “見つからない”
- 429/503 → “少し待って再試行” + Retry-Afterがあれば秒数表示
演習3:Parse を強化する🧼
Content-Typeがapplication/jsonじゃなかったら Parse 扱い- 204 No Content は
Ok(undefined)にするとか、方針を決めて実装✨
8) AI活用プロンプト🤖💬(コピペOK)
- 「このAPI呼び出しの失敗ケースを、Network/Timeout/HttpStatus/Parse に分類して一覧にして」
- 「HTTP 4xx/5xx のうち、ユーザーに“再試行”を出すべきものだけ理由付きで選んで」 (RFCエディタ)
- 「この
HttpError型、情報が多すぎ/少なすぎをレビューして。改善案も」 - 「Retry-After を seconds と HTTP-date の両方で解釈する実装にして」 (MDN Web Docs)
まとめ🎉✨
fetchは 通信失敗だけ catch、HTTPエラー(404/500)はresponse.okで拾う (MDN Web Docs)- 失敗を Network / Timeout / Abort / HttpStatus / Parse に分けると、UIも運用も強くなる💪💖
- 429/503 は
Retry-Afterも見て、リトライ設計に活かすとスマート🔁✨ (MDN Web Docs)
次の章(第23章)は、相手APIのエラー形式がバラバラでも、こっち側で“正規化”して統一する話に繋がるよ〜🌩️🧼