Caller errors are translated in one place, and two of them were 500s
Affects: backend/src/middlewares/error-handler.ts, backend/src/lib/errors.ts, backend/test/error-handler.test.ts
What was wrong
Two classes of ordinary caller mistake were answered with 500 internal_error.
A ZodError thrown by an inline .parse() in a controller matched no branch in the error handler and fell to the 500 case. A caller who sent ?limit=abc was told the server had failed, and the field that was wrong was hidden behind "The request could not be completed."
A body-parser failure did the same. Malformed JSON, an oversized body, an unsupported charset or content-encoding, a connection dropped mid-upload — body-parser throws before any route runs, carrying a type and a status that the handler ignored. Every one of them was a 500.
Both are the caller's fault, both are fixable by the caller, and both were being reported as server defects — which also means both were landing in the error log as if something were broken.
The decision
Translation happens in the error handler, centrally, and nowhere else. The alternative — try/catch at each call site — puts the translation next to the code that failed, which reads well and is wrong: it has to be repeated at every site, it is silently missing at any site nobody thought about, and "was this handled here?" becomes a question you answer by reading every controller.
Three details that are not obvious:
Body-parser errors are matched on `type`, not on `status`. An internal error that happens to carry status: 400 is still a defect and belongs in the error log, not reflected to the client as a 4xx telling the caller they did something wrong. Matching on status would have made any thrown object with a stray numeric property into a client error.
The messages are fixed strings, not `error.message`. body-parser's own text quotes the offending bytes — those bytes are the caller's data, sometimes a half-sent credential, and echoing them into a response is a habit worth not having. The same rule covers Prisma: P2002 carries the columns that collided, and echoing target back on a users table turns a signup form into an account enumeration oracle.
`formatZodIssues` moved into `lib/errors.ts`. It was defined in the middleware and used by the validate middleware too. Two formatters producing subtly different details.fields shapes for the same failure is exactly the inconsistency clients build optional-chaining around.
Why the ZodError branch stays now that nothing reaches it
D-004 removed every inline .parse(), so no route can produce a ZodError any more. The branch is kept deliberately and is documented as a net.
Without it, one inline .parse() added later re-arms the original defect exactly. The branch costs four lines and is asserted in error-handler.test.ts — driven directly rather than over HTTP, because no route can reach it. The same is true of three body-parser types that need a client sending a bad charset or dying mid-upload, and of the Prisma code table, which fires only against a real database. Those are precisely the branches that rot unnoticed: they translate a failure into what the client is told, they run on the worst day, and an HTTP-level suite cannot exercise them. That is a case for a unit test, not against one.