3123 字
16 分鐘

Promise.allSettled 怎麼用?多個 API 部分失敗時保留成功結果

驗證於

本文目錄

儀表板同時讀取帳號、通知與推薦內容時,如果通知 API 失敗就把整頁換成錯誤畫面,成功取得的帳號資料也會被藏掉。當這些資料可以各自顯示,Promise.allSettled() 能讓程式拿到每一項成功或失敗的結果,再替不同區塊決定 fallback。

資料彼此獨立、需要保留部分成功結果時用 allSettled;必須全數成功才能繼續時用 Promise.all。 allSettled 不會替請求設定 timeout、限制並行數或取消其他工作,這些仍要在任務本身設計。

catch 記錄錯誤後,為什麼 allSettled 反而是 fulfilled?#

allSettled 觀察的是你交給它的 Promise。若任務內已經 .catch(error => { /* 記錄 */ }),callback 正常結束會回傳 undefined,後續 Promise 就是 fulfilled。換成 catch(error => error) 也一樣:Error 物件是正常回傳值,不是 rejection。這是 Promise.catch 的回傳規則,不能只看 console 印過錯誤就預期 rejected。

讓 allSettled 負責分類時,可把原始 rejection 直接交給它,再遍歷結果記錄錯誤;如果一定要在任務內記錄,須重新 throw 原錯誤。日誌函式本身若拋錯,也可能取代原原因,應另訂記錄失敗的處理方式。

以下可獨立存成 .mjs 執行,用同一個失敗比較四種契約:

import assert from 'node:assert/strict';
const failure = new Error('notifications unavailable');
const logs = [];
const fail = () => Promise.reject(failure);
const results = await Promise.allSettled([
fail().catch(error => { logs.push(error); }),
fail().catch(error => error),
fail().catch(error => { logs.push(error); throw error; }),
fail().catch(() => ({ kind: 'fallback', items: [] })),
]);
assert.deepEqual(results[0], { status: 'fulfilled', value: undefined });
assert.equal(results[1].status, 'fulfilled');
assert.equal(results[1].value, failure);
assert.equal(results[2].status, 'rejected');
assert.equal(results[2].reason, failure);
assert.deepEqual(results[3], {
status: 'fulfilled', value: { kind: 'fallback', items: [] },
});
assert.deepEqual(logs, [failure, failure]);
// 將記錄移到聚合之後,不會改掉聚合器已取得的分類。
const raw = await Promise.allSettled([fail(), Promise.resolve([])]);
const rejected = raw.filter(result => result.status === 'rejected');
assert.equal(rejected.length, 1);
assert.equal(rejected[0].reason, failure);
assert.deepEqual(raw[1], { status: 'fulfilled', value: [] });
console.log('catch recovery versus rejection: PASS');

第四項是明確恢復成可用結果,fulfilled 正是預期行為;畫面再用 kind 區分正常內容與 fallback,不能只用外層 status 宣稱 API 成功。相反地,沒有契約就回傳空陣列,會把「真的沒有通知」和「通知服務失敗」混在一起。

這份測試於 2026-10-30 由維護流程在 macOS、Node.js v24.13.0 執行,只驗證 Promise 鏈的錯誤傳遞;沒有呼叫產品 API 或測試日誌服務。HTTP 404/500 的 fetch 本身也可能 fulfilled,仍要在任務內檢查 response.ok;詳見 fetch JSON 分層排錯。

結果依輸入順序排列,先替工作命名#

MDN 說明,每個結果都有 status:成功時是 fulfilled 並含 value,失敗時是 rejected 並含 reason。結果順序依輸入順序,不依完成速度。

只記住「第零項是帳號」很容易在新增 API 後拿錯資料。下面讓每個任務先有名稱,並在同一個索引上配回結果:

async function settleNamed(tasks) {
if (!Array.isArray(tasks)) throw new TypeError('tasks must be an array');
const names = [];
for (let index = 0; index < tasks.length; index += 1) {
if (!Object.hasOwn(tasks, index)) throw new TypeError('tasks must be dense');
const task = tasks[index];
if (!task || typeof task.name !== 'string' || task.name.trim() === '' ||
typeof task.run !== 'function') {
throw new TypeError('each task needs a non-empty string name and run function');
}
names.push(task.name);
}
if (new Set(names).size !== names.length) {
throw new Error('task names must be unique');
}
const results = await Promise.allSettled(
tasks.map((task) => Promise.resolve().then(() => task.run())),
);
return Object.fromEntries(results.map((result, index) => [
names[index], result,
]));
}

這是文章自己的 wrapper,並非新的內建 API。Promise.resolve().then(...) 讓 run() 在 Promise 鏈中執行,即使它同步 throw,也會成為該任務的 rejected 結果;若直接寫 tasks.map(task => task.run()),同步例外可能在進入 allSettled 前就中斷整個 map。

範例拒絕重複名稱,避免 Object.fromEntries 讓後面的結果蓋掉前面的結果。正式專案可再用 TypeScript 定義各任務的資料型別;先有明確對應,才知道畫面上的錯誤屬於哪個來源。

名稱先驗證,避免 1 與字串「1」覆蓋結果#

原本只用 Set 檢查重複名稱還不夠:1 與 '1' 在 Set 裡不同,但交給 Object.fromEntries 都會變成字串 key '1',後者會覆蓋前者。新版 wrapper 要求名稱為非空白字串,並在任何 run() 啟動前驗證整份清單。名稱保留原樣,不自動 trim;'profile' 與 ' profile ' 仍是不同識別字,呼叫端應統一命名規則。

稀疏陣列也會破壞名稱與結果的對應:map 會跳過空洞,但 allSettled 迭代陣列時仍會取得該位置的 undefined。這裡明確拒絕空洞,也確認每項都有可呼叫的 run。此契約適用一般、執行期間不變動的工作描述物件;沒有宣稱可防禦會變動的 getter、Proxy 或其他程式同時修改清單。

以下接在 settleNamed 後,存為 names-check.mjs 執行。它同時檢查無效清單沒有啟動工作,以及 fulfilled 的 undefined/rejected 的 undefined 不能用真偽值混判:

import assert from 'node:assert/strict';
let started = 0;
const run = () => { started += 1; return 1; };
for (const tasks of [
[{ name: 1, run }, { name: '1', run }],
[{ name: 'ok', run }, { name: 'ok', run }],
[{ name: 'ok', run }, , { name: 'last', run }],
[{ name: 'ok', run }, { name: 'bad', run: null }],
[{ name: 'ok', run }, { name: ' ', run }],
]) {
await assert.rejects(settleNamed(tasks));
assert.equal(started, 0);
}
const result = await settleNamed([
{ name: 'empty', run: () => undefined },
{ name: 'failed', run: () => Promise.reject(undefined) },
{ name: '__proto__', run: () => 42 },
]);
assert.equal(result.empty.status, 'fulfilled');
assert.equal(result.empty.value, undefined);
assert.equal(result.failed.status, 'rejected');
assert.equal(result.failed.reason, undefined);
assert.equal(Object.hasOwn(result, '__proto__'), true);
assert.equal(result.__proto__.value, 42);
assert.equal(Object.getPrototypeOf(result), Object.prototype);
assert.deepEqual(await settleNamed([]), {});
console.log('名稱、啟動前驗證與結果狀態檢查通過');

這組測試於 2026-10-25 由維護流程在 Node.js v24.13.0 執行。Object.fromEntries 可以建立名為 __proto__ 的自有資料屬性,這裡沒有用動態賦值模擬它;讀取結果仍應用 status 分流,確認 key 是否存在則用 Object.hasOwn。這次沒有重跑後面的 HTTP 或 p-limit 測試,原測試日期與環境仍各自適用。

fetch 要自己判斷 HTTP 錯誤與逾時#

fetch 遇到 HTTP 404、500 不會自動 reject。若沒有檢查 response.ok,allSettled 可能把錯誤頁面當作成功資料。以下 helper 在每次呼叫時建立新的 timeout signal:

async function fetchJson(url, timeoutMs = 3_000) {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
}
const sections = await settleNamed([
{ name: 'profile', run: () => fetchJson('/api/profile') },
{ name: 'notifications', run: () => fetchJson('/api/notifications') },
{ name: 'recommendations', run: () => fetchJson('/api/recommendations') },
]);
if (sections.profile.status === 'fulfilled') {
console.log('帳號資料', sections.profile.value);
} else {
console.error('帳號區塊載入失敗', sections.profile.reason);
}

三個 /api/ 路徑是產品整合示意,請換成實際 endpoint。UI 可讓通知區顯示重試按鈕、推薦區顯示預設內容,帳號區則依產品需求決定是否為必要資料。

必要與選用資料不一定要放在同一組等待:如果帳號決定權限,先確認帳號,再載入依賴該權限的內容;若希望各區塊一完成就呈現,可以在個別任務完成時更新 UI。allSettled 的聚合 Promise 會等全部結束,並不提供逐項串流結果。

timeout 也包含回應本文讀取。詳細的取消原因與 signal 生命週期可看 fetch timeout 的處理方式。重試時要建立新請求與新 signal,不能重用已中止的物件。

用可控制的任務驗證順序與部分失敗#

把 settleNamed 放入 settled-check.mjs,附上以下測試,再執行 node settled-check.mjs。這組範例於 2026-10-12 維護時在 Node.js v24.13.0 執行;2026-10-25 的新增驗證見前面的名稱測試:

import assert from 'node:assert/strict';
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const completed = [];
const data = await settleNamed([
{ name: 'slow', run: async () => {
await wait(20);
completed.push('slow');
return 42;
} },
{ name: 'broken', run: () => { throw new Error('unavailable'); } },
{ name: 'fast', run: async () => {
completed.push('fast');
return 7;
} },
]);
assert.deepEqual(completed, ['fast', 'slow']);
assert.deepEqual(Object.keys(data), ['slow', 'broken', 'fast']);
assert.equal(data.slow.value, 42);
assert.equal(data.broken.status, 'rejected');
assert.equal(data.broken.reason.message, 'unavailable');
assert.equal(data.fast.value, 7);
assert.deepEqual(await settleNamed([]), {});
await assert.rejects(settleNamed([
{ name: 'same', run: () => 1 },
{ name: 'same', run: () => 2 },
]));
console.log('allSettled checks passed');

2026-10-03 維護流程另以本機 HTTP 伺服器確認了成功 JSON、HTTP 503 與慢回應 timeout 可以一起保留各自結果;本次沒有重跑這組 HTTP 測試。這些驗證處理的是 JavaScript 與請求邊界,沒有測試儀表板畫面、登入狀態或跨來源請求。

保留部分成功結果,也限制同時執行數#

若通知來源從三個增加到數十個,原本的結果對應仍有用,但需要控制啟動時點。p-limit 官方 README 的介面是 limit(fn):名額可用時才呼叫函式。傳進去的是尚未開始的工作函式,不能先建立全部請求,再期待 limiter 倒回去限制它們。

這段延伸前面的 settleNamed,維持每個名稱的 fulfilled/rejected 結果。run() 必須回傳涵蓋完整操作的 Promise;若啟動背景請求後立刻回傳,名額會過早釋放。任務清單應是沒有空洞的陣列。

在獨立測試資料夾安裝本文查核的版本;p-limit 7.3.3 要求 Node.js 20 以上:

Terminal window
npm install p-limit@7.3.3

把前面的 settleNamed 與以下 helper 放入 limited-check.mjs:

import pLimit from 'p-limit';
async function settleNamedLimited(tasks, concurrency) {
const limit = pLimit(concurrency);
return settleNamed(tasks.map(task => ({
name: task.name,
run: () => limit(() => task.run()),
})));
}

例如把原本的 settleNamed([...]) 換成 settleNamedLimited([...], 2),最多同時執行兩個 run()。原先的重複名稱檢查仍在工作啟動前進行。單項失敗會成為 rejected 結果,空出的名額仍可處理下一項;它不會因一項失敗而丟掉其餘結果。

下面測試用手動 gate 控制任務何時結束,不靠網路速度或固定等待時間猜測並行數。接在同一份檔案後執行 node limited-check.mjs:

import assert from 'node:assert/strict';
function deferred() {
let resolve;
const promise = new Promise(ok => { resolve = ok; });
return { promise, resolve };
}
const gates = Array.from({ length: 4 }, deferred);
const entered = Array.from({ length: 4 }, deferred);
const started = [];
let active = 0;
let peak = 0;
const failure = new Error('第三項失敗');
const tasks = gates.map((gate, index) => ({
name: `section-${index}`,
run: async () => {
started.push(index);
active += 1;
peak = Math.max(peak, active);
entered[index].resolve();
try {
await gate.promise;
if (index === 2) throw failure;
return index * 10;
} finally {
active -= 1;
}
},
}));
const batch = settleNamedLimited(tasks, 2);
await Promise.all([entered[0].promise, entered[1].promise]);
assert.deepEqual(started, [0, 1]);
assert.equal(active, 2);
gates[1].resolve();
await entered[2].promise;
assert.deepEqual(started, [0, 1, 2]);
gates[2].resolve();
await entered[3].promise;
assert.equal(active, 2);
gates[3].resolve();
gates[0].resolve();
const results = await batch;
assert.equal(peak, 2);
assert.equal(active, 0);
assert.deepEqual(Object.keys(results), tasks.map(task => task.name));
assert.equal(results['section-0'].value, 0);
assert.equal(results['section-1'].value, 10);
assert.equal(results['section-2'].status, 'rejected');
assert.equal(results['section-2'].reason, failure);
assert.equal(results['section-3'].value, 30);
assert.deepEqual(await settleNamedLimited([], 2), {});
await assert.rejects(settleNamedLimited([], 0), TypeError);
console.log('並行上限、失敗後續處理與名稱對應檢查通過');

本次 2026-10-12 維護流程在 macOS、Node.js v24.13.0 與 p-limit 7.3.3 執行上述組合,檢查通過;原本的同步拋錯與結果順序測試也重新執行。這次沒有呼叫真實 API 或測試瀏覽器 UI,先前本機 HTTP 測試的執行日期仍是 2026-10-03。

concurrency = 2 管的是同時未結束的工作數,不是每秒兩次請求。兩個工作很快結束時,一秒內仍可能啟動很多項;遇到 HTTP 429,需另外遵守 API 的速率配額與重試規則。這個 helper 每次建立自己的 limiter,同時呼叫兩批就可能各跑兩項;若多批共享同一個下游配額,應在它們共同的呼叫邊界重用同一個 limiter。

這份範例會為整個清單建立排隊項目,沒有處理無限來源或大量資料的記憶體背壓;也沒有新增取消功能。任務仍需自己的 timeout,否則卡住的工作會一直占著名額。若要在全部工作結束後才清理共享資源,可接著看 Promise.all 失敗後的等待與清理邊界。

allSettled 仍會卡住,也不會替你省請求#

只要有一項 Promise 永遠 pending,聚合就會一直等待。因此 timeout 要放在會停滯的任務上;只在外面用 Promise.race 停止等待,底層工作仍可能繼續執行。

另外,tasks.map 會把所有工作建立出來。十項和一千項都不會因為 allSettled 自動變成每次五項;大量請求要另用批次或 concurrency limiter。也不要用這個 wrapper 把必須依序執行的資料庫寫入、建立訂單和付款改成並行。

Promise.all 在第一項 reject 時會較早回報錯誤,但沒有取消其他 Promise。選擇兩者的核心是結果處理方式,不能拿它們作為資源取消或併發控制機制。

要上線前,至少讓每個失敗結果都有對應的 UI 或紀錄。若只是把 rejected 全部濾掉,沒有說明缺了哪些資料,使用者看到的「成功畫面」仍可能是不完整的。

參考資料:

MDN:Promise.prototype.catch()

MDN:Promise.allSettled()

MDN:Promise.all()

MDN:Using the Fetch API

MDN:AbortSignal.timeout()

p-limit 7.3.3:延後啟動與並行限制

MDN:Object.fromEntries()

MDN:Array.prototype.map() 與稀疏陣列

Promise.allSettled 怎麼用?多個 API 部分失敗時保留成功結果
https://laplusda.com/posts/javascript-promise-allsettled-partial-failure/
作者
Zero
發佈於
2026-10-03
許可協議
CC BY-NC-SA 4.0