結論:fetch にはタイムアウトがないので AbortSignal.timeout を必ず付ける
Node.js の組み込み fetch にはデフォルトのタイムアウトが存在しない。外部APIが遅延・無応答になると await fetch() が永遠に返らず、サーバーがハングする。対策はリクエストごとに signal: AbortSignal.timeout(ミリ秒) を渡すこと。これだけで指定時間で確実に中断できる。
// これが最小の正解
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })
以下、なぜ起きるのか、本番でどんな障害になるのか、そして状況別の実装パターンを解説する。
症状:外部APIが遅いとサーバープロセスが無限に詰まる
次のような何の変哲もないコードが本番に潜んでいるとする。
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(...)が完了せずブロックされ続ける- そのリクエストを処理しているワーカー/イベントループ上の処理が解放されない
- 同じ症状のリクエストが積み重なり、コネクションとメモリを食い潰す
- 最終的にサーバー全体が応答不能・OOMでクラッシュ
「デプロイ直後は問題ないのに数時間後に急に応答が遅くなる」「監視で特定エンドポイントだけタイムアウトが多発する」という症状の犯人として、このタイムアウト未設定は非常に多い。
原因:Fetch 仕様にタイムアウトパラメータがない
WHATWG Fetch 仕様にはタイムアウトを指定するオプションが定義されていない。これはブラウザの fetch() も同じで、リクエストの中断は AbortController / AbortSignal を使うのが仕様上の正規ルートだからだ。
Node.js の組み込み fetch は内部的に undici を使っており、この仕様に従う。つまりタイムアウトは開発者が明示的に実装しなければならない。axios の timeout オプションや node-fetch の感覚で書くと、ここに落とし穴がある。
なお、undici にはソケットレベルの headersTimeout(既定約5分)・bodyTimeout(既定約5分)が存在するが、これは「リクエスト全体を素早く切る」用途には長すぎる。アプリ要件に合わせた中断は自前で行う。
対策1(推奨):AbortSignal.timeout でリクエスト単位に切る
Node.js 17.3 以降(現行のLTS全てで利用可)では AbortSignal.timeout(ms) が使える。指定ミリ秒後に自動で abort する使い捨てシグナルを返すので、これを fetch に渡すだけでよい。
async function getUser(id) {
try {
const res = await fetch(`https://external-api.example.com/users/${id}`, {
signal: AbortSignal.timeout(5000), // 5秒で中断
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return await res.json()
} catch (err) {
if (err.name === 'TimeoutError') {
// AbortSignal.timeout による中断はこの name になる
throw new Error('外部APIがタイムアウトしました')
}
throw err
}
}
ポイントはエラーの name で分岐すること。AbortSignal.timeout 起因の中断は TimeoutError、手動 abort(後述)は AbortError になる。HTTPステータスのエラーとは別物なので、ログとリトライ判定で区別する。
対策2:AbortController で手動制御する(細かい制御・キャンセル併用)
「ユーザー操作でキャンセルもしたい」「タイムアウト値を動的に変えたい」場合は AbortController を自分で持つ。
async function getUser(id, timeoutMs = 5000) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch(`https://external-api.example.com/users/${id}`, {
signal: controller.signal,
})
return await res.json()
} finally {
clearTimeout(timer) // タイマーリークを防ぐため必ず解放する
}
}
finally での clearTimeout を忘れると、リクエストが早く終わってもタイマーが残り続ける。手動方式では必ず後始末する。
対策3:共通ラッパーにして全 fetch に強制する
個別の呼び出しでタイムアウトを付け忘れるのが事故の元なので、プロジェクト全体で使うラッパー関数にしてしまうのが堅実だ。
// src/lib/http.js
const DEFAULT_TIMEOUT = 5000
export async function httpFetch(url, options = {}) {
const { timeout = DEFAULT_TIMEOUT, ...rest } = options
const res = await fetch(url, {
...rest,
signal: AbortSignal.timeout(timeout),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`)
}
return res
}
レビューで「生の fetch を直接使っていないか」だけ見ればよくなり、付け忘れを構造的に防げる。
対策4:複数シグナルを束ねる(タイムアウト+ユーザーキャンセル)
「5秒のタイムアウト」と「リクエストスコープのキャンセル」を両方効かせたいときは AbortSignal.any()(Node.js 20.3+)でシグナルを合成する。
async function getUser(id, externalSignal) {
const signal = AbortSignal.any([
AbortSignal.timeout(5000),
externalSignal, // 例: リクエスト中断時に abort されるシグナル
].filter(Boolean))
const res = await fetch(`https://api.example.com/users/${id}`, { signal })
return res.json()
}
どちらか早い方の中断で fetch が止まる。タイムアウトとキャンセルを別々に if で書くより安全だ。
対策5:タイムアウト+リトライをセットにする
外部API連携では、タイムアウトしたら一定回数リトライしたいことが多い。タイムアウト(TimeoutError)とサーバー側5xxだけを対象に、指数バックオフで再試行する。
async function fetchWithRetry(url, { retries = 2, timeout = 3000 } = {}) {
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeout) })
if (res.status >= 500) throw new Error(`server ${res.status}`)
return res
} catch (err) {
const retryable = err.name === 'TimeoutError' || /server 5/.test(err.message)
if (!retryable || attempt === retries) throw err
await new Promise((r) => setTimeout(r, 2 ** attempt * 200)) // 200ms, 400ms, ...
}
}
}
リトライ対象を絞らないと、4xx(クライアント起因)まで無駄に叩いて負荷を増やすので注意する。
ハマりやすい注意点
response.json()/response.text()側はタイムアウトしない:fetchが返ってきてもボディ読み込みで詰まることがある。巨大/遅いレスポンスではAbortSignalがボディ読み込みにも効くよう、同じ signal を使う実装(上記のAbortSignal.timeout)を選ぶ。Promise.raceでの自前タイムアウトは中途半端:Promise.race([fetch(...), timeoutPromise])は「待つのをやめる」だけで、裏のリクエスト自体はキャンセルされない。コネクションは開いたまま残る。必ずsignalで中断する。- エラーの name を取り違える:
AbortSignal.timeout→TimeoutError、controller.abort()→AbortError。ログ集計や監視のフィルタで混同しないこと。
まとめ
| やりたいこと | 方法 |
|---|---|
| 一定時間で確実に中断(基本) | fetch(url, { signal: AbortSignal.timeout(ms) }) |
| キャンセルや動的タイムアウト | AbortController + setTimeout + finally で clearTimeout |
| 付け忘れ防止 | 共通 httpFetch ラッパーに集約 |
| タイムアウト+キャンセル両立 | AbortSignal.any([...])(Node 20.3+) |
| タイムアウト時のリトライ | name === 'TimeoutError' と5xxだけ指数バックオフ |
Node.js の fetch は「タイムアウトが無いのがデフォルト」と覚えておくのが安全だ。新規コードでは生の fetch を直接使わず、必ず AbortSignal.timeout 付きのラッパー経由にすることで、本番のハング障害をまとめて防げる。
