順番に読み進めながら学べます

fetch のエラー処理とタイムアウト — AbortController と再試行

fetch が例外を投げない HTTP エラーと、fetch 自身が失敗するネットワークエラーの見分け方、AbortController と setTimeout による時間切れの打ち切り、1 回だけの再試行を扱います。

response.ok を読めば、ファイルが無いという応答は見分けられます。通信が切れていると、ok を読む前の await fetch の行で例外になります。応答が遅いと、次の行へ進まずに待ち続けます。

この記事では、fetch の失敗を種類ごとに例外として受け取る書き方と、AbortController で取得を打ち切る書き方を扱います。

失敗の応答を例外に変える — throw new Error

注文の一覧を、3 つの画面から読み込むとします。前の記事の loadJson のように失敗のときに null を返すと、画面ごとに null かどうかを確かめる必要があり、1 か所でも忘れると、その先で値を読んだ行が止まります。

HTTP エラー(サーバーに届いたが、status が 403 や 404 などの失敗を表す応答)では、fetch は例外を投げません。ok が false のときに throw new Error(...) で投げると、呼び出し側は try / catch の 1 か所で受け取れます。

// ok が false なら、status を添えて例外を投げる
async function loadJson(path) {
  const response = await fetch(path);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${path}`);
  }
  return await response.json();
}

try {
  const orders = await loadJson("fixtures/order.json");   // この行で例外になる
  console.log(`注文 ${orders.length} 件`);                // 実行されない
} catch (error) {
  // 練習問題のコンソールは 403、ほかのサーバーでは 404 など
  console.log(error.message);   // HTTP 403: fixtures/order.json
}
throw すると原因が catch に届く
ok が false ならnull を返すorders にnull が入るorders.lengthを読むTypeError で止まり403 が残らないok が false ならthrow するawait loadJson の行で例外になる残りの行を飛ばしcatch へ移るerror.message はHTTP 403: …
null を返すと、orders.length の行で別のエラーになります。throw すれば、status 入りの文言で catch に届きます。

throw にしておけば、catch を書き忘れた画面でも、止まったときの文言に HTTP 403 が残ります。画面ごとに null を確かめる if を書く代わりに、catch で受け取る場所を決めるだけで済みます。

配送状況の画面で、注文の一覧を読み込みます。関数 loadOrders の枠は宣言済みで、2 つのパスはコンソールの先頭にあります。

① 失敗の応答が返ったら、「注文を読み込めません: 」にパスを続けた例外を投げてください。

② 成功したら、本文を配列にして返してください。

③ 正しいパスで読み込み、注文の合計金額を表示してください。

④ 書き間違えたパスで読み込み、失敗したら例外の文言を表示してください。

(正しく実行できれば解説が表示されます)

JavaScript / TypeScript エディタ

コードを実行してください

通信の失敗を区別する — TypeError と instanceof

外出先で電波が途切れると、要求はサーバーに届かず、応答もありません。この失敗も HTTP エラーと同じ catch に届くため、そのままでは「ファイルが無い」と「通信できない」に同じ案内を出してしまいます。

ネットワークエラー(通信が切れている・ドメインが無いなどで、応答が届かない失敗)では、fetch が返す Promise が TypeError で rejected になります。catch で instanceof TypeError を調べれば、自分で投げた Error と区別できます。

// loadJson は前の章と同じ(ok が false なら例外を投げる)
const urls = [
  "fixtures/order.json",                // サーバーに届くが、ファイルが無い
  "https://shop.invalid/orders.json",   // .invalid のドメインは、どこにもつながらない
];

for (const url of urls) {
  try {
    await loadJson(url);
  } catch (error) {
    // 自分で投げた Error か、fetch が投げた TypeError か
    const kind = error instanceof TypeError ? "通信できない" : "応答が失敗";
    console.log(`${kind} / ${error.name}: ${error.message}`);
  }
}
// 応答が失敗 / Error: HTTP 403: fixtures/order.json
// 通信できない / TypeError: Failed to fetch
2 種類の失敗が生まれる場所
呼び出し側の try — await loadJson(url) の行
  • どちらの失敗も、この行で例外になって catch へ移る
  • catch で error instanceof TypeError を調べて分ける
loadJson の中
  • if (!response.ok) — fixtures/order.json は応答が届き、ok は false
  • throw new Error(...) — 自分で投げる Error
await fetch(url) の中
  • https://shop.invalid/… — 応答が届かない
  • TypeError: Failed to fetch — fetch が投げる
HTTP エラーは loadJson が投げ、ネットワークエラーは fetch が投げます。どちらも同じ catch に届くので、種類で見分けます。

内側の fetch で起きた TypeError は、loadJson の if まで進まずに外へ出ます。どちらの案内を出すかは画面ごとに違うので、loadJson では投げるだけにして、両方が届く呼び出し側の catch で種類を調べます。

通信の失敗の文言はブラウザごとに違う

fetch が投げる TypeError の message は、Chrome では Failed to fetch、Safari では Load failed と、ブラウザで違います。文言の一致で判定すると、ほかのブラウザで別の案内に分かれます。種類は instanceof TypeError で調べます。

在庫管理の画面で、失敗の種類ごとに案内を変えます。loadJson と、3 つの URL が入った urls は宣言済みです。

① showStock で商品の一覧を読み込み、「商品 4 件」の形で表示してください。

② サーバーに届かなかったときは「サーバーにつながりません」と表示してください。

③ それ以外の失敗では「商品の一覧を読み込めません」と表示してください。

④ urls を順に showStock に渡してください。

JavaScript / TypeScript エディタ

コードを実行してください

時間切れで取得を打ち切る — AbortController

混み合ったサーバーは、応答を返すまでに何十秒もかかることがあります。fetch には待つ時間の上限を指定する引数が無いので、await fetch の行は応答が届くまで進まず、画面は読み込み中のまま止まって見えます。この章のコードでは、応答に 300ms かかる slowFetch を fetch の代わりに使います。

AbortController(fetch を途中で打ち切るオブジェクト)を作り、fetch(url, { signal: controller.signal }) と渡すと、abort() を呼んだときに AbortError で失敗します。

setTimeout が返すタイマー ID(予約を指す数値)を clearTimeout に渡すと取り消せます。

// slowFetch は、実践 3 のコンソールに用意した、応答に 300ms かかる fetch の代わり(使い方は fetch と同じ)
const controller = new AbortController();

// 100ms 後に abort() を呼ぶように予約し、タイマー ID を受け取る
const timerId = setTimeout(() => controller.abort(), 100);

try {
  // fetch と同じく、2 番目の引数に signal を入れて渡す
  const response = await slowFetch("fixtures/orders.json", { signal: controller.signal });
  console.log(response.ok);     // 応答は 300ms 後なので、ここには来ない
} catch (error) {
  console.log(error.name);      // AbortError
} finally {
  clearTimeout(timerId);        // 応答が先に届いたときのために、予約を取り消す
}
タイマーと応答のどちらが先か
制限 100ms応答は 300ms 後100ms の時点でabort() が動くfetch がAbortError で失敗catch で時間切れを知らせる制限 3000ms応答は 300ms 後300ms の時点で応答が届くfinally でclearTimeoutabort() は呼ばれない
制限時間より応答が遅いと、abort() が先に動いて取得が失敗します。間に合ったときは、予約した abort() を取り消します。

clearTimeout を finally に書くのは、成功でも失敗でも予約を残さないためです。本文を読み終える前に abort() が動くと json() も AbortError で失敗するので、return await response.json() まで try に入れ、本文を読み終えてから finally に進めます。

契約プランの確認画面で、会員の一覧の読み込みに制限時間を設けます。遅い API の代わりの slowFetch は宣言済みです。

① loadUsers の中で、制限時間がたったら打ち切るように予約してください。

② slowFetch で、打ち切れるようにして会員の一覧を取得し、本文の配列を返してください。

③ 成功しても失敗しても、予約を取り消してください。

④ 制限 3000ms で人数を、制限 100ms で失敗の種類の名前を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください

1 回だけ取得し直す — 再試行と return await

電車で移動中の端末では、通信が一瞬だけ途切れて取得に失敗し、すぐに試し直せば成功することがあります。失敗のたびに利用者へ再読み込みを頼むより、コードの中で自動で取得し直すほうが、画面の表示が途切れません。

再試行(失敗した取得を、同じ URL でもう一度行うこと)は、catch の中でもう一度呼び出して書きます。回数に上限を付けないと、つながらない間ずっと要求を送り続けるので、2 回目の失敗はそのまま呼び出し側へ投げます。

// loadJson は最初の章と同じ。通信の失敗と時間切れのときだけ、1 回だけ取得し直す
async function loadWithRetry(path) {
  try {
    return await loadJson(path);
  } catch (error) {
    const retryable = error instanceof TypeError || error.name === "AbortError";
    if (!retryable) {
      throw error;                                   // 403 や 404 は、送り直しても同じ応答が返る
    }
    console.log(`${error.name} のため再試行します`);   // TypeError のため再試行します
    return await loadJson(path);                     // 2 回目の失敗は、そのまま呼び出し側へ出る
  }
}

try {
  await loadWithRetry("https://shop.invalid/orders.json");
} catch (error) {
  console.log(`2 回とも失敗しました: ${error.name}`);   // 2 回とも失敗しました: TypeError
}
再試行は 1 回までで打ち切る
loadWithRetry(path) を呼ぶ1 回目成功1 回目TypeError1 回目TypeError2 回目は呼ばない2 回目成功2 回目もTypeError1 回目の配列を返す再試行の表示の後配列を返す3 回目は無く例外を投げる
1 回目が成功すれば、2 回目は呼びません。2 回目も失敗したら 3 回目は無く、例外が呼び出し側へ出ます。

fixtures/order.json のような HTTP エラーは、1 回目の catch で投げ直すので、2 回目を呼ばずに呼び出し側へ出ます。下の表は、失敗の種類ごとに、catch での見分け方と再試行するかどうかをまとめたものです。

失敗の種類catch での見分け方再試行
ネットワークエラー(通信が切れた)error instanceof TypeError が true1 回だけする(通信が戻れば成功する)
時間切れ(abort() で打ち切った)error.name が "AbortError"1 回だけする(混雑が収まれば間に合う)
ファイルが無い・権限が無い(status 403 や 404)それ以外(ok を見て自分で投げた Error)しない(送り直しても同じ応答が返る)

return だけでは catch を通らない

try { return loadJson(path); } と await を付けずに返すと、Promise を返した時点で try を抜けます。後から rejected になっても catch は動かず、失敗はそのまま呼び出し側へ出ます。return await loadJson(path); と書きます。

遅い API から会員一覧を読みます。slowFetch と loadWithin は宣言済みです。

① loadWithRetry で、制限 limitMs で読んだ結果を返してください。

② 時間切れのときだけ「時間切れのため再試行します」と表示し、読み直してください。

③ loadWithRetry で会員を読み、「会員 4 人」の形で表示してください。

④ 誤ったパスも読み、「中止しました: 」と例外の文言を表示してください。

JavaScript / TypeScript エディタ

コードを実行してください
QUIZ

理解度チェック

まずは1問ずつ答えてみましょう。

Q1ドメインが無い URL を await fetch(url) したとき、catch に届く error.name は?

Q2100ms 後の abort() を予約し、応答に 300ms かかる取得へ signal を渡すと?

Q3この記事の loadWithRetry が、再試行せずにそのまま投げ直す失敗は?