typescript-http-error-mapping
Root cause. All 5 HTTP route call sites catch every recallTool failure and wrap it unconditionally as ApiError(500, message). A missing namespace throws NamespaceNotFoundError ("Namespace X does not exist"), so it landed as a 500 — indistinguishable from a real crash, and every route would have needed the same per-route special-case.
The fix is a single change in src/errorHandler.ts (the final middleware registered after all routes). It remaps any ApiError(500) whose message exactly matches /^Namespace .+ does not exist$/ to 404. No route file changes:
import type { NextFunction, Request, Response } from "express";
import { ApiError } from "./errors.ts";
// Message-exact contract: anchored ^$ so a namespace-not-found *mentioned
// inside* a larger failure keeps its 500 semantics.
export const NAMESPACE_NOT_FOUND_RE = /^Namespace .+ does not exist$/;
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction,
): void {
if (err instanceof ApiError) {
const status =
err.status === 500 && NAMESPACE_NOT_FOUND_RE.test(err.message)
? 404
: err.status;
res.status(status).json({ error: err.message });
return;
}
// Non-ApiError: a genuine crash. Log and surface as a generic 500.
console.error(err);
res.status(500).json({ error: "Internal Server Error" });
}
Registration in src/app.ts (unchanged): app.use("/api/v1", buildRouter(tool)) then app.use(errorHandler) — error handler last, so next(err) from any of the 5 call sites (GET/POST /recall/..., GET/POST/DELETE /namespaces/...) flows through the one central remap. Routes stay as-is, still wrapping everything in ApiError(500).
Regression tests (test/regression.test.ts) spawn the real server (src/server.ts) as a child process with a fresh temp namespace root (fs.mkdtempSync), read a {"port","root"} handshake line from stdout, and drive it over real HTTP with fetch:
test("real server: missing namespace on GET /recall/:ns/:key returns 404", async () => {
const res = await fetch(`${server.baseUrl}/api/v1/recall/ghost-ns/some-key`);
assert.equal(res.status, 404);
assert.deepEqual(await res.json(), { error: "Namespace ghost-ns does not exist" });
});
plus one test asserting 404 across all 5 call sites, a happy-path 200, a genuine-crash 500, and temp-root cleanup on shutdown. AGENTS.md was updated in the same change to the exact CI-asserted count (11).
Verified on a real spawned server in `~/work` (Node 22, `node --test`, `tsc --noEmit` clean): ``` # tests 11 # pass 11 # fail 0 TSC_OK ``` Before/after demonstration (`tools/beforeAfter.ts`) proves the bug and the fix on the identical request `GET /api/v1/recall/ghost-ns/some-key`: ``` BEFORE (no remap): -> 500 (bug: namespace-not-found indistinguishable from crash) AFTER (remap) : -> 404 (expected NOT_FOUND) ``` Edge cases covered (all passing): - **All 5 call sites** return 404 for a missing namespace — central remap works without touching routes. - **Other 500s preserved**: missing *key* within an existing namespace still returns 500 with its message; the regex is anchored `^…$`, so `"corrupt state: Namespace foo does not exist"` and `"Namespace foo does not exist right now"` stay 500. - **Non-500 ApiError respected**: `ApiError(400, "Namespace foo does not exist")` stays 400 — only `status === 500` is remapped. - **Empty namespace name** (`"Namespace does not exist"`) does not match (`.+` requires ≥1 char) — correctly not classified as namespace-not-found. - **Crashes** (non-ApiError) still yield a generic 500 `Internal Server Error`. - **Happy path** untouched: existing namespace returns 200 with payload. - **Temp namespace root** is removed on server shutdown (cleanup test restarts the server and asserts no leaked roots).
{"model": "deepseek-v4-flash", "problem_class": "typescript-http-error-mapping", "result": "passed", "tests": 11}