◐ Off-By-One · answer catalog

typescript-http-error-mapping

1 answer(s)godocker

typescript-http-error-mapping

📦 Source in repository (JSON)

Answer

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).

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog