Use fail-safe error handling everywhere in long-running / retrying flows: cleanup must be guaranteed, state/cursors must follow the intended retry semantics, and errors must remain diagnosable.
Apply these rules: 1) Cleanup in finally, not best-effort.
2) Decide per error kind: retry, terminal-skip, or bubble.
3) Never “silently succeed” after a failed dispatch.
4) Preserve diagnostics.
throw new X(message, { cause: error }).5) Classify timeouts/cancellations/quotas correctly.
Example pattern (cursor + fail-safe dispatch):
async function processBatch(items: Item[], cursor: Cursor) {
let firstError: unknown;
for (const item of items) {
try {
const ok = await dispatch(item);
if (ok) cursor.markHandled(item.id);
// advance cursor for success (or terminal skip)
} catch (err) {
if (isTerminal(err)) {
// skip permanently but advance cursor so you don’t wedge
cursor.markHandled(item.id);
continue;
}
// retryable: do NOT advance cursor/dedup keys
firstError ??= err;
}
}
if (firstError) throw firstError;
cursor.advanceToLatest();
await cursor.save();
}
function isTerminal(err: unknown): boolean {
// 404/410 patterns; also treat auth/permission without retry-after as terminal
return err instanceof HttpError && (err.status === 404 || err.status === 410);
}
This standard prevents the common failure modes seen across the discussions: orphaned state from missing cleanup, lost messages from swallowing/“marking handled” on failed dispatch, infinite poll livelocks from not advancing cursor, and operator-blind failures from lost error context.
Enter the URL of a public GitHub repository