From 702fb489d3ce5cc160ac4d7504b5cc3d452a13ab Mon Sep 17 00:00:00 2001 From: Harish Seshadri Date: Sun, 25 Oct 2026 08:43:27 -0700 Subject: [PATCH] feat: add problemFor(raw) to preserve the observed HTTP status classify() returns only the code, so a code registering [400, 422] made toProblemDetails() emit 400 even when the failure was a 422. problemFor() classifies and serializes in one step, using the status that matched; options.status still overrides. Additive API, tested. Fixes the multi-status loss identified in the review. --- README.md | 5 +++++ docs/API.md | 2 +- src/registry.ts | 16 ++++++++++++++ src/types.ts | 9 ++++++++ test/problem-for.test.ts | 48 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 test/problem-for.test.ts diff --git a/README.md b/README.md index e524869..7a67118 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,11 @@ console.log(JSON.stringify(body)); // An Error you can throw. Its .message is the code, so logs stay searchable. // The sentence comes from describe(). +// Starting from a raw failure, problemFor() classifies it and keeps the +// observed status — including when the matched code registers several. +const fromRaw = errors.problemFor({ status: 422 }); +console.log(JSON.stringify(fromRaw)); + const err = errors.create("config.missing", { field: "API_KEY" }); console.log(err.message); console.log(errors.describe(err.code, err.params)); diff --git a/docs/API.md b/docs/API.md index 3493d3c..80a9f7c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -255,7 +255,7 @@ Everything else is your catalog. `Registry` methods: `classify(raw)`, `describe(code, params?, t?)` (`t` is any `Translator`, including i18next's `t` as-is), -`toProblemDetails(code, params?, opts?)`, `create(code, params?)`, plus +`toProblemDetails(code, params?, opts?)`, `problemFor(raw, params?, opts?)`, `create(code, params?)`, plus `codes`, `has`, and `get`. `CatalogEntry` fields: `category` (required), `en`, `params`, `i18nKey`, diff --git a/src/registry.ts b/src/registry.ts index d796db1..7abab10 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -333,6 +333,21 @@ function createRegistry( return problem; } + function problemFor( + raw: unknown, + params?: Params, + options?: ProblemOptions, + ): ProblemDetails { + const code = classify(raw); + const observed = httpStatusOf(raw); + const status = own(options, "status") ?? observed; + return toProblemDetails( + code, + params, + status === undefined ? options : { ...options, status }, + ); + } + function create(code: ErrorCode, params?: Params): CanonicalError { const category = own(entryOf(code), "category") ?? "internal"; return new CanonicalError(code, category, params); @@ -345,6 +360,7 @@ function createRegistry( classify, describe, toProblemDetails, + problemFor, create, }; return registry as Registry; diff --git a/src/types.ts b/src/types.ts index b84bc6f..28cc2b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -183,6 +183,15 @@ export interface Registry { /** Turn a raw transport/LLM failure into a code (this registry's fallback). */ classify(raw: unknown): ErrorCode; + /** Classify a raw failure AND serialize it, preserving the observed HTTP + * status when the matched code registers more than one (`httpStatus: [400, + * 422]`). `options.status` still wins. */ + problemFor( + raw: unknown, + params?: Params, + options?: ProblemOptions, + ): ProblemDetails; + /** Resolve the human text for a code via i18n, falling back to English. */ describe( code: K, diff --git a/test/problem-for.test.ts b/test/problem-for.test.ts new file mode 100644 index 0000000..b9b967f --- /dev/null +++ b/test/problem-for.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { defineErrors } from "../src/index.js"; + +const errors = defineErrors({ + "api.multi": { + category: "request", + en: "The request was rejected.", + httpStatus: [400, 422], + }, + "api.one": { + category: "request", + en: "The request was rejected once.", + httpStatus: [409], + }, + "internal.unknown": { + category: "internal", + en: "Something went wrong.", + }, +}); + +describe("problemFor preserves the observed status", () => { + it("uses the status that matched, not the first status the code registers", () => { + expect(errors.problemFor({ status: 422 }, { field: "email" }).status).toBe( + 422, + ); + expect(errors.problemFor({ status: 400 }).status).toBe(400); + }); + + it("still uses the single registered status for a one-status code", () => { + expect(errors.problemFor({ status: 409 }).status).toBe(409); + }); + + it("omits status when the raw failure has none", () => { + expect(errors.problemFor({ message: "boom" }).status).toBeUndefined(); + }); + + it("lets the caller override the observed status", () => { + expect(errors.problemFor({ status: 422 }, {}, { status: 502 }).status).toBe( + 502, + ); + }); + + it("keeps the classified code and its params", () => { + const body = errors.problemFor({ status: 422 }, { field: "email" }); + expect(body.type).toBe("api.multi"); + expect(body.field).toBe("email"); + }); +});