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 失敗後仍在執行的工作。
參考資料: