Node.js 18で組み込みfetchが正式に搭載され、node-fetchやaxiosなしでHTTPリクエストを送れるようになった。しかし、デフォルトではタイムアウトが一切設定されていない。
これは本番環境で深刻な問題になる。
症状: 外部APIが遅いとサーバーが無限にハングする
次のようなコードが本番にあるとする。
// Express でよくあるパターン
app.get('/user/:id', async (req, res) => {
const response = await fetch(`https://external-api.example.com/users/${req.params.id}`)
const json = await response.json()
res.json(json)
})
外部APIの応答が遅い、またはサーバーが落ちている場合:
await fetch(...)でリクエストが完了するまでブロックされ続ける- Expressのワーカープロセスが占有される
- リクエストが積み重なり、最終的にサーバーがメモリ不足でクラッシュ
「たまにサーバーがハングする」「デプロイ直後は問題ないのに数時間後に応答が遅くなる」という症状の原因として、このタイムアウト未設定が意外と多い。
原因: Fetch APIの仕様にタイムアウトパラメータがない
WHATWG Fetch仕様にはタイムアウトパラメータが定義されていない。ブラウザのfetch()も同様で、キャンセルにはAbortControllerを使うのが仕様上の正しいアプローチ。
Node.jsの組み込みfetch(undiciベース)もこの仕様に従うため、タイムアウトは開発者が明示的に実装しなければならない。
axiosはデフォルト0ms(タイムアウトなし)だが、ドキュメントにtimeoutオプションが明示されているので意識しやすい。fetchはオプション自体が存在しないという点が落とし穴になっている。
解決策1: AbortSignal.timeout()(Node.js 17.3+)
// AbortSignal.timeout を使う(最もシンプル)
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000), // 5秒でタイムアウト
})
AbortSignal.timeout(ms)はNode.js 17.3(2022年1月リリース)以降で使える。タイムアウトするとTimeoutErrorがスローされる。
エラーハンドリング込みの実用的なパターン:
async function fetchWithTimeout(url, options = {}) {
const { timeout = 10000, ...fetchOptions } = options
try {
const response = await fetch(url, {
...fetchOptions,
signal: AbortSignal.timeout(timeout),
})
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
return await response.json()
} catch (err) {
if (err.name === 'TimeoutError') {
throw new Error(`リクエストがタイムアウトしました(${timeout}ms): ${url}`)
}
throw err
}
}
// 使い方
const data = await fetchWithTimeout('https://api.example.com/data', { timeout: 5000 })
解決策2: AbortController + setTimeout(Node.js 16以下でも使える)
Node.js 16以下や、AbortSignal.timeoutが使えない環境ではAbortControllerで代替できる。
function fetchWithTimeout(url, options = {}, timeout = 10000) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timeoutId))
}
// 使い方
try {
const response = await fetchWithTimeout('https://api.example.com/data', {}, 5000)
const data = await response.json()
} catch (err) {
if (err.name === 'AbortError') {
console.error('リクエストがキャンセルされました(タイムアウト)')
}
}
AbortSignal.timeout()との違い:
| AbortSignal.timeout() | AbortController.abort() | |
|---|---|---|
| エラー名 | TimeoutError |
AbortError |
| Node.js対応バージョン | 17.3+ | 15.0+ |
| コード量 | 少ない | 多め |
エラーの種類で判定する場合はerr.nameを使い分けること。
解決策3: AbortSignal.any()で複数条件を組み合わせる(Node.js 20+)
タイムアウトに加えて「ユーザーがリクエストをキャンセルしたら途中で止める」という要件がある場合:
const controller = new AbortController()
// タイムアウト(5秒)と手動キャンセルを両方設定
const signal = AbortSignal.any([
AbortSignal.timeout(5000),
controller.signal,
])
const response = await fetch(url, { signal })
// 任意のタイミングで手動キャンセル
controller.abort()
AbortSignal.any()はNode.js 20.3+で使える。最初にabortされたシグナルのreasonがそのまま伝播する。
プロジェクト共通ラッパーの作り方
毎回AbortSignal.timeoutを書くのは面倒なので、プロジェクト共通のラッパー関数を作っておくのがベストプラクティス。
// lib/api-client.ts
const DEFAULT_TIMEOUT = parseInt(({}).API_TIMEOUT ?? '10000')
type FetchOptions = RequestInit & { timeout?: number }
export async function apiFetch<T>(url: string, options: FetchOptions = {}): Promise<T> {
const { timeout = DEFAULT_TIMEOUT, ...fetchOptions } = options
let response: Response
try {
response = await fetch(url, {
...fetchOptions,
signal: AbortSignal.timeout(timeout),
headers: {
'Content-Type': 'application/json',
...fetchOptions.headers,
},
})
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') {
throw new Error(`API timeout (${timeout}ms): ${url}`)
}
throw err
}
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(`API error ${response.status}: ${body}`)
}
return response.json() as Promise<T>
}
// 使い方
const user = await apiFetch<User>('https://api.example.com/users/1', { timeout: 3000 })
環境変数API_TIMEOUTでタイムアウトを一括管理できるので、本番環境だけ長くする、といった調整も簡単になる。
axiosからの移行時に詰まるポイント
axiosユーザーがfetchに移行する際に追加でハマりやすいポイントをまとめる。
// axios(慣れた書き方)
const { data } = await axios.get('https://api.example.com/data', { timeout: 5000 })
// fetch(相当するコード)— 3つの違いに注意
const response = await fetch('https://api.example.com/data', {
signal: AbortSignal.timeout(5000), // ① タイムアウトは自分で設定
})
if (!response.ok) { // ② 4xx/5xx はthrowされないので手動チェック
throw new Error(`${response.status}`)
}
const data = await response.json() // ③ JSONパースも自分で呼ぶ
axiosとfetchの主な違い:
| axios | fetch | |
|---|---|---|
| タイムアウト | timeoutオプション |
AbortSignal.timeout() |
| 4xx/5xxエラー | 自動でthrow | throwされない(response.okチェック必要) |
| JSONパース | 自動 | response.json()を手動で呼ぶ |
| リクエストボディ | オブジェクトをそのまま渡せる | JSON.stringify()が必要 |
特に「4xx/5xxでthrowされない」は初見で必ずハマる。
まとめ
| 問題 | 原因 | 対策 |
|---|---|---|
| fetchがタイムアウトしない | Fetch仕様にtimeoutパラメータがない | AbortSignal.timeout() |
| Node.js 17.3未満で使えない | AbortSignal.timeoutの対応バージョン | AbortController + setTimeout |
| TimeoutErrorとAbortErrorの混同 | 設定方法によってerr.nameが異なる | err.nameで分岐 |
| 4xx/5xxがthrowされない | Fetch仕様 | response.okチェックを忘れない |
Node.js 18への移行をきっかけにnode-fetchから組み込みfetchへ乗り換えるプロジェクトが増えている。「fetchに書き換えたら本番でたまにハングする」という症状が出たら、まずタイムアウト未設定を疑ってほしい。
プロジェクト全体で共通ラッパー関数を作り、AbortSignal.timeout()を必ずセットにする習慣をつけるのが最も確実な対策だ。
