「たまに画面が固まって、何も出ないまま動かなくなる」——受託で納めたシステムについて、こんな曖昧な不具合報告が届くことがあります。調べると、原因は外部 API への fetch でした。連携先が一時的に詰まっているのにタイムアウトを設定しておらず、応答を待ち続けて画面が無言で固まる。あるいは、連携先がレスポンスの形をこっそり変え、data.items が undefined になった状態で配列メソッドを呼んでクラッシュする。fetch はブラウザにも Node.js にも標準で入っていて手軽ですが、標準のままでは「失敗したとき」の備えが何もない。その素の fetch を画面のあちこちに直書きしていると、失敗の扱いがバラバラになり、こうした不具合が起きるたびに該当箇所を一つずつ直すことになります。
この散らばりを断つには、タイムアウト・リトライ・エラー処理・レスポンス検証を一か所に集約した「APIクライアント層」を設けます。InfoQ が 2026 年 6 月に報じた Ky 2.0 Fetch API Wrapper(InfoQ) は、fetch を薄く包んでこうした実務上の備えを標準装備した軽量ライブラリで、その設計は自前でクライアント層を作るときの良い手本になります。本記事では、受託で「失敗に強い」HTTP通信を作るために、何を一か所に集約すべきかを整理します。
なぜ素の fetch 直書きが障害を生むのか
fetch をそのまま各コンポーネントで呼ぶ書き方は、最初は何の問題もありません。障害につながるのは、fetch が標準で面倒を見てくれない領域が、現実の運用で必ず顔を出すからです。
第一に、タイムアウトが無い。素の fetch は、相手が応答しなければ既定では待ち続けます。連携先が詰まると、ユーザーには「固まった画面」だけが残り、何が起きているか分かりません。
第二に、HTTPエラーで例外を投げない。fetch は 404 や 500 が返っても、ネットワーク的に通信できた以上「成功」として解決します。response.ok を毎回チェックしないと、エラーレスポンスの本文を正常データとして処理してしまう。直書きだと、このチェックを書き忘れた箇所から事故が起きます。
第三に、一時的な失敗をリトライしない。混雑による一時的な失敗(タイムアウトや 503 など)は、少し待って再試行すれば通ることが多い。素の fetch は当然それをしないので、各所で自前のリトライを書くか、書かずに諦めるかになります。
第四に、レスポンスの形を検証しない。fetch は受け取った JSON を、そのまま信頼します。連携先が形を変えても、実行時にどこか遠くで undefined を触ってクラッシュするまで気づけません。
これらを「呼び出すたびに各自が気をつける」のは無理があります。一か所に集約し、呼ぶ側は素の通信を意識しなくていい状態を作るのが、堅牢化の本筋です。
集約すべき4つの責務
APIクライアント層に持たせるべき責務は、上記の裏返しで四つです。fetch を薄く包んだ関数として、これらをまとめて持たせます。
| 責務 | 素の fetch | 集約後 |
|---|---|---|
| タイムアウト | 無し(待ち続ける) | 既定値を一律設定(例: 10秒) |
| HTTPエラー | 例外を投げない | 4xx/5xx を例外化して握りやすく |
| リトライ | 無し | 一時的失敗のみ指数バックオフで再試行 |
| レスポンス検証 | 形を信頼 | スキーマで検証してから返す |
Ky のような軽量ラッパーは、まさにこの四つを既定で備えています。タイムアウトとリトライが最初から有効で、HTTP エラーは例外として投げられ、応答のスキーマ検証もフックで差し込める。自前で作る場合も、目指す形は同じです。AbortController でタイムアウトを実装し、response.ok を見て例外化し、一時的なエラーだけ再試行し、返す前にスキーマで形を確かめる——これらを一つの関数に閉じ込めます。
// 集約したAPIクライアントの骨子(自前実装の例)
import { z } from "zod";
async function apiClient<T>(
url: string,
schema: z.ZodType<T>,
{ retries = 2, timeoutMs = 10_000 }: { retries?: number; timeoutMs?: number } = {},
): Promise<T> {
for (let attempt = 0; ; attempt++) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs); // タイムアウト
try {
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) {
// 一時的なエラー(5xx)だけ再試行、4xxは即失敗
if (res.status >= 500 && attempt < retries) {
await wait(2 ** attempt * 300); // 指数バックオフ
continue;
}
throw new ApiError(res.status, await res.text());
}
// 返す前に形を検証する。ここを通ればT型を保証できる
return schema.parse(await res.json());
} catch (err) {
if (isTransient(err) && attempt < retries) {
await wait(2 ** attempt * 300);
continue;
}
throw err; // 再試行しても駄目なら呼び出し側へ
} finally {
clearTimeout(timer);
}
}
}
呼ぶ側は、apiClient("/api/orders", OrderListSchema) のように、URL と期待する形だけを渡します。タイムアウトもリトライも検証も、もう各画面で意識しなくていい。失敗の扱い方が一か所に集まるので、方針を変えたいとき(タイムアウトを延ばす、リトライ回数を変える)も一か所を直すだけで全体に効きます。
レスポンス検証を「型の入口」にする
四つの責務のうち、受託で特に効くのがスキーマ検証です。TypeScript の型注釈は、コンパイル時には嘘をつけても、実行時には何も守ってくれません。fetch の戻りを as OrderList とキャストするのは、「形が合っているはず」という願望をコンパイラに飲ませているだけで、連携先が実際に違う形を返せば、その嘘は実行時に遠くの箇所で露呈します。
スキーマ検証(上の例の schema.parse)を通すと、APIクライアントがシステムの境界で形を確かめる関所になります。連携先が形を変えたら、そのデータを使う遠くのコンポーネントではなく、データが入ってきた入口で、明確なエラーとして検出できる。受託では、この「壊れたときに、原因の近くで止まる」性質が、障害調査の時間を大きく縮めます。連携先が信頼できない外部であるほど、入口での検証は効きます。外部サービスや第三者スクリプトを信頼しすぎることのリスクは、サプライチェーン監査の記事で扱った「外から入るものを無検査で信じない」姿勢と同じ発想です。
この集約したクライアント層は、フロントエンドだけでなく、自前のバックエンドが別のサービスを呼ぶ場面でも同じく有効です。サーバー側で外部 API を束ねる設計については、Honoでバックエンドを作る記事で扱ったレイヤー分割と組み合わせると、通信の堅牢化とルーティングの整理を一度に進められます。
受託で導入するときの落とし穴
弊社が改修を引き継いだあるアパレル EC の管理画面(社名は伏せます)では、冒頭の「たまに固まる」がまさに起きていました。外部の在庫 API への fetch が二十数か所に直書きされ、タイムアウトもエラーチェックも箇所ごとにバラバラ。ある画面は response.ok を見ておらず、API がメンテナンス中に返す HTML を JSON として処理しようとしてクラッシュしていました。
私たちは作り直しではなく、まず集約したAPIクライアント層を一つ用意し、新規・改修する箇所から順にそこへ寄せました。タイムアウトを一律 10 秒、5xx のみ二回まで指数バックオフ、レスポンスは在庫スキーマで検証——という方針を一か所に決め、呼び出し側からは素の fetch を消していった。古い直書きは触るタイミングで置き換える方針にし、一括変換は避けました。やったのは、散らばっていた失敗時の備えを一か所に集めただけです。結果、メンテナンス中の HTML を掴んでのクラッシュは入口で明確なエラーになり、一時的な詰まりはリトライで自然に回復するようになって、「たまに固まる」という曖昧な報告自体が止まりました。
この案件で一番効いた学びは、リトライは「安全に再試行できる操作」だけに限るということでした。データ取得(GET)は何度試しても副作用がありませんが、注文確定のような操作を素朴にリトライすると、二重登録を生みます。集約したクライアントでリトライを既定で有効にする場合は、書き込み系の操作を対象から外すか、再試行しても結果が変わらない仕組み(冪等キー)を顧客側 API と合意したうえで有効にする。「とりあえず全部リトライ」は、別の障害を作ります。
もう一つの落とし穴は、エラーを握りつぶすことです。集約したクライアントが例外を投げても、呼び出し側が catch して何も表示しなければ、ユーザーには結局「無言の失敗」が残ります。クライアント層は失敗を「明確な例外」にするところまでが仕事で、それをユーザーにどう伝えるか(再試行ボタン・エラーメッセージ)は、呼び出し側の責務として別に設計します。
どこから着手するか
fetch がコードのあちこちに直書きされていて、失敗時の挙動が箇所ごとに違うなら、APIクライアント層を一つ設ける価値があります。完璧なライブラリ選定から入る必要はありません。タイムアウト・HTTPエラーの例外化・限定的なリトライ・スキーマ検証——この四つを一つの関数に閉じ込めるところから始められます。
最初の一歩は、いちばん事故が起きている外部 API の呼び出しを一つ選び、それを集約クライアント経由に置き換えること。既存の直書きは無理に一括変換せず、改修のついでに少しずつ寄せれば、差分もレビューも小さく保てます。軽量ライブラリ(Ky など)を使うか自前で薄く作るかは、依存を増やしたくないか・既定の手厚さが欲しいかで判断すれば十分です。
外部API連携で時々固まる・形が変わるとクラッシュする、通信まわりの実装が散らばって保守しづらい——そうしたお悩みがあれば、グリームハブのお問い合わせからご相談ください。現行システムの通信層を拝見し、失敗に強いAPIクライアントへ段階的に寄せる設計をご一緒に組みます。