A production pod kept crashing, and it was one of those bugs that made no sense on first read of the code — because the code looked careful. Try/catch blocks in the right places throughout the request path, sensible error propagation, nothing that screamed "this will crash the process." And yet it kept crashing.
The reason none of that error handling helped is the actual lesson here: the failure happened somewhere try/catch structurally cannot reach.
The Chain of Events
A third-party API call — a DocuSign integration, in this case — came back with a consent_required response. Nothing exotic; API integrations need re-authorization sometimes. The code's response to that condition was to call a getConsent() function, which tried to prompt an interactive yes/no answer from a terminal, using a library that reads from /dev/tty.
A Kubernetes pod has no terminal. The read failed with ENXIO — no such device or address — because there was no TTY to read from in the first place.
That alone would just be a normal thrown error. Annoying, but catchable. Here's the part that made it genuinely hard to find.
The Async Promise Executor Trap
The prompt call was wrapped like this:
function getConsent() {
return new Promise(async (resolve) => {
const answer = await promptFromTerminal(); // throws ENXIO — no TTY in a pod
resolve(answer);
});
}
That new Promise(async (resolve) => { ... }) pattern is a well-known JavaScript anti-pattern: an async Promise executor. It looks harmless, and it mostly behaves fine — right up until something inside it throws.
Here's why it breaks the normal rules: a synchronous executor function that throws causes the Promise to reject automatically — that's a specced, well-understood behavior. But when the executor itself is async, a thrown error inside it does not propagate the same way. Instead of rejecting the outer Promise predictably, the throw becomes an unhandled promise rejection — invisible to any try/catch wrapped around the code that called getConsent(), no matter how carefully that calling code was written.
try {
await getConsent(); // this try/catch never sees the ENXIO throw
} catch (err) {
logger.error('consent failed', err); // never runs
}
Node's default behavior for an unhandled promise rejection is to terminate the process. So the pod didn't error gracefully and recover — it just died, on a code path that every review would have looked at and called "properly handled."
Fixing the Fire, Then Fixing the Gun
The immediate fix was outside the codebase entirely: manually grant DocuSign consent in the admin account, which removes the trigger condition and stops the code from ever calling the broken function. That bought time without touching a deploy pipeline mid-incident.
The real fix — flagged to the owning team rather than rushed in — was to stop wrapping async logic in a new Promise() executor at all. Modern async/await doesn't need it:
// no executor needed — this propagates rejections normally
async function getConsent() {
return await promptFromTerminal();
}
A second finding surfaced during the same investigation: the pod was actually pointed at DocuSign's demo environment, a separate configuration issue worth its own fix. Neither finding got buried chasing the other — they were logged and handled as two distinct problems, because conflating "the crash" and "the wrong environment" would have muddied both fixes.
Why This Is Worth Knowing
The unsettling part of this bug isn't the specific library or the specific vendor — it's that the code looked correct. Every reviewer would see try/catch in the right places and move on. The failure mode only exists because of a subtle interaction between async/await semantics and how Promise executors handle thrown errors, and it's exactly the kind of thing that's easy to miss without knowing to look for it.
If you see new Promise(async (resolve, reject) => ...) anywhere in a codebase, it's worth treating as a landmine, not a style nit — any exception thrown inside it walks straight past every surrounding try/catch and takes the process down with it.
Takeaways
- An async executor inside
new Promise()can turn a normal throw into an unhandled promise rejection that no surrounding try/catch will see. - Modern async/await almost never needs a manual Promise wrapper — if you're writing
new Promise(async ...), there's usually a simpler, safer version of the same function. - Separate "unblock the incident" from "fix the underlying bug" — the admin console fix and the code fix were two different tickets, on two different timelines, and that was the right call.