1971 字
10 分鐘

Promise.any 搶到錯誤回應?先驗證 HTTP 與 JSON 再選最快結果

驗證於

本文目錄

同一份唯讀資料有兩個鏡像,你用 Promise.any(urls.map(url => fetch(url))) 想拿最快的可用結果,卻拿到 503。原因是 Promise.any 選第一個 fulfilled,而 fetch 收到 HTTP 錯誤狀態仍可 fulfilled。先回傳 Response,並不代表內容可以顯示。

要選「第一份可用資料」,每個候選 Promise 都必須包含狀態碼檢查、本文讀取與資料驗證。等這些都通過才 fulfilled;這樣最快抵達的錯誤頁才不會搶先勝出。

Promise.any 的成功條件,由傳入的工作決定#

Promise.any 的規則是等待第一個 fulfilled;較早的 rejection 不會讓整批立刻失敗。Promise.race 則採第一個 settled,包含失敗。換成 race 無法解決錯誤頁勝出的問題,還會讓最快的網路錯誤直接結束整批等待。

三個邊界要放在同一份候選工作內:

  • response.ok:拒絕非 2xx 回應。若產品只接受特定狀態,例如必須有本文的 200,應再縮小條件。
  • await response.json():等本文完整讀取、解析成功,不能拿到標頭就宣布勝出。
  • 資料驗證:合法 JSON 也可能是 {"error":"unavailable"},仍要確認需要的欄位。

下面是文章自己的 helper,僅示範可公開讀取、內容等價的 JSON 鏡像。它要求 title 是非空白字串,回傳來源 URL 方便追查;正式資料還應依 schema 與版本檢查完整性。

async function readCandidate(url) {
const response = await fetch(url, {
signal: AbortSignal.timeout(3_000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (typeof data?.title !== 'string' || data.title.trim() === '') {
throw new TypeError('title must be a non-empty string');
}
return { url, data };
}

每次呼叫建立自己的 timeout signal;本文讀取仍使用該請求的 signal。3 秒是 fixture 的上限,正式服務應另訂時間預算。對本文取消與 timeout reason 有需求時,可接著看 fetch timeout 與 AbortSignal 的生命週期。

用本機伺服器重現 503、錯誤格式與有效資料#

把上面的 helper 和下面程式存成同一份 any-check.mjs,執行 node any-check.mjs。fixture 刻意讓有效資料等待 gate;兩個不合格候選處理完才放行,避免測試依賴網路速度或毫秒競賽。

import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
let release;
const gate = new Promise(resolve => { release = resolve; });
const server = http.createServer(async (req, res) => {
res.setHeader('Content-Type', 'application/json');
if (req.url === '/good') {
await gate;
res.end('{"title":"可用資料"}');
} else if (req.url === '/status') {
res.statusCode = 503;
res.end('{"error":"unavailable"}');
} else if (req.url === '/syntax') {
res.end('not json');
} else {
res.end('{"error":"wrong shape"}');
}
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const base = `http://127.0.0.1:${server.address().port}`;
try {
// HTTP 503 本身不會讓 fetch reject。
const raw = await fetch(base + '/status');
assert.equal(raw.ok, false);
assert.equal(raw.status, 503);
await raw.text();
const urls = ['/good', '/status', '/shape'].map(path => base + path);
const jobs = urls.map(readCandidate);
let settled = false;
const winner = Promise.any(jobs).finally(() => { settled = true; });
await Promise.allSettled(jobs.slice(1));
assert.equal(settled, false);
release();
assert.deepEqual(await winner, {
url: base + '/good', data: { title: '可用資料' },
});
await Promise.allSettled(jobs);
await assert.rejects(Promise.any([
readCandidate(base + '/status'),
readCandidate(base + '/syntax'),
readCandidate(base + '/shape'),
]), error => {
assert.ok(error instanceof AggregateError);
assert.equal(error.errors.length, 3);
assert.equal(error.errors[0].message, 'HTTP 503');
assert.ok(error.errors[1] instanceof SyntaxError);
assert.ok(error.errors[2] instanceof TypeError);
return true;
});
await assert.rejects(Promise.any([]), error => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, []);
return true;
});
console.log('HTTP、解析、格式、全部失敗與空清單檢查通過');
} finally {
release();
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}

這份刊登範例由內容維護流程於 2026-10-25 在 Node.js v24.13.0 執行。它驗證候選資料與錯誤分類,沒有量測鏡像加速效果,也沒有驗證瀏覽器、CORS、認證或正式 API。

全部失敗時,依來源保存 errors#

ECMAScript 規格以輸入索引保存 rejection reason。因此 AggregateError.errors[0] 對應第一個候選,並非最早出錯的來源。記錄時把原本的 URL 清單配回同索引,保留 HTTP、解析與 schema 錯誤的差別,不要只顯示 AggregateError 的通用 message。

空清單也會 reject,errors 為空;如果「沒有設定鏡像」屬於設定錯誤,應在建立請求前單獨回報。若某個候選一直 pending,且其他候選全失敗,any 也會繼續等,所以每個工作都需要適用的結束條件。

有了勝出者,其他請求仍可能繼續#

Promise.any 不會取消剩餘操作。範例以每次請求自己的 timeout 限制等待,但沒有在勝出後主動 abort 其餘請求;fixture 會等待本批 jobs 全部 settled 再釋放伺服器。若正式產品需要節省流量,可為各候選配置 controller,取得有效本文後取消剩餘工作,並另外驗證取消、清理與錯誤紀錄。

不要把這個模式套在建立訂單、扣款或其他有副作用的 POST:同時送出多份操作,拿到一份成功不會撤銷其餘操作。即使是唯讀鏡像,也要先確認內容版本等價、目的地允許存取,以及多份請求不會超出配額。需要呈現每個來源各自的結果時,應改看 allSettled 的部分成功處理;需要釋放共用資源,則先處理 Promise.all 失敗後仍在執行的工作。

需要清理完成才回傳時,包住整個候選工作#

取得有效資料後可以主動取消其他候選,但 abort() 只發通知;它不代表工作與清理已完成。若呼叫端接著會釋放共用資源,需要等待剩餘 Promise settled,再回傳勝出資料。這也代表畫面拿到結果的時間包含清理等待,不能同時承諾最短回傳延遲。

以下 helper 接收非空、密集的函式陣列,每個函式收到自己的 signal,回傳值必須涵蓋完整操作與清理。實際整合 readCandidate() 時,需要改成接受外部 signal 並傳給 fetch;下列驗證使用可控制的工作,沒有連正式鏡像。

async function firstValidAndWait(tasks) {
if (!Array.isArray(tasks) || tasks.length === 0) {
throw new TypeError('Expected non-empty task array');
}
for (let i = 0; i < tasks.length; i += 1) {
if (!Object.hasOwn(tasks, i) || typeof tasks[i] !== 'function') {
throw new TypeError('Expected dense task functions');
}
}
const controllers = tasks.map(() => new AbortController());
const jobs = tasks.map((task, index) => Promise.resolve()
.then(() => task(controllers[index].signal))
.then(value => ({ index, value })));
let winnerIndex = -1;
try {
const winner = await Promise.any(jobs);
winnerIndex = winner.index;
return winner.value;
} finally {
controllers.forEach((controller, index) => {
if (index !== winnerIndex) controller.abort();
});
await Promise.allSettled(jobs);
}
}

它以 Promise.resolve().then() 收進同步 throw,讓其他候選仍能啟動;不合格結果必須由 task 自行 reject。全部失敗時保留原 AggregateError,清理失敗不會覆蓋已選出的成功資料。這個政策適合「成功資料仍有效、其他工作失敗另行觀測」的情境;若清理失敗也應使整批失敗,需要另訂回傳契約。

把以下斷言接在 helper 後面,存成 any-cleanup-check.mjs 執行。gate 控制清理何時結束,沒有用固定睡眠猜測事件順序:

import assert from 'node:assert/strict';
let observeAbort;
const aborted = new Promise(resolve => { observeAbort = resolve; });
let finishCleanup;
const cleanupGate = new Promise(resolve => { finishCleanup = resolve; });
let finished = false;
const result = firstValidAndWait([
async () => 'valid data',
async signal => {
try {
await new Promise((resolve, reject) => {
const onAbort = () => {
observeAbort();
reject(signal.reason);
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
});
} finally {
await cleanupGate;
}
},
]).finally(() => { finished = true; });
await aborted;
assert.equal(finished, false);
finishCleanup();
assert.equal(await result, 'valid data');
assert.equal(finished, true);
const firstError = new Error('first input');
const secondError = new Error('second input');
await assert.rejects(firstValidAndWait([
() => { throw firstError; },
async () => { throw secondError; },
]), error => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(error.errors, [firstError, secondError]);
return true;
});
await assert.rejects(firstValidAndWait([]), TypeError);
await assert.rejects(firstValidAndWait(new Array(2)), TypeError);
assert.equal(await firstValidAndWait([
() => { throw new Error('sync failure'); },
async () => 'second candidate',
]), 'second candidate');
console.log('勝出資料、取消通知、清理等待與全部失敗檢查通過');

2026-10-29 維護流程在 macOS、Node.js v24.13.0 執行上述 helper/fixture 及原有 loopback HTTP 範例通過。新增範例沒有測真實 fetch 取消、伺服器端停止或框架卸載。task 若忽略 signal 且永不 settled,helper 也會一直等;它沒有強制終止、並行數上限或整體 deadline。工作自己的 timeout 與完整清理仍不可省略,背景啟動卻沒有納入回傳 Promise 的操作也不在等待範圍內。

參考資料:

MDN:Promise.any()

MDN:Using the Fetch API

ECMAScript:Promise.any

MDN:AbortSignal 與可取消 API

Promise.any 搶到錯誤回應?先驗證 HTTP 與 JSON 再選最快結果
https://laplusda.com/posts/javascript-promise-any-valid-response/
作者
Zero
發佈於
2026-10-25
許可協議
CC BY-NC-SA 4.0