1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Node.js fetchでAI APIの無限待ちを10分で止める — AbortSignalの4ステップ

1
Posted at

Node.js fetchでAI APIの無限待ちを10分で止める — AbortSignalの4ステップ

AI APIを呼ぶコードは、成功する時だけ見ると驚くほど短いです。

const response = await fetch(url, options);
const result = await response.json();

でも現場では、応答が返らない、利用者が画面を閉じた、上流が混雑した、retryが重なった、という「待ち時間」の問題が起きます。

そこで Promise.race() でタイマーと競争させて、画面上の待機だけ終わらせる。これ、一見直ったように見えるんですが、元の通信処理は残ったままになることがあります。

なんかこれって、受付票だけ捨てて、厨房への注文は残しているような状態なんですよね。

先に結論です。

Node.jsで外部AI APIを呼ぶ時は、次の4つを分けて設計すると、無限待ちと危険なretryを減らせます。

  1. 期限を決める
  2. キャンセル理由を分ける
  3. retryを安全な失敗へ限定する
  4. 待ち時間を観測する

この記事では、この4つを 「待ち時間の4境界」 と呼びます。外部API呼び出しを、期限、キャンセル理由、再試行条件、観測項目の4境界で設計する実務手順です。

読了後のゴールは、タイムアウト設計を全部完成させることではありません。10分でローカルの遅延APIを止め、自分のAPI呼び出し1本へ入れる場所を選べること。 まずはそこまでで十分です。

更新日: 2026-07-29

まず知っておきたい用語

最初に、この記事で使う言葉をそろえておきます。

  • fetch(): URLへHTTPリクエストを送り、応答をPromiseで受け取るAPIです。Node.jsではグローバルAPIとして利用できます。
  • AbortController: 実行中の処理へ「中断してほしい」と通知するためのコントローラーです。
  • AbortSignal: 中断通知を処理へ渡すための信号です。fetch()signalへ設定します。
  • TimeoutError: AbortSignal.timeout()で指定した時間を超えた時に識別できるDOMException名です。
  • AbortError: 利用者操作などで明示的に中断した時に使われるDOMException名です。
  • 冪等性: 同じ操作を複数回実行しても、結果が重複しない性質です。retryしてよいかを決める重要な基準になります。

タイムアウトとキャンセルは似ていますが、意味が違います。

タイムアウトは「こちらが決めた期限を超えた」。キャンセルは「利用者や上位処理が、もう結果を要らないと決めた」。意味が違うので、ログ、利用者への表示、retryの判断も分ける必要があります。

10分の初回成功: 200msでTimeoutErrorを出す

外部AI APIは使いません。APIキーも課金も不要です。

まず、1.5秒後に応答する遅いローカルサーバーを作ります。

slow-server.mjs
import { createServer } from "node:http";

const server = createServer((_request, response) => {
  setTimeout(() => {
    response.writeHead(200, {
      "content-type": "application/json",
    });
    response.end(JSON.stringify({ message: "done" }));
  }, 1_500);
});

server.listen(3000, "127.0.0.1", () => {
  console.log("slow server: http://127.0.0.1:3000");
});

別のターミナルで、200msの期限を持つクライアントを実行します。

timeout-client.mjs
const startedAt = performance.now();

try {
  const response = await fetch("http://127.0.0.1:3000", {
    signal: AbortSignal.timeout(200),
  });

  console.log(await response.json());
} catch (error) {
  const elapsedMs = Math.round(performance.now() - startedAt);

  console.log({
    name: error.name,
    elapsedMs,
  });
}

実行します。

node slow-server.mjs
node timeout-client.mjs

出力例は次のようになります。

{ name: 'TimeoutError', elapsedMs: 203 }

環境によって数msから数十msほど前後します。ここで大事なのは200という数字ぴったりではありません。

1.5秒待ち続けず、こちらが決めた期限で処理を終え、理由をTimeoutErrorとして識別できた。

これが最初の成功です。

Node.js公式ドキュメントでは、AbortSignal.timeout(delay)は指定ミリ秒後に中断されるsignalを返します。追加されたバージョンはNode.js v17.3.0 / v16.14.0です。後で使うAbortSignal.any()はv20.3.0 / v18.17.0で追加されています。古いNode.jsを使っている場合は、実行環境の対応状況を先に確認してください。

境界1: 期限を「なんとなく30秒」で決めない

AbortSignal.timeout(30_000)を全リクエストへ付ければ完成、ではありません。

利用者がボタンを押して待つ処理と、夜間バッチで大量データを要約する処理では、待てる時間が違うからです。

利用場面 期限を決める主な材料 期限超過後の例
画面上の短い補助生成 利用者が待てる時間、代替表示 途中終了を表示し、再実行を選べるようにする
バックグラウンド処理 ジョブ全体の締切、キュー滞留 状態を保存し、後続ジョブへ回す
検索・分類API 上位APIのSLO、fallback有無 既定値やキャッシュへ切り替える
長時間ストリーミング 初回応答、無通信時間、全体上限 どの期限を超えたか分けて終了する

ここはAIに決めさせる場所ではありません。

AIは「似た呼び出し箇所」「現在の待ち時間」「fallback候補」を整理できます。でも、利用者が何秒待てるか、未完了の結果を返してよいか、業務締切に間に合わない時にどうするかは、プロダクトと運用を知る人間の判断です。

最初から最適値を当てる必要はありません。まず用途別に仮の期限を置き、elapsed_msを観測して更新する。期限は定数ではなく、判断を更新するための境界です。

const TIMEOUT_MS = {
  interactive: 8_000,
  background: 60_000,
};

const signal = AbortSignal.timeout(TIMEOUT_MS.interactive);

この数字は例です。そのまま本番へコピーせず、自分のSLO、上流の制限、利用者体験から決めてください。

境界2: 期限と利用者キャンセルを合成する

タイムアウトだけでは足りない場面があります。

利用者がページを移動した、上位ジョブが停止した、デプロイで処理を終了した。もう結果が不要なのに、下位のfetch()だけが待ち続けるのはもったいないですよね。

Node.jsのAbortSignal.any()は、複数のsignalを1つに合成できます。どれか1つが中断されると、合成したsignalも中断されます。

fetch-json.mjs
export async function fetchJson(
  url,
  {
    timeoutMs = 8_000,
    callerSignal,
  } = {},
) {
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  const signal = callerSignal
    ? AbortSignal.any([callerSignal, timeoutSignal])
    : timeoutSignal;

  try {
    const response = await fetch(url, { signal });

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

    return await response.json();
  } catch (error) {
    if (signal.aborted) {
      const reason = signal.reason;

      if (reason?.name === "TimeoutError") {
        throw new Error("UPSTREAM_TIMEOUT", { cause: reason });
      }

      throw new Error("CALLER_CANCELLED", { cause: reason });
    }

    throw error;
  }
}

呼び出し元は、利用者キャンセルを伝えられます。

const controller = new AbortController();

const task = fetchJson("https://api.example.invalid/data", {
  timeoutMs: 8_000,
  callerSignal: controller.signal,
});

// 例: 画面遷移やジョブ停止時
controller.abort(new DOMException("No longer needed", "AbortError"));

await task;

AbortSignalは一度中断されると再利用できません。次のリクエストには新しいcontrollerやtimeout signalを作ります。

また、Node.js公式ドキュメントでは、AbortSignal.any()で作ったsignalのreasonは、最初に中断を発生させたsignalのreasonになると説明されています。エラー文の完全一致ではなく、DOMExceptionのnameや自分で定義した分類コードを使う方が、運用上は安定しやすいです。

境界3: retryは「失敗したら全部」ではない

タイムアウトを入れた次に起きやすいのが、retryの入れすぎです。

AI APIが遅いから3回retryする。利用者が100人いれば、上流が混雑している時に最大300回の追加リクエストが向かいます。障害を回復させたいのに、こちらから負荷を足してしまう。

retry前に、最低でも次の3つを確認します。

  1. 同じ操作を再実行して安全か
  2. 失敗が一時的である可能性が高いか
  3. 回数上限と待機時間があるか

次は、説明用にGETだけを対象にした小さなretry例です。

get-with-retry.mjs
const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);

const sleep = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));

export async function getWithRetry(
  url,
  {
    attempts = 3,
    timeoutMs = 2_000,
  } = {},
) {
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
    try {
      const response = await fetch(url, {
        method: "GET",
        signal: AbortSignal.timeout(timeoutMs),
      });

      if (response.ok) {
        return await response.json();
      }

      if (!RETRYABLE_STATUS.has(response.status)) {
        throw new Error(`NON_RETRYABLE_HTTP_${response.status}`);
      }

      if (attempt === attempts) {
        throw new Error(`RETRY_EXHAUSTED_HTTP_${response.status}`);
      }
    } catch (error) {
      const retryableError =
        error.name === "TimeoutError" ||
        error.code === "ECONNRESET";

      if (!retryableError || attempt === attempts) {
        throw error;
      }
    }

    const baseMs = 200 * 2 ** (attempt - 1);
    const jitterMs = Math.floor(Math.random() * 100);
    await sleep(baseMs + jitterMs);
  }

  throw new Error("UNREACHABLE");
}

待機時間を少しずつ増やす方法を 指数バックオフ、同時retryが同じ瞬間へ集中しないようランダムな揺れを足す方法を ジッター と呼びます。

この例はretryの考え方を示すための最小コードです。Retry-Afterヘッダー、総処理時間の上限、サーキットブレーカー、サービス固有のエラー仕様までは含めていません。

特にPOSTは注意が必要です。決済、注文、ジョブ作成、ファイル生成などは、クライアント側でタイムアウトしても上流では成功している場合があります。そこで同じPOSTをretryすると、二重処理になるかもしれません。

POSTを自動retryするかは、APIの冪等性キーや状態照会手段を確認してから、人間が決めます。

境界4: エラー名だけでなく、次の判断をログに残す

console.error(error)だけでは、翌日の自分が困ります。

必要なのは、スタックトレースの量より「何を判断できるか」です。

function logApiFailure({
  operation,
  error,
  elapsedMs,
  attempt,
  timeoutMs,
}) {
  console.error(JSON.stringify({
    event: "upstream_request_failed",
    operation,
    reason: error.name,
    elapsed_ms: elapsedMs,
    attempt,
    timeout_ms: timeoutMs,
  }));
}

最低限、次の項目があると調査しやすくなります。

  • operation: どの業務処理か
  • reason: timeout、caller cancel、HTTP status、network errorのどれか
  • elapsed_ms: 実際に何ms待ったか
  • attempt: 何回目か
  • timeout_ms: 設定した期限
  • request_id: 上流が返す追跡ID。存在する場合だけ

一方で、プロンプト全文、認証ヘッダー、APIキー、個人情報をそのままログへ入れてはいけません。調査に必要な最小項目へ絞り、本文や入力値はマスキング、ハッシュ化、保存しない、のどれを選ぶか決めます。

ログがあると、次の更新ができます。

  • 期限超過が多い処理だけtimeoutを見直す
  • 利用者キャンセルが多い画面はUXを見直す
  • retryの2回目以降で成功していないなら、回数を減らす
  • 特定statusだけ増えたら上流の障害情報と照合する

タイムアウト値を勘で決めて終わるのではなく、観測して更新する。ここまで来ると、待ち時間の設計がチームの資産になります。

AIへ任せる3つのプロンプト

AIは、判断そのものより 判断材料をそろえる作業 に向いています。

プロンプト1: 外部呼び出しの棚卸し

このNode.jsリポジトリから外部HTTP呼び出しを列挙してください。

各呼び出しについて次を表にしてください。
- ファイルと関数
- HTTP method
- 現在のtimeout/cancel設定
- retryの有無
- 利用者操作かbackground処理か
- timeout時のfallback

コードは変更しないでください。
不明な項目は推測せず「要確認」と書いてください。
認証情報や入力本文は出力しないでください。

プロンプト2: retry安全性レビュー

次のAPI呼び出しを、retryして安全かレビューしてください。

確認観点:
1. 冪等性
2. 冪等性キーの有無
3. timeout後に上流の成功状態を照会できるか
4. retry対象にしてよいHTTP statusとnetwork error
5. 最大回数、backoff、jitter
6. 二重処理が起きた場合の影響

結論を断定せず、
- 安全と判断できる根拠
- 不足している証拠
- 人間が決める項目
を分けてください。

プロンプト3: 失敗テスト生成

Node.js標準のnode:testを使い、fetch wrapperの失敗テスト案を作ってください。

最低限のケース:
- 期限内に成功
- TimeoutError
- 呼び出し元のAbortError
- retry対象status
- retry対象外status
- 最大回数で停止

外部APIへ接続せず、ローカルHTTPサーバーで再現してください。
各テストが守る業務上の意図を1文で添えてください。
未確認のNode.js APIは公式URLを示し、推測で補わないでください。

この3本で、棚卸し、危険性の確認、テストのたたき台までは速くできます。

ただし、期限、retry可否、利用者へ返す結果は人間が決めます。AIが「たぶん安全」と言っても、決済や注文の重複を引き受けるのはコードではなく事業だからです。

人間が設計し、AIに任せる範囲

項目 人間が決める AIに任せやすい
期限 SLO、利用者が待てる時間、業務締切 現在値の棚卸し、候補値の比較
キャンセル どの操作で不要になるか signal未伝播箇所の検出
retry 冪等性、二重処理の許容、最大負荷 対象status候補、実装差分
fallback 未完了・キャッシュ・エラー表示の選択 UI文言案、分岐テスト
ログ 保存してよい情報、保持期間 構造化、集計、傾向要約
公開判断 本番投入とロールバック条件 チェックリストと証拠整理

AIへ丸投げするのではなく、人間の判断点が見えるようにAIを使う。

なんかこの方が、速さと安心がケンカしにくい気がするんです。

効かない条件と回避策

この方法にも限界があります。

1. fetchを中断しても、上流処理の停止は保証できない

クライアント側が待つのをやめても、相手サーバーでは処理が続いている場合があります。料金計算や生成処理が止まるかは、上流APIの仕様次第です。

見分け方: APIのキャンセル用endpoint、job ID、状態照会、課金仕様を確認する。

回避策: 長い処理は同期POSTだけで完結させず、job作成→状態照会→明示キャンセルのモデルを検討する。

2. 長時間ストリーミングへ全体timeoutだけを当てると途中で切れる

正常に少しずつデータが届いていても、全体期限を超えれば中断されます。

見分け方: 成功時の処理時間が長く、途中データが継続して届くか。

回避策: 接続開始までの期限、最初の1byteまでの期限、無通信期限、全体上限を分ける。

3. timeoutを短くしすぎると、成功できる処理まで失敗になる

失敗を早く返すことが、いつも良いUXとは限りません。

見分け方: elapsed_msが期限付近へ集中し、retry後に成功する比率が高いか。

回避策: 用途別に期限を分け、実測分布とSLOから更新する。

無料で十分な人もいます。呼び出しが1本だけで、重複処理の危険がなく、利用者キャンセルも不要なら、まずAbortSignal.timeout()の1行とエラー分類だけで十分です。共通wrapperや高度なretry基盤は、呼び出しが増えてからでも遅くありません。

明日そのまま使う導入チェックリスト

  • 外部API呼び出しを1本だけ選ぶ
  • 用途とSLOから仮のtimeoutMsを決める
  • AbortSignal.timeout()を渡す
  • TimeoutErrorと利用者キャンセルを分ける
  • POSTを自動retryする前に冪等性を確認する
  • elapsed_msattempttimeout_msを残す
  • 入力本文、認証情報、個人情報をログへ入れない
  • ローカル遅延サーバーで期限超過をテストする
  • 上流処理や課金が止まるかは別途確認する

最初から全部やらなくて大丈夫です。

今日10分で、AbortSignal.timeout()を1本へ入れ、TimeoutErrorを見えるようにする。待ち時間を「なんとなく」から「選べる境界」へ変える最初の一歩になります。

参考リンク

生成AI活用エンジニア&3児のパパ。AI×開発の実践知を毎日発信しています。続きはプロフィール掲載のXへ。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?