← 記事一覧に戻る

Node.js 18の組み込みfetchはタイムアウトしない — AbortSignal.timeoutで本番障害を防ぐ

Node.js 18で組み込みfetchが正式に搭載され、node-fetchaxiosなしで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()を必ずセットにする習慣をつけるのが最も確実な対策だ。