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 的操作也不在等待範圍內。
參考資料: