← 記事一覧に戻る

Node.js の fetch がタイムアウトしない・サーバーがハングする原因と対策【AbortSignal.timeout】

結論: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 を使っており、この仕様に従う。つまりタイムアウトは開発者が明示的に実装しなければならないaxiostimeout オプションや 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.timeoutTimeoutErrorcontroller.abort()AbortError。ログ集計や監視のフィルタで混同しないこと。

まとめ

やりたいこと 方法
一定時間で確実に中断(基本) fetch(url, { signal: AbortSignal.timeout(ms) })
キャンセルや動的タイムアウト AbortController + setTimeout + finallyclearTimeout
付け忘れ防止 共通 httpFetch ラッパーに集約
タイムアウト+キャンセル両立 AbortSignal.any([...])(Node 20.3+)
タイムアウト時のリトライ name === 'TimeoutError' と5xxだけ指数バックオフ

Node.js の fetch は「タイムアウトが無いのがデフォルト」と覚えておくのが安全だ。新規コードでは生の fetch を直接使わず、必ず AbortSignal.timeout 付きのラッパー経由にすることで、本番のハング障害をまとめて防げる。