Reject Fetch requests when a 2xx body cannot be parsed
Fixes #1838
Problem
When a 2xx response body can't be read in the declared format, the generated Fetch client resolves with
ok: true, data: null and the SyntaxError/AbortError stored in error. Examples: HTML from a proxy
login page, truncated JSON, or a body stream aborted after the headers. Callers that branch on ok get null
typed as their DTO, and the failure surfaces later, far from where it happened. With unwrapResponseData the call
resolves to null and the error is lost entirely.
The same parse path also turned legitimate no-body responses into errors: 204 No Content, and an empty 200
from an endpoint that declares a JSON body, both ended up with error: SyntaxError.
Solution
All changes are in templates/base/http-clients/fetch-http-client.ejs:
- New
parseResponseBodymethod.204and205resolve tonullwithout reading the body. For
format: "json", the client reads the body as text first. An empty or whitespace-only body givesnull;
anything else goes throughJSON.parse. Other formats are unchanged. - Read/parse failures on 2xx responses now reject. The client throws the
HttpResponsewith the parse
error inerror, the same shape non-2xx rejections already use, so existingcatchhandlers work. With
disableThrowOnError, the response still resolves as before.
I chose to reject rather than add a new field such as parseError, because rejecting reuses the existing error
path and adds no public API. The downside is that error can still hold a SyntaxError rather than E. See
the follow-ups below.
| Response | Before | After |
|---|---|---|
| 2xx, valid JSON | resolves, data set | unchanged |
2xx, empty or whitespace-only body (format: "json") | resolves, error: SyntaxError | resolves, data: null, error: null |
204 / 205 | resolves, error: SyntaxError | resolves, data: null, error: null |
2xx, literal null body | resolves, data: null | unchanged (valid JSON) |
| 2xx, HTML / plain text when JSON is declared | resolves, error: SyntaxError | rejects, error: SyntaxError |
| 2xx, truncated JSON | resolves, error: SyntaxError | rejects |
| 2xx, body stream aborted after headers | resolves, error: AbortError | rejects (aborts before headers already reject) |
| non-2xx, empty body | rejects, error: SyntaxError | rejects, error: null |
| non-2xx, non-JSON body | rejects, error: SyntaxError | unchanged |
any rejecting case with unwrapResponseData | resolved to null | rejects |
any case with disableThrowOnError | resolves | resolves (same fields as above) |
This is a behavior change. APIs whose contracts declare JSON but whose servers send something else will
now reject, so the changeset is marked major. I've left a comment below proposing an opt-in alternative,
which would make this a minor change.
The Axios client is unchanged: its default transitional.silentJSONParsing returns the raw string instead of
throwing, so it doesn't have this problem in the same form.
Verification
- New
tests/fetch-http-client-response-parsing.test.tsgenerates the Fetch client (default,
unwrapResponseData, anddisableThrowOnErrorvariants), imports it, and drives it throughcustomFetch
with each case from the table. 7 of its 9 tests fail onmainand all pass with this change. As far as I
can tell, it is the first test in the repo that runs a generated client rather than checking its text. - Generated clients for all three variants pass
tsc --strict --noUnusedLocalswith the// @ts-nocheck
header removed. Generated files include that header by default, so without removing it the check proves nothing. bun run test: 319/319 passing. The 141 updated snapshots contain only this template change, repeated in
every Fetch fixture.
To reproduce manually:
const client = new HttpClient({
customFetch: async () => new Response("<html>login</html>", { status: 200 }),
});
await client.request({ path: "/x", method: "GET", format: "json" });
// before: resolves { ok: true, data: null, error: SyntaxError }
// after: rejects with that HttpResponse
🤖 Generated with Claude Code