この記事について
この記事は、生成AIを活用した自動生成フローで作成しています。MDN Web DocsとNode.jsのAbortController・fetch仕様を確認し、外部APIを使わず中断と正常終了を比較するlocalhost実験を用意しました。Node.js実機での動作確認はまだ行っていません。
検証ステータス:📘 MDN・Node.js公式仕様確認済み/実機未確認
JavaScriptのfetch()でAPIを呼び出したところ、相手サーバーが応答せず画面が進まないことがあります。そこで利用できるのがAbortControllerです。一定時間で要求を中断したり、ユーザーがキャンセルボタンを押したときに処理を止めたりできます。
ただし、通信を中断することと、サーバー側の作業を取り消すことは違います。たとえば注文APIへ送信した後で画面を閉じても、サーバーでは注文処理が成功している可能性があります。実際の業務処理では重複防止や結果確認が別に必要です。
AbortControllerの仕組み
AbortControllerにはsignalというオブジェクトがあります。そのsignalをfetchへ渡しておき、後からabort()を呼ぶと、待機中のfetchが中断されます。
const controller = new AbortController();
const responsePromise = fetch("/api/data", {
signal: controller.signal
});
// 必要になったときに中断する
controller.abort();
これは形を示す例であり、/api/dataが存在しないページへそのまま貼り付けて動くものではありません。実際に見比べるため、次ではNode.js上に小さなローカルサーバーを立てます。
そのまま試せるlocalhostのデモ
Node.js 18以上を使用します。ファイル名をabort-demo.jsとして保存します。
const http = require("node:http");
async function main() {
const server = http.createServer((_req, res) => {
// サーバー側は400ミリ秒かけて応答する
setTimeout(() => {
if (!res.writableEnded) {
res.writeHead(200, {"content-type": "text/plain"});
res.end("finished");
}
}, 400);
});
// 外部へ公開しないよう localhost のみに待ち受ける
server.listen(0, "127.0.0.1");
await new Promise(resolve => server.once("listening", resolve));
const url = `http://127.0.0.1:${server.address().port}/`;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 100);
try {
await fetch(url, {signal: controller.signal});
console.log("Unexpectedly completed before abort");
process.exitCode = 1;
} catch (error) {
if (error.name !== "AbortError") throw error;
console.log("First request: AbortError (cancelled by client)");
} finally {
clearTimeout(timer);
}
// 同じサーバーへ今度は中断せずにアクセスする
const response = await fetch(url);
console.log(`Second request: ${response.status} ${await response.text()}`);
} finally {
server.closeAllConnections?.();
await new Promise(resolve => server.close(resolve));
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});
server.listen(0)はOSに空いているポートを選ばせる指定です。ポート番号を固定しないので、ほかのローカルサービスとの衝突を減らせます。
node abort-demo.js
期待される結果は次のとおりです。実機ログではなく、APIの仕様から見込まれる内容です。
First request: AbortError (cancelled by client) Second request: 200 finished
この実験はサーバーを400ms待たせ、1回目のクライアントだけ100msで中断し、2回目は中断せず最後まで取得します。同じローカルサーバーへの二つのアクセスを比較できるため、ネットワークの実通信を外へ出す必要がありません。
GitHubサンプル:AbortControllerとlocalhost fetchにコードと実行方法を保存しています。
なぜfinallyでタイマーを止める?
setTimeoutが残っていると、すでに完了したfetchに対して後からabort()が実行される可能性があります。不要になったタイマーは、成功と失敗のどちらでもclearTimeoutで片付けます。
また、サンプルはサーバーも停止させます。記事のコードをコピーして何度も試したとき、裏でサーバーが起動したままになることを避けるためです。
1か所変更して違いを見る
100を600に変更してから試します。
// 変更前 const timer = setTimeout(() => controller.abort(), 100); // 変更後 const timer = setTimeout(() => controller.abort(), 600);
サーバーの応答はおおむね400msなので、600msにすると中断より先にfetchが完了し、サンプル内の「Unexpectedly completed before abort」という表示になる可能性があります。このコードは中断できなかったことも見逃さず、終了コードを失敗にします。実際のタイミングはOS負荷によって変動するため、応答時間の実測も行ってください。
AbortSignal.timeoutとの違い
近年のJavaScript環境にはAbortSignal.timeout(1000)もあります。
const response = await fetch("/api/data", {
signal: AbortSignal.timeout(1000)
});
この例も対象APIがある環境で使用します。一定時間を超えたとき、AbortSignal.timeoutは通常TimeoutErrorの例外を返します。一方、明示的にcontroller.abort()を呼んだ場合は通常AbortErrorになります。
ブラウザーやNode.jsの対応バージョンは、公開時点で確認してください。特に互換性の必要な業務アプリでは古い環境も対象になることがあります。
エラー時にどう扱うか
| 状態 | 一般的な扱い | 次の確認 |
|---|---|---|
| AbortError | ユーザー操作などによる中断 | 操作中断の理由を表示 |
| TimeoutError | 時間経過による中断 | 応答の遅延・リトライ方針 |
| TypeErrorなど | ネットワーク/その他の取得失敗 | 通信先、権限、CORS、ネットワーク |
| HTTP 500 | fetch自体が解決する場合がある | response.ok / statusで判断 |
特に重要なのは、HTTP 500は必ずしもfetchのPromiseをrejectしないことです。リクエストが届いてHTTP応答を取得できれば、HTTPエラーはresponse.statusで判断します。そこで次のように確認します。
const response = await fetch("/api/data");
if (!response.ok) {
throw new Error(`HTTP status: ${response.status}`);
}
実務で使う場合
データの読取りAPIをリトライするのと、注文・送金などの更新APIをリトライするのは危険度が違います。クライアントが中断したからといって、サーバーで処理が取り消されたとは限りません。
更新系APIの再実行には、冪等キー、処理ID、結果照合、重複防止などの設計が必要です。AbortControllerは通信のキャンセル手段であり、業務の取引取消機能ではありません。
