After a catch-all handler was registered on the broadest failure type, a service returns 500 where it used to return 404 — why, and how do you fix it?
answer
- no endpoint changed, the table did
- broad registration joins every ancestry
- framework failures already carry a status
- narrow the base type, or re-emit carried status
- test unmatched path and wrong method
basics
~20 sThe catch-all is now the nearest registered ancestor for failures the framework raises itself, which already carried their own status. Instead of reaching the built-in mapping, they hit the catch-all and become 500. Fix by narrowing it or honouring a carried status.
solid answer
~50 sFrameworks signal their own transport problems as failure objects too: no route matched, wrong method, an unparseable body, an unsupported media type, a missing required parameter. Those objects carry an intended status and are normally mapped by built-in registrations. Registering your own mapper on the **broadest** failure type puts an entry on the path every one of them walks, so lookup now stops at yours — and yours returns 500. Three fixes, in order of preference: narrow the catch-all to a base type that only your domain failures extend; or have it detect a failure that already carries a status and re-emit that status unchanged; or give the framework's own transport failure type an explicit registration ahead of the catch-all. The diagnostic that makes this obvious in seconds is logging the thrown type alongside the resolved mapper at the boundary.
go deeper
Take away the rule of thumb: a handler registered on the broadest failure type catches far more than your own failures, so register on a narrow type you own.
Explain the mechanism — the broad registration is now the nearest registered ancestor for framework-raised failures that already carried their own status — and name at least two such failures.
Show the diagnosis path and the fix hierarchy: probe unmatched path, wrong method and bad body; log thrown type with resolved mapper; narrow the registration or re-emit a carried status rather than patching per route.
Separate the two goals that get conflated here: uniform failure body shape and correct status selection. Only the first justifies a broad handler, and the policy should forbid the broad one from deciding statuses at all.
## What actually changed Nothing about routing changed, and no endpoint was edited. What changed is the **shape of the lookup table**. Before, the failures a framework raises for transport-level problems had no application registration on their ancestry, so resolution walked past your mappers and reached the framework's built-in mapping, which produced the status each of those failures was designed to produce. Adding a registration on the broadest failure type inserts an entry into the ancestry of *everything thrown anywhere*, including those. Lookup stops at the nearest registered ancestor, your entry is now that ancestor, and your mapper returns whatever it was written to return — usually a generic 500. This is the exact reason the leaf exists: **one catch-all turns 404s into 500s**, and it does so without a single test failing if all your tests throw domain failures. ## Which failures the framework raises itself Common ones, all of which arrive as objects the registry can match: - No route matched the request path. - A route matched the path but not the method. - The body could not be parsed, or the media type is not one the endpoint accepts. - A required path, query or header value was absent or not convertible. - The request exceeded a configured size or timed out being read. They have two properties that matter here: they are raised **outside your handler body**, and they **already know their intended status**. A catch-all destroys the second property. ## Diagnosing it 1. **Log the resolved mapper with the thrown type** at the boundary. One line — `thrown=<type> mapper=<name> status=<code>` — converts this from theory to fact. 2. **Compare against the failure rate by route.** A catch-all regression shows up as 500s concentrated on *unmatched* paths and malformed requests, not on real endpoints. 3. **Probe deliberately.** Request a path that cannot match, then a real path with the wrong method, then a body that cannot parse. Statuses that used to differ and now all read 500 confirm it. 4. **Read the registry's diff, not the endpoints.** The change is always in registration, never in the handler that "broke". ## Four ways to fix it, best first | Fix | What it does | Trade-off | |---|---|---| | Narrow the registration | Register on a base type only your domain failures extend | Requires a disciplined domain failure hierarchy | | Honour a carried status | Catch-all checks whether the failure already carries a status and re-emits it | Keeps one entry; needs a reliable way to ask | | Register the transport type explicitly | Give the framework's transport failure type its own mapper, nearer than the catch-all | Couples your table to a framework-owned type | | Rethrow from the catch-all | Let unrecognised failures fall through to the built-in path | Loses the uniform body for those responses | The first is the real fix, because it puts the boundary where it belongs: your mapper should express *your* failure vocabulary, and the framework's transport failures are not part of it. The second is the pragmatic fix when a shared body format must cover everything — the mapper stays broad but stops inventing statuses. ## Keeping it from coming back - **Test the shapes nobody owns.** Assert the status for an unmatched path, a wrong method and an unparseable body. These are the tests that fail the moment a catch-all is added, and almost nobody writes them until this has happened once. - **Make the broadest registration reviewable.** Put it in the shared registry, with a comment naming what it is deliberately allowed to swallow. - **Alert on the 500 rate, split by whether a route matched.** A rise concentrated on unmatched paths is a mapping regression, not a dependency outage. - **Prefer a narrow domain base type from day one**, so the catch-all is never the convenient place to add shared behaviour. ## The wider lesson A catch-all is not a safety net; it is a **priority claim over every failure in the process**. That includes failures that were already correct. The senior instinct is to treat the broadest registration as the most expensive line in the error-handling configuration and to justify it explicitly, rather than to add it because "everything should return our error format". Format uniformity and status correctness are separate goals, and only the first one requires a broad handler — the second requires that the broad handler keep its hands off statuses it did not decide.
- Why do tests usually stay green when this regression ships?Because handler tests throw domain failures, which the catch-all maps the same way it always did. The broken cases are requests that never reach a handler — unmatched paths, wrong methods, unparseable bodies — and almost nobody has assertions for those until this has bitten them once.
- If a shared body format must cover every failure, how do you keep a broad handler without breaking statuses?Keep the broad registration but strip its status-deciding power: if the failure already carries an intended status, re-emit that status and only reformat the body. The mapper then guarantees uniformity of shape while leaving the status to whoever actually knew the answer.
- What single observability change makes this class of bug obvious?Log the thrown type, the resolved mapper and the emitted status as one line at the boundary. Any mismatch between the failure's own intent and the mapper that claimed it is then visible directly, and 500s on unmatched paths stand out immediately from 500s on real endpoints.
saying these in an interview costs you the question
- Blames routing or the client instead of the newly registered handler
- Thinks the framework's own transport failures bypass the application registry
- Keeps the catch-all and hardcodes exceptions for individual paths inside it
- Says any failure reaching a catch-all is genuinely a server error
- Believes green handler tests prove the mapping table is still correct