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

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 は例外を投げません。okfalse のときに 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 になります。catchinstanceof 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 へ移る
  • catcherror instanceof TypeError を調べて分ける
loadJson の中
  • if (!response.ok)fixtures/order.json は応答が届き、okfalse
  • throw new Error(...)自分で投げる Error
await fetch(url) の中
  • https://shop.invalid/… — 応答が届かない
  • TypeError: Failed to fetchfetch が投げる
HTTP エラーは loadJson が投げ、ネットワークエラーは fetch が投げます。どちらも同じ catch に届くので、種類で見分けます

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

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

fetch が投げる TypeErrormessage は、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() を取り消します

clearTimeoutfinally に書くのは、成功でも失敗でも予約を残さないためです。本文を読み終える前に 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 から会員一覧を読みます。slowFetchloadWithin は宣言済みです。

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

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

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

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

JavaScript / TypeScript エディタ

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

理解度チェック

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

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

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

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