Article

⏱️ Node.jsのfetchにタイムアウトを入れる:AbortSignal.timeoutとanyの実務的な使い分け

nodejs, javascript, typescript, fetch, webapi

Node.jsで外部APIを呼ぶとき、fetch()をそのまま使っていないでしょうか。

外部APIは必ずしもすぐ応答するとは限りません。相手側の障害やネットワーク不調で待ち続けると、サーバー側ではリクエストやコネクションなどのリソースを長時間抱えることになります。

そこで重要になるのがタイムアウトキャンセルです。

この記事では、AbortSignal.timeout()AbortControllerAbortSignal.any()を使い、Node.js / TypeScriptで外部API呼び出しを安全に止める方法を整理します。

まずは最小構成

一定時間でfetchを打ち切るだけなら、AbortSignal.timeout()がシンプルです。

const response = await fetch("https://example.com/api/users", {
  signal: AbortSignal.timeout(5_000),
});

AbortSignalは、非同期処理へ「中断してほしい」という状態を伝えるためのオブジェクトです。

AbortSignal.timeout(5_000)は5,000ミリ秒後に自動的にabortされるSignalを返します。タイムアウトするとfetch()はrejectされ、タイムアウト由来ならTimeoutErrorとして扱えます。

try {
  const response = await fetch("https://example.com/api/users", {
    signal: AbortSignal.timeout(5_000),
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return await response.json();
} catch (error) {
  if (error instanceof DOMException && error.name === "TimeoutError") {
    console.error("外部APIがタイムアウトしました");
    throw error;
  }

  throw error;
}

重要なのは、HTTP 500とタイムアウトは別物という点です。

fetch()はHTTP 404や500を受け取っただけではrejectされません。HTTPエラーとして扱いたい場合はresponse.okresponse.statusを自分で確認します。一方、タイムアウトやネットワークエラーは例外として処理します。

AbortControllerは何のために使う?

AbortSignal.timeout()は時間による中断には便利ですが、処理を任意のタイミングで止めたい場合はAbortControllerを使います。

const controller = new AbortController();

const promise = fetch("https://example.com/api/report", {
  signal: controller.signal,
});

// 何らかの理由で不要になった
controller.abort();

await promise;

AbortControllerは「中断命令を出す側」、AbortSignalは「中断状態を受け取る側」と考えると理解しやすいです。

abort()されたfetch()は通常AbortErrorでrejectされます。

タイムアウトと外部からのキャンセルを両立する

実務では、次の2つを同時に扱いたいことがあります。

  • 5秒経過したらタイムアウト
  • 呼び出し元の処理がキャンセルされたら即座に中断

この場合に便利なのがAbortSignal.any()です。

async function fetchUser(
  userId: string,
  signal?: AbortSignal,
) {
  const timeoutSignal = AbortSignal.timeout(5_000);

  const combinedSignal = signal
    ? AbortSignal.any([signal, timeoutSignal])
    : timeoutSignal;

  const response = await fetch(
    `https://example.com/api/users/${userId}`,
    { signal: combinedSignal },
  );

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  return response.json();
}

呼び出し側は次のようにキャンセルできます。

const controller = new AbortController();

const request = fetchUser("123", controller.signal);

controller.abort();

await request;

AbortSignal.any()は、渡したSignalのどれか1つがabortされた時点でabortされるSignalを作ります。

これにより、関数の内部ルールである「5秒タイムアウト」と、呼び出し元から渡される「キャンセル」を1つのsignalとしてfetch()へ渡せます。

Signalを使い回さない

ハマりやすいのが、abort済みのSignalを再利用するケースです。

const controller = new AbortController();

controller.abort();

await fetch(urlA, { signal: controller.signal });
await fetch(urlB, { signal: controller.signal });

一度abortされたAbortSignalはabort状態のままです。そのため、同じSignalを使った後続のfetch()もすぐrejectされます。

リクエスト単位でAbortControllerを作る設計にしておく方が安全です。

setTimeout + AbortControllerとの違い

従来は次のような実装もよく使われます。

const controller = new AbortController();

const timer = setTimeout(() => {
  controller.abort();
}, 5_000);

try {
  return await fetch(url, {
    signal: controller.signal,
  });
} finally {
  clearTimeout(timer);
}

この方法はコード量が増えますが、タイマーをclearTimeout()で明示的に解除できる利点があります。

単純なタイムアウトならAbortSignal.timeout()、タイマー自体のライフサイクルまで細かく制御したいならAbortController + setTimeoutという使い分けができます。

リトライするならタイムアウトと分けて考える

タイムアウトを入れたあと、すぐ「失敗したら3回リトライ」と実装したくなります。しかし、すべての失敗をリトライするのは危険です。

例えば次のように分類します。

状況 リトライ候補
タイムアウト
HTTP 429 ○(Retry-Afterも考慮)
HTTP 503
HTTP 400 原則×
HTTP 401/403 原則×
呼び出し元からのキャンセル ×

さらに、即時リトライを繰り返すと障害中のAPIへ負荷を集中させます。実務では指数バックオフ(失敗するたび待ち時間を長くする方式)やジッター(待ち時間にランダム性を入れる方法)も検討します。

共通APIクライアントに閉じ込める

タイムアウト処理を各Serviceへコピーすると設定がばらつきます。

export async function fetchJson<T>(
  url: string,
  options: RequestInit & {
    timeoutMs?: number;
  } = {},
): Promise<T> {
  const { timeoutMs = 5_000, signal, ...requestInit } = options;

  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  const combinedSignal = signal
    ? AbortSignal.any([signal, timeoutSignal])
    : timeoutSignal;

  const response = await fetch(url, {
    ...requestInit,
    signal: combinedSignal,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return response.json() as Promise<T>;
}

利用側は通信制御を意識しすぎずに済みます。

type User = {
  id: string;
  name: string;
};

const user = await fetchJson<User>(
  "https://example.com/api/users/123",
  { timeoutMs: 3_000 },
);

ただし、全APIに同じタイムアウト値を適用する必要はありません。軽量な参照APIと大きなファイルを生成するAPIでは適切な時間が異なります。デフォルト値を決めつつ、用途ごとに上書きできる設計が扱いやすいです。

よくあるハマりどころ

1. Promise.raceだけでタイムアウトした気になる

await Promise.race([
  fetch(url),
  timeoutPromise,
]);

呼び出し側ではタイムアウトしたように見えても、fetch自体をabortしていなければ裏側の通信が続く可能性があります。

通信そのものを中断したい場合はAbortSignalfetchへ渡します。

2. HTTPエラーと通信エラーを一緒にする

response.okの確認とcatchを分けて考えると、ログやリトライ条件を整理しやすくなります。

3. タイムアウト値をコード中に散らす

5000のようなマジックナンバーを各所へ書くと変更が難しくなります。APIクライアントや設定ファイルへ寄せる方が運用しやすくなります。

まとめ

Node.jsで外部APIを呼ぶときは、fetch()を書くだけでなく「いつ諦めるか」まで設計しておくことが重要です。

  • 単純な時間制限にはAbortSignal.timeout()
  • 任意キャンセルにはAbortController
  • 複数の中断条件をまとめるならAbortSignal.any()
  • abort済みSignalは使い回さない
  • HTTPエラーとタイムアウトを分ける
  • リトライは失敗理由ごとに判断する
  • 共通APIクライアントへタイムアウト処理を寄せる

外部API障害はアプリケーション側から防げませんが、障害時に自分のサービスまで巻き込まれるかどうかはクライアント側の設計で大きく変わります。

参考