Reject Fetch requests when a 2xx body cannot be parsed

UP
5d ago
+2777-566

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 parseResponseBody method. 204 and 205 resolve to null without reading the body. For
    format: "json", the client reads the body as text first. An empty or whitespace-only body gives null;
    anything else goes through JSON.parse. Other formats are unchanged.
  • Read/parse failures on 2xx responses now reject. The client throws the HttpResponse with the parse
    error in error, the same shape non-2xx rejections already use, so existing catch handlers 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.

ResponseBeforeAfter
2xx, valid JSONresolves, data setunchanged
2xx, empty or whitespace-only body (format: "json")resolves, error: SyntaxErrorresolves, data: null, error: null
204 / 205resolves, error: SyntaxErrorresolves, data: null, error: null
2xx, literal null bodyresolves, data: nullunchanged (valid JSON)
2xx, HTML / plain text when JSON is declaredresolves, error: SyntaxErrorrejects, error: SyntaxError
2xx, truncated JSONresolves, error: SyntaxErrorrejects
2xx, body stream aborted after headersresolves, error: AbortErrorrejects (aborts before headers already reject)
non-2xx, empty bodyrejects, error: SyntaxErrorrejects, error: null
non-2xx, non-JSON bodyrejects, error: SyntaxErrorunchanged
any rejecting case with unwrapResponseDataresolved to nullrejects
any case with disableThrowOnErrorresolvesresolves (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.ts generates the Fetch client (default,
    unwrapResponseData, and disableThrowOnError variants), imports it, and drives it through customFetch
    with each case from the table. 7 of its 9 tests fail on main and 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 --noUnusedLocals with 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