Skip to content

Commit 94307c4

Browse files
feat: RFC 9457 problem+json parsing on HTTPError
When a server emits `Content-Type: application/problem+json` (RFC 9457, formerly RFC 7807), HTTPError now exposes the parsed shape on `err.problem`: type URI ref identifying the problem type title short summary status HTTP status (echoed) detail specific occurrence detail instance URI ref for this occurrence ... extension fields preserved per spec The error message also surfaces problem.detail (or title fallback) so console output is immediately useful: HTTPError: Request failed with status 402 Payment Required: Your balance is $0.00. ProblemDetails type re-exported from the root entry. Spring 6, .NET, Cloudflare 1xxx errors all emit this format — no major JS HTTP client parses it natively, so consumers had to reach for `error.data` and manually inspect content-type. Now they don't. Audit pass 41 (test/problem-details.test.ts, 6 tests): Full shape parsing incl. extension fields, message includes detail, title fallback, non-problem content-type leaves problem undefined, charset parameter handled, ProblemDetails type reachable from root. Suite: 376 / 376 (was 370). Lint+typecheck+build clean. Dist 104 kB. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ccd5374 commit 94307c4

4 files changed

Lines changed: 159 additions & 2 deletions

File tree

src/errors/http.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,63 @@
11
import { MisinaError } from "./base.ts"
22

3+
/**
4+
* RFC 9457 problem details (formerly RFC 7807). Servers signal application
5+
* errors with `Content-Type: application/problem+json` and this shape.
6+
*/
7+
export interface ProblemDetails {
8+
/** URI reference identifying the problem type. Default `"about:blank"`. */
9+
type?: string
10+
/** Short, human-readable summary. */
11+
title?: string
12+
/** HTTP status code (echoed). */
13+
status?: number
14+
/** Specific occurrence detail. */
15+
detail?: string
16+
/** URI reference identifying the specific occurrence. */
17+
instance?: string
18+
/** Custom extension fields are allowed by the spec. */
19+
[key: string]: unknown
20+
}
21+
322
export class HTTPError<T = unknown> extends MisinaError {
423
override readonly name = "HTTPError"
524
readonly status: number
625
readonly statusText: string
726
readonly response: Response
827
readonly request: Request
928
readonly data: T
29+
/**
30+
* Parsed RFC 9457 problem details — present when the response had
31+
* `Content-Type: application/problem+json` and a JSON body. `undefined`
32+
* otherwise.
33+
*/
34+
readonly problem: ProblemDetails | undefined
1035

1136
constructor(response: Response, request: Request, data: T) {
12-
super(`Request failed with status ${response.status} ${response.statusText}`)
37+
const problem = extractProblem(response, data)
38+
super(buildMessage(response, problem))
1339
this.status = response.status
1440
this.statusText = response.statusText
1541
this.response = response
1642
this.request = request
1743
this.data = data
44+
this.problem = problem
1845
}
1946
}
47+
48+
function extractProblem(response: Response, data: unknown): ProblemDetails | undefined {
49+
const ct = response.headers.get("content-type") ?? ""
50+
// Match application/problem+json (RFC 9457) and any vendor variant ending
51+
// in +problem+json (rare but legal per RFC 6839 structured-syntax suffix).
52+
if (!/^application\/problem\+json(;.*)?$/i.test(ct)) return undefined
53+
if (!data || typeof data !== "object") return undefined
54+
return data as ProblemDetails
55+
}
56+
57+
function buildMessage(response: Response, problem: ProblemDetails | undefined): string {
58+
const base = `Request failed with status ${response.status} ${response.statusText}`
59+
if (!problem) return base
60+
// Prefer detail, fall back to title — both are spec-encouraged.
61+
const blurb = problem.detail ?? problem.title
62+
return blurb ? `${base}: ${blurb}` : base
63+
}

src/errors/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export { MisinaError } from "./base.ts"
2-
export { HTTPError } from "./http.ts"
2+
export { HTTPError, type ProblemDetails } from "./http.ts"
33
export { isRawNetworkError, NetworkError } from "./network.ts"
44
export { TimeoutError } from "./timeout.ts"
55

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export {
2727
isTimeoutError,
2828
MisinaError,
2929
NetworkError,
30+
type ProblemDetails,
3031
TimeoutError,
3132
} from "./errors/index.ts"
3233

test/problem-details.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina, HTTPError, type ProblemDetails } from "../src/index.ts"
3+
4+
describe("RFC 9457 — problem+json on HTTPError", () => {
5+
it("parses application/problem+json into err.problem", async () => {
6+
const driver = {
7+
name: "p",
8+
request: async () =>
9+
new Response(
10+
JSON.stringify({
11+
type: "https://example.test/errors/insufficient-funds",
12+
title: "Insufficient Funds",
13+
status: 402,
14+
detail: "Your balance is $0.00.",
15+
instance: "/transactions/123",
16+
balance: 0,
17+
}),
18+
{
19+
status: 402,
20+
headers: { "content-type": "application/problem+json" },
21+
},
22+
),
23+
}
24+
const m = createMisina({ driver, retry: 0 })
25+
26+
const err = (await m.post("https://api.test/buy", { item: 1 }).catch((e) => e)) as HTTPError
27+
expect(err).toBeInstanceOf(HTTPError)
28+
expect(err.problem).toBeDefined()
29+
expect(err.problem?.type).toBe("https://example.test/errors/insufficient-funds")
30+
expect(err.problem?.title).toBe("Insufficient Funds")
31+
expect(err.problem?.status).toBe(402)
32+
expect(err.problem?.detail).toBe("Your balance is $0.00.")
33+
expect(err.problem?.instance).toBe("/transactions/123")
34+
// Extension fields are preserved.
35+
expect(err.problem?.balance).toBe(0)
36+
})
37+
38+
it("includes problem.detail in the error message when present", async () => {
39+
const driver = {
40+
name: "p",
41+
request: async () =>
42+
new Response(
43+
JSON.stringify({
44+
title: "Validation Error",
45+
detail: "field 'email' is required",
46+
}),
47+
{
48+
status: 400,
49+
statusText: "Bad Request",
50+
headers: { "content-type": "application/problem+json" },
51+
},
52+
),
53+
}
54+
const m = createMisina({ driver, retry: 0 })
55+
56+
const err = (await m.post("https://api.test/", { x: 1 }).catch((e) => e)) as HTTPError
57+
expect(err.message).toContain("400")
58+
expect(err.message).toContain("field 'email' is required")
59+
})
60+
61+
it("falls back to problem.title when detail is missing", async () => {
62+
const driver = {
63+
name: "p",
64+
request: async () =>
65+
new Response(JSON.stringify({ title: "Forbidden" }), {
66+
status: 403,
67+
headers: { "content-type": "application/problem+json" },
68+
}),
69+
}
70+
const m = createMisina({ driver, retry: 0 })
71+
72+
const err = (await m.get("https://api.test/").catch((e) => e)) as HTTPError
73+
expect(err.message).toContain("Forbidden")
74+
})
75+
76+
it("non-problem content-type → err.problem is undefined", async () => {
77+
const driver = {
78+
name: "p",
79+
request: async () =>
80+
new Response(JSON.stringify({ title: "Forbidden" }), {
81+
status: 403,
82+
headers: { "content-type": "application/json" },
83+
}),
84+
}
85+
const m = createMisina({ driver, retry: 0 })
86+
87+
const err = (await m.get("https://api.test/").catch((e) => e)) as HTTPError
88+
expect(err).toBeInstanceOf(HTTPError)
89+
expect(err.problem).toBeUndefined()
90+
})
91+
92+
it("application/problem+json with charset parameter still parses", async () => {
93+
const driver = {
94+
name: "p",
95+
request: async () =>
96+
new Response(JSON.stringify({ title: "x", detail: "y" }), {
97+
status: 400,
98+
headers: { "content-type": "application/problem+json; charset=utf-8" },
99+
}),
100+
}
101+
const m = createMisina({ driver, retry: 0 })
102+
103+
const err = (await m.get("https://api.test/").catch((e) => e)) as HTTPError
104+
expect(err.problem?.title).toBe("x")
105+
})
106+
107+
it("ProblemDetails type re-exports from the root", () => {
108+
// Compile-time check: the type is reachable from the root entry.
109+
const sample: ProblemDetails = { title: "x", status: 400 }
110+
expect(sample.title).toBe("x")
111+
})
112+
})

0 commit comments

Comments
 (0)