Reviewing a Promise Chain for the Rejection It Swallows

A catch that returns a fallback converts a failure into a value, and the caller downstream treats the fallback as data.

Share
Reviewing a Promise Chain for the Rejection It Swallows. Abstract code review illustration in orange and dark grey on debugly.dev

The incident was a report that showed zeros where revenue should be, and the zeros were not a data problem, they were a fallback: an upstream fetch failed, its catch returned an empty result as the graceful degradation, and the report, receiving the empty result, rendered it as a legitimate quiet day, because the fallback had been designed to look exactly like a successful empty answer. The failure was handled, in the sense that it did not crash, and the handling was the bug, because it removed the only signal that distinguished a quiet day from a broken one.

This is the review guide for the swallowed rejection, the catch that returns a value, and the family of defects where the error is not lost but laundered into data, which is harder to catch than the crash, because the crash is loud and the launder is quiet by design.

This was Node 22.14 with a promise based client, and the pattern is the inverse of the unhandled promise rejection that crashes the process, and the shared property is the one worth fixing.

The two shapes of the swallow

The review should distinguish two shapes, because they fail differently.

The catch that returns a fallback value. The rejection is converted into a value of the success type, an empty list, a zero, a cached stale copy, and the caller cannot tell the fallback from a real answer, because the types match. The information that a failure occurred is destroyed at the catch, and every downstream decision treats the fallback as truth, which is the zeros in the report.

The catch that returns a resolved promise of nothing. The rejection is caught and the chain continues with undefined, and the downstream code, expecting a result, reads properties of undefined or branches on its absence, producing a different failure, later, farther from the cause, which is the debugging tax, because the stack at the later failure does not include the catch that ate the original.

Both shapes pass a review that looks for error handling, because there is a catch, and the catch reads as responsible, and the review's failure is that it checks for the presence of handling, not for the preservation of the signal.

The review questions that catch the launder

For every catch in the diff, the review asks three questions, and a fallback that cannot answer them is a block.

Who needs to know this failed, and how do they find out. A fallback is correct only when the failure is genuinely acceptable to every consumer of the value, and the review names the consumers and asks each, and the report that renders the fallback as a quiet day is a consumer that was never asked, which is the defect.

Does the fallback share the success type. If the fallback is representable in the success type, the failure is invisible by construction, and the review should require the failure to change the type, a result that is a value or an error, so the caller must branch, and the branch is the signal preserved. The launder is the shared type, and the fix is the distinct type.

Is the fallback logged with the cause. A fallback without a log is a silent degradation, and the log must carry the original rejection, because the fallback's own existence is the symptom and the cause is the diagnosis, and a log that says "used fallback" without the reason is a smoke alarm that omits the fire.

The legitimate fallback, and its contract

Fallbacks are sometimes right, and the review should not ban them, it should require their contract to be stated: the fallback is acceptable when the consumer genuinely cannot use the real value and the degradation is the product decision, a stale cache with a staleness indicator, a partial result marked partial, and the marker is the contract, because the consumer then knows, and the knowing is the signal.

The defect is never the fallback. It is the unmarked fallback, the degradation that presents as success, and the review's job is the marker, which is the difference between a designed degradation and a launder.

The test that catches it

The regression test rejects the upstream and asserts that the downstream either receives the error or receives a marked fallback, and that the unmarked success type never carries the failure, which is the launder made impossible by assertion, and the test fails on the original code, because the original returns the unmarked fallback, which is the bug, in one line.

The test also asserts the log carries the cause, because the observability half is part of the contract, and a fallback that degrades silently is a fallback that will degrade in production without a witness, which is the incident's shape.

What I would do differently

I would have reviewed the catch with the same suspicion as the happy path, because the catch is where the system decides what a failure means, and the decision is a product decision wearing error handling's clothes, and product decisions belong in review, and the review that skips the catch skips the decision, and the decision, unreviewed, was the zeros.

I would also have named the fallback's consumers at the time the fallback was written, because the writer knows the consumers, and the consumers are the ones the fallback can lie to, and the lie is designed at the catch, not discovered at the report.

The takeaway

A catch that returns a value of the success type converts a failure into data, and the data then lies to every consumer, so the review asks who needs to know, whether the fallback shares the success type, and whether the log carries the cause, and requires the failure to change the type or the fallback to carry a marker.

The crash is honest. It says something failed, loudly, once. The launder is quiet, and repeats, in every consumer, for as long as the fallback stands, which is why the review must treat the quiet catch with more suspicion than the crash, because the crash ends the incident and the launder begins it.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud