這篇與上一篇是連貫的,如果你還沒看過,請先看過再來看這篇。
const getTodo = async (
id: number,
opt?: { signal?: AbortSignal },
): Promise<unknown> => {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
signal: opt?.signal,
});
return await res.json();
};
const getTodos = (
ids: number[],
opt?: { limit?: number; signal?: AbortSignal },
) => {
const limit = opt?.limit ?? 5;
const controller = new AbortController();
const remaining = ids
.slice(0, ids.length)
.map((id, index) => [id, index] as const)
.reverse();
const results: unknown[] = [];
if (opt?.signal) {
opt.signal.addEventListener("abort", () => {
controller.abort();
});
}
return new Promise<unknown[]>((resolve, reject) => {
let pending = 0;
for (let i = 0; i < limit; i++) {
fetchRemaining();
}
function fetchRemaining() {
if (remaining.length > 0) {
const [remainingToFetchId, remainingToFetchIdx] = remaining.pop()!;
pending++;
getTodo(remainingToFetchId, { signal: controller.signal })
.then((res) => {
results[remainingToFetchIdx] = res;
pending--;
fetchRemaining();
})
.catch((err) => {
controller.abort();
return reject(err);
});
} else if (pending === 0) {
resolve(results);
}
}
});
};
getTodos
可取消:呼叫 getTodos(ids, { signal })
;當你對該 signal
呼叫 abort()
,我們會把中止訊號轉給所有進行中的 fetch
,它們會立刻中斷後續行為。.catch
到錯誤後,會立刻 controller.abort()
並 reject(error)
。這樣可以避免資源浪費(網路、CPU)並加速失敗回報(fail fast)。results[originalIndex] = res
保證結果順序與輸入 ids
相同。fetch
在收到中止訊號會拋出 AbortError
,最先抵達的錯誤會讓最外層 Promise 失敗。其餘同時被中止的請求也會各自結束,不會留下懸掛任務。pending
與 remaining
構成簡單的「併發請求池」。一旦中止,池中 worker 不再遞補新任務,既有中的任務也會被中止。const controller = new AbortController();
const p = getTodos([1,2,3,4,5,6,7,8,9,10], {
limit: 5,
signal: controller.signal,
});
// 例如超過 300ms 還沒完成就中止
setTimeout(() => controller.abort(), 300);
p.catch((err) => {
if ((err as any)?.name === "AbortError") {
// 被中止
return;
}
// 其他錯誤處理
});
opt.signal.addEventListener
綁定後未移除,可在監聽時加上 { once: true }
,或在 Promise settle 後手動移除。
opt.signal.addEventListener("abort", () => controller.abort(), { once: true })
AbortError
,避免把中止誤當成系統故障。{ successes: A[], errors: Error[] }
,而非立即 reject
。aborted: true
以利追蹤。透過網路發出的請求,隨時可能因為連線問題而失敗;我們需要一套錯誤處理機制來避免因為暫時性錯誤而讓整個流程中斷。這時就會用到 retry(重試)機制——簡單說,就是在失敗時再嘗試幾次。底下是實作程式碼:
const wait = (ms: number) => new Promise((res) => setTimeout(res, ms));
const callWithRetry = async (
fn: () => Promise<unknown>,
opt?: { limit?: number; cap?: number; base?: number; unitMs?: number },
depth = 0,
): Promise<unknown> => {
try {
return await fn();
} catch (error) {
if (depth >= (opt?.limit ?? 10)) {
throw error;
}
await wait(
Math.min((opt?.base ?? 2) ** depth * (opt?.unitMs ?? 10), opt?.cap ?? 2000),
);
return callWithRetry(fn, opt, depth + 1);
}
};
const getTodo = async (
id: number,
opt?: { signal?: AbortSignal },
): Promise<unknown> => {
return callWithRetry(
async () => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/todos/${id}`,
{ signal: opt?.signal },
);
return await res.json();
},
{ limit: 10, cap: 2000, base: 2, unitMs: 10 },
);
};
上面實現了簡單的指數退避(Exponential Backoff),簡單來說,就是 retry 的時候,每次等待的時間會隨指數型增長。這樣可以避免因為錯誤導致 server 受到持續性的大量請求。
wait(ms)
:將 setTimeout
包成 Promise
,用來在重試前延遲一段時間。callWithRetry(fn, opt, depth)
:
depth
代表第幾次重試遞迴層級;第一次執行 fn()
成功就直接回傳,失敗才進入 catch
並等待後重試。depth >= limit
(預設 10)時丟出原始錯誤,避免無限重試。delayMs = min(base^depth * unitMs, cap)
base
:指數底數(預設 2),每次重試倍增等待。unitMs
:基礎時間單位(預設 10ms)。cap
:上限(預設 2000ms),避免等待時間無限制成長。base=2, unitMs=10, cap=2000
時,依序為 10, 20, 40, 80, 160, 320, 640, 1280, 2000, 2000...
(達上限後維持)。Math.min
:控制最大等待時間,避免對下游服務造成雪崩式壓力或拖累整體延遲。5xx
)重試,對於 4xx
(如 400/401/404
)通常不應重試。小提醒:指數退避通常還會搭配「抖動(Jitter)」降低同時重試的同步化風險,例如將延遲乘上一個
0.5~1.5
的隨機係數,以減少瞬間尖峰。雖然我們這裡沒有實作,但這也是產品級的服務需要考慮的。
在產品級應用中,logging 提供可觀測性(發生了什麼、何時、在哪裡、錯誤細節)。
先實作一個建立 logger 的方法:
type LogLevel = "debug" | "info" | "warn" | "error";
export type Logger = {
debug: (message: string, context?: Record<string, unknown>) => void;
info: (message: string, context?: Record<string, unknown>) => void;
warn: (message: string, context?: Record<string, unknown>) => void;
error: (
message: string,
error?: unknown,
context?: Record<string, unknown>,
) => void;
timer: (
name: string,
context?: Record<string, unknown>,
) => (
success?: boolean,
error?: unknown,
extraContext?: Record<string, unknown>,
) => void;
};
const levelPriority: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
const mask = (
obj: Record<string, unknown> | undefined,
keys: string[],
): Record<string, unknown> | undefined => {
if (!obj) return obj;
const out: Record<string, unknown> = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
out[key] = keys.includes(key) ? "[REDACTED]" : (obj as any)[key];
}
}
return out;
};
export const createLogger = (options?: {
level?: LogLevel;
redactKeys?: string[];
}): Logger => {
const minPriority = levelPriority[options?.level ?? "info"];
const redactKeys = options?.redactKeys ?? [];
const log = (
level: LogLevel,
message: string,
context?: Record<string, unknown>,
error?: unknown,
) => {
if (levelPriority[level] < minPriority) return;
const payload = mask(context, redactKeys);
const method =
level === "error"
? console.error
: level === "warn"
? console.warn
: level === "info"
? console.info
: console.debug;
error
? method(message, { ...(payload ?? {}), error })
: method(message, payload);
};
return {
debug: (message, context) => log("debug", message, context),
info: (message, context) => log("info", message, context),
warn: (message, context) => log("warn", message, context),
error: (message, error, context) => log("error", message, context, error),
timer: (name, context) => {
const start = Date.now();
return (success = true, error, extraContext) => {
const durationMs = Date.now() - start;
const contextToLog = {
durationMs,
...(context ?? {}),
...(extraContext ?? {}),
};
success
? log("info", `${name} completed`, contextToLog)
: log("error", `${name} failed`, contextToLog, error);
};
},
};
};
levelPriority
搭配 minPriority
控制輸出層級。mask
依 redactKeys
將敏感欄位改為 "[REDACTED]"
。log
依層級選擇 console
方法,並統一結構化輸出。timer(name, context)
回傳結束函式,結束時自動計時並輸出成功/失敗與額外上下文。先建立一個 logger 實例,並在 getTodos
中使用。
const logger = createLogger({
level: "info",
redactKeys: ["password", "token", "authorization"],
});
const getTodos = (
ids: number[],
opt?: { limit?: number; signal?: AbortSignal; logger?: Logger },
) => {
const log = opt?.logger ?? createLogger({ level: "info" });
const limit = opt?.limit ?? 5;
const controller = new AbortController();
const remaining = ids
.slice(0, ids.length)
.map((id, index) => [id, index] as const)
.reverse();
const results: unknown[] = [];
if (opt?.signal) {
opt.signal.addEventListener("abort", () => {
log.warn("getTodos aborted by caller");
controller.abort();
});
}
return new Promise<unknown[]>((resolve, reject) => {
const stopTimer = log.timer("getTodos", { total: ids.length, limit });
let pending = 0;
log.info("getTodos start", { remaining: remaining.length });
for (let i = 0; i < limit; i++) {
fetchRemaining();
}
function fetchRemaining() {
if (remaining.length > 0) {
const [idToFetch, originalIndex] = remaining.pop()!;
pending++;
log.info("fetch todo", { id: idToFetch, pending });
getTodo(idToFetch, { signal: controller.signal })
.then((res) => {
results[originalIndex] = res;
pending--;
fetchRemaining();
})
.catch((error) => {
log.error("fetch todo failed", error, { id: idToFetch });
controller.abort();
stopTimer(false, error, {
fetched: results.filter((x) => x != null).length,
});
return reject(error);
});
} else if (pending === 0) {
log.info("getTodos completed", { count: results.length });
stopTimer(true, undefined, { count: results.length });
resolve(results);
}
}
});
};
getTodos
時記錄起始(info
),包含 total
、limit
、remaining
。id
發出請求前記錄(info
),包含該 id
與當下 pending
。warn
),並中止所有進行中的請求。"fetch todo failed"
,內容含失敗的 id
與 error
。"getTodos failed"
,內容含 durationMs
與目前已完成數 fetched
。pending === 0
時記錄(info
),包含最終 count
,並結束計時。這裡的 logging 機制只是一個簡單範例。產品級的 logger 還需要更多功能,例如除了 console.log,也應提供其他輸出管道,例如輸出到第三方 logging 服務,或具備 child logger 機制。
完整程式碼在這裡,歡迎拉下來跑跑看。
看似只是簡單地取得 Todo 資料,卻變得這麼複雜。但我們的程式碼離產品級軟體的實作標準還有非常大的距離。無論是在剛剛我們實作的面相上(中斷機制、重試機制、logging),還是其他面向,例如 timeout 機制、切換瀏覽器頁籤返回後的自動 refetch、metrics(度量指標)、tracing、telemetry 等等。若你也想從一般工程師(Average Engineer)成長為產品級工程師,我認為在產品成長過程中持續學習,掌握各個節點的最佳實踐,是非常有效的路徑。別讓自己一直停留在 POC 階段,否則很難做出真正好的產品。順帶一提,網路上充斥著許多非產品級的範例,AI 預設產生的內容也未必夠穩健(robust);唯有具備相應知識,才能引導 AI 產出符合產品級要求的結果。這也是希望避免被 AI 取代的一般工程師可以努力的方向。