Sync and Async Run on Different Tracks: Know the Two Catches
try/catch only catches synchronous throws and an await of a rejected Promise. A throw inside a setTimeout callback, or a Promise rejected without being awaited, escapes try/catch.
Inside an async function you can either wrap await in try/catch or return the promise for an outer .catch. The choice is whether to recover locally (try/catch) or push it to the caller (return the promise).
A classic trip: wrapping a whole await sequence in try/catch while one of the awaited ops is actually not awaited — that error leaks to unhandledrejection.
// 异步里的 throw 不会被外层 try/catch 拦住:
try {
setTimeout(() => { throw new Error("boom"); }, 0);
} catch (e) {
console.log("caught"); // 永远不会到
}
// 正确接住异步错误:await 必须真的被 await
try {
await risky();
} catch (e) {
console.log("caught", e.message);
}
// 或者把 promise 交给调用方
function loadUser(id) {
return fetchUser(id).then(normalize);
}
// loadUser(...).catch(...) by the callerGive Enough Context: Do Not Destroy the Original Error
A common sin is logging err as a mere string "something failed", losing the stack and cause. Ten minutes into a real outage everyone wants the stack, not a dangling sentence.
Modern runtimes support an Error cause chain (new Error("context", { cause: original })), linking "failed here" with "root cause there", useful when debugging nested dependencies.
When a validation library throws, it usually throws a fresh Error; be careful not to swallow the original. Pass the original as cause, or at least log it, instead of formatting it away.
async function fetchProfile() {
try {
const blob = await http.get("/me");
return parseProfile(blob);
} catch (err) {
// 保留原始错误为 cause,外面可看堆栈
throw new Error("failed to load profile", { cause: err });
}
}
try {
await fetchProfile();
} catch (err) {
console.error(err.message); // failed to load profile
console.error(err.cause?.message); // 底层根因
console.error(err.cause?.stack);
}Mistake: Swallowing the Error Into an Empty Log
Some catch blocks only console.error("oops") or sit empty. On a real incident there is no stack, no context, nothing reproducible.
A sneakier variant returns a "fake success" value (say return null that nobody checks) and the downstream blows through null-reference errors.
The fix rule: catch and translate to an explicit failure only if you can recover; otherwise rethrow with a full cause. Never let one error decay into twenty unrelated downstream cries.
// 错误示范:吞掉并伪造成功
async function getScore() {
try {
return await fetchScore();
} catch {
return null; // 下游 if (!score) throw 一通乱
}
}
// 修复:区分“能恢复”与“直接暴露”
async function getScore() {
try {
return await fetchScore();
} catch (err) {
// 有限次数降级可恢复,否则带 cause 重新抛出
logger.error({ err, msg: "fetchScore failed, falling back to 0" });
return cachedScore() ?? 0;
}
}Global Fallbacks: Catching Runaways via process and Browser Events
No mechanism is flawless; runaways escape to unhandledRejection / uncaughtException. Global hooks log them to monitoring instead of vanishing, and on Node they decide whether the process must exit.
In Node, process.on("uncaughtException") and process.on("unhandledRejection") gather the tail; note the process state may be untrustworthy after a caught uncaughtException, so log, alert, and exit cleanly for the orchestrator to restart.
On the browser, window.onerror and unhandledrejection pair with Sentry or self-built reporting to pull back even errors swallowed on the user side.
// Node 末端兜底
process.on("uncaughtException", (err) => {
reportToMonitoring(err);
console.error("FATAL", err);
process.exit(1); // 状态不可信,交给编排重启
});
process.on("unhandledRejection", (reason) => {
reportToMonitoring(reason);
});
// 浏览器
window.addEventListener("error", (e) => reportToMonitoring(e.error));
window.addEventListener("unhandledrejection", (e) =>
reportToMonitoring(e.reason),
);Retry with Backoff: Transient Failure Is Not Failure
Network glitches, downstream 5xx, and pooled-connection pressure are usually transient. A sanely sized retry smooths out occasional failures, but blind infinite retries amplify a transient storm into an avalanche.
Exponential backoff plus random jitter keeps many clients from retrying in sync and crashing into each other; cap with a totalTimeout or maxAttempts; only retry idempotent or safely-replayable requests (evaluate POSTs).
Centralize the retry policy in a shared HTTP wrapper instead of re-implementing per call site, for consistency, observability (log each retry reason), and optional graceful degradation.
async function withRetry(fn, { maxAttempts = 4, base = 200 } = {}) {
let lastErr;
for (let i = 0; i < maxAttempts; i++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const shouldStop = err.retryable === false || i === maxAttempts - 1;
if (shouldStop) break;
const delay = base * 2 ** i + Math.floor(Math.random() * base); // jitter
console.warn(`attempt ${i + 1} failed, retry in ${delay}ms`, err.message);
await new Promise((r) => setTimeout(r, delay));
}
}
throw lastErr;
}
const data = await withRetry(() => http.get("/api/orders"));