Contents
fetch と Promise の基本
fetch() はブラウザと Node.js(v18 以降)で利用できる標準的な HTTP クライアントです。
呼び出しは Promise\<Response> を返し、ネットワークエラーや CORS エラーが起きた場合は reject、サーバからレスポンスが正常に届いた場合は resolve されます。
ポイント
HTTP ステータスが 4xx/5xx でも Promise は成功 ( resolved ) とみなされるため、ステータスコードのチェックは必須です。
fetch()が返すオブジェクトはストリームベースなので、response.bodyの消費は一度だけ行う必要があります。
シンプルな使用例
|
1 2 3 4 5 6 7 8 9 |
// GET リクエスト → JSON 取得 fetch('https://api.example.com/data') .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); // body を一度だけ読む }) .then(data => console.log('データ:', data)) .catch(err => console.error('fetch エラー:', err)); |
response.okがtrueのときはステータスが 200–299。- ネットワーク障害や CORS エラーは
catchに流れる。
AbortController と AbortSignal.timeout によるタイムアウト実装
標準化と対応状況(2024 年時点)
| 環境 | 実装バージョン | 備考 |
|---|---|---|
| Chrome | 115 (2023‑12) | AbortSignal.timeout() が組み込み |
| Edge | 115 (Chromium ベース) | 同上 |
| Firefox | 119 (2024‑04) | 実装済み(フラグ不要) |
| Safari | 17.0 (2023‑09) | 実装済み |
| Node.js | v20 以降 | globalThis.AbortSignal.timeout() が利用可能。※ v18 では未実装で、ポリフィルが必要 |
AbortSignal.timeout(ms) は WHATWG Fetch 標準 の一部として追加され、内部的に setTimeout と同等のタイマーを自動管理します。これにより手動で clearTimeout を忘れるリスクが排除されます。
手動実装との比較
| 項目 | 従来 (setTimeout) |
AbortSignal.timeout() |
|---|---|---|
| 行数 | 5 行程度(controller, timer, clear) | 1 行だけ signal: AbortSignal.timeout(ms) |
| タイマー管理 | 明示的に clearTimeout が必要 |
自動クリア、リーク防止 |
| 可読性 | 中程度 | 高い |
| フォールバック | 常に使用可能 | Node v20 未満では polyfill が必要 |
実装例
手動タイマー方式(フォールバック用)
|
1 2 3 4 5 6 7 8 |
function fetchWithManualTimeout(url, timeoutMs = 5000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); return fetch(url, { signal: controller.signal }) .finally(() => clearTimeout(timer)); // 必ずクリアしてリーク防止 } |
AbortSignal.timeout() を使ったシンプル版(推奨)
|
1 2 3 4 |
function fetchWithTimeout(url, timeoutMs = 5000) { return fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); } |
注意
Node.js v20 未満でこのコードを実行する場合は、abort-controllerパッケージやfetch-blobの polyfill を組み合わせて自前のAbortSignal.timeout()相当処理を提供してください。
リトライロジックと指数バックオフ(jitter 付き)
基本方針
- 最大試行回数 と ベース遅延 をパラメータ化する。
- エラー種別に応じてリトライ可否を判定(
AbortError,TypeError, HTTP 5xx 等)。 - 遅延は 指数バックオフ + jitter で算出し、同時リクエストが集中するスパイクを緩和する。
汎用関数(for ループ版)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
/** * fetch にタイムアウト・リトライ・指数バックオフを組み込んだラッパー * * @param {string} url * @param {object} [options] * @param {number} [options.retries=3] 最大リトライ回数 * @param {number} [options.timeoutMs=5000] 各試行のタイムアウト(ms) * @param {number} [options.baseDelayMs=200] バックオフのベース遅延(ms) * @param {number} [options.jitterMs=100] jitter の上限(ms) * @returns {Promise<Response>} */ async function fetchWithRetry(url, { retries = 3, timeoutMs = 5000, baseDelayMs = 200, jitterMs = 100 } = {}) { for (let attempt = 0; attempt <= retries; ++attempt) { try { const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); // 5xx 系はリトライ対象、4xx はそのまま返す(要件に合わせて変更可) if (!response.ok && response.status >= 500) throw new Error(`HTTP ${response.status}`); return response; // 成功 } catch (err) { const isLast = attempt === retries; const retryable = err.name === 'AbortError' || err instanceof TypeError || (err.message?.startsWith('HTTP') && Number(err.message.split(' ')[1]) >= 500); if (isLast || !retryable) throw err; // もうリトライしない // 指数バックオフ + jitter const delay = baseDelayMs * 2 ** attempt + Math.random() * jitterMs; console.warn(`リトライ #${attempt + 1}: ${Math.round(delay)}ms 待機`); await new Promise(res => setTimeout(res, delay)); } } } |
再帰版(好みで選択可)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
async function fetchRetryRecursive(url, opts = {}, attempt = 0) { const { retries = 3, timeoutMs = 5000, baseDelayMs = 200, jitterMs = 100 } = opts; try { return await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); } catch (err) { if (attempt >= retries) throw err; const retryable = err.name === 'AbortError' || err instanceof TypeError || (err.message?.startsWith('HTTP') && Number(err.message.split(' ')[1]) >= 500); if (!retryable) throw err; const delay = baseDelayMs * 2 ** attempt + Math.random() * jitterMs; console.warn(`リトライ #${attempt + 1}: ${Math.round(delay)}ms 待機`); await new Promise(r => setTimeout(r, delay)); return fetchRetryRecursive(url, opts, attempt + 1); } } |
バックオフと jitter の意味
| 用語 | 計算式例 | 効果 |
|---|---|---|
| 指数バックオフ | delay = baseDelay * 2^attempt(200 → 400 → 800 …) |
サーバ負荷が高まるたびに待機時間を伸ばす |
| jitter | Math.random() * jitterMs(0‑100 ms のランダム) |
同時リトライが揃う「スパイク」現象を分散させる |
実務では baseDelayMs = 200, jitterMs = 100 がデフォルトとして広く採用されています。
Node.js(v20+)での実装と注意点
組み込み fetch と AbortSignal.timeout の利用
Node.js v20 以降はブラウザと同様に globalThis.fetch と AbortSignal.timeout() がネイティブで提供されます。追加パッケージ (node-fetch 等) は不要です。
|
1 2 3 4 5 |
// node-fetch はインストールしないで OK async function nodeFetchWithRetry(url, opts = {}) { return fetchWithRetry(url, opts); // 前節の汎用関数をそのまま流用 } |
Node 固有のネットワーク遅延への対策
| 項目 | ブラウザ側 | Node.js 側の留意点 |
|---|---|---|
| DNS ルックアップ | OS が内部で処理し、fetch のタイミングに影響しにくい | dns.lookup にもタイムアウト (lookupTimeout) を設定できる(Node 20+) |
| TCP 接続確立 | ブラウザが自動的に再試行することがある | http.Agent / https.Agent の keepAlive や maxSockets で制御可能 |
| TLS 証明書エラー | ユーザーに警告 UI が表示される | rejectUnauthorized: false は開発時以外は使用しない。代わりに正しい証明書チェーンを提供 |
総合タイムアウト例(DNS + fetch を包括)
|
1 2 3 4 5 6 7 8 9 10 |
import { lookup } from 'node:dns/promises'; async function resolveAndFetch(hostname, path, opts = {}) { const { timeoutMs = 5000 } = opts; // DNS 解決に対する総合タイムアウト (Node v20+ のオプション) const address = await lookup(hostname, { timeout: timeoutMs }); const url = `https://${address.address}${path}`; return fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); } |
実務的な指針
全体の上限タイムアウトは 8 秒 程度に抑え、内部でfetchのタイムアウトを 5 秒* に分割すると、DNS エラーや TCP 接続遅延が原因の場合でも早期に失敗を検知できます。
エラーハンドリングのベストプラクティスとデバッグ手法
エラー種別別リトライ方針
| 種類 | 発生シーン | リトライ推奨 |
|---|---|---|
AbortError |
タイムアウト、controller.abort() |
✅(要件に応じて) |
TypeError |
ネットワーク切断・DNS失敗 | ✅ |
| HTTP 5xx | サーバ側一時障害 | ✅ |
| HTTP 429 | レートリミット → 待機後再試行(バックオフと組み合わせ) | ✅ |
| HTTP 4xx | クライアントエラー(認証失敗等) | ❌(基本的にリトライしない) |
|
1 2 3 4 5 6 7 8 9 |
function shouldRetry(err, response) { if (err?.name === 'AbortError') return true; if (err instanceof TypeError) return true; if (!response) return false; const status = response.status; return status >= 500 || status === 429; // 5xx と 429 はリトライ対象 } |
リソース解放の徹底
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
async function robustFetch(url, opts = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok && shouldRetry(null, response)) throw new Error(`HTTP ${response.status}`); return response; } catch (err) { console.error(`[fetch] ${err.name}: ${err.message}`); throw err; } finally { clearTimeout(timer); // タイマーは必ずクリア controller.abort(); // 信号を解放(二重 abort は安全) } } |
デバッグ手法
| 環境 | 推奨ツール・フラグ |
|---|---|
| ブラウザ | Chrome/Edge DevTools → Network タブで canceled が表示されたら AbortError。Timing パネルで DNS/TCP の待ち時間を可視化。 |
| Node.js | node --trace-warnings script.js で未処理 Promise 警告やタイムアウト情報を取得。NODE_DEBUG=fetch 環境変数で内部ログ(リクエストヘッダー、ステータス)を出力できる。 |
| テスト | msw(Mock Service Worker)や nock でタイムアウト・ネットワーク障害シナリオを再現し、リトライロジックの単体テストを自動化する。 |
まとめ
fetch()は Promise ベースなので ステータスコードチェック と エラーハンドリング が必須です。AbortSignal.timeout(ms)(Chrome 115+, Edge, Firefox 119+, Safari 17, Node v20+)を利用すれば、手動タイマーの管理ミスによるリークを防げます。Node v18 以前では polyfill が必要です。- リトライは 最大試行回数 + 指数バックオフ + jitter の組み合わせで実装し、エラー種別に応じて
shouldRetryロジックで判定します。 - Node.js 環境では DNS や TCP 接続遅延がタイムアウトの要因になるため、総合的なタイムアウト戦略を導入すると安全です。
- エラーは必ず finally ブロックでリソース(タイマー・AbortController)を解放し、デバッグはブラウザ DevTools と Node のトレースフラグを併用して行うと効果的です。
これらのポイントを踏まえて実装すれば、ブラウザ・Node どちらでも 安全かつ保守性の高い HTTP 通信 が実現できます。