Skip to content

FetchHttpClientResponse.toJSON() consumes the body, so any JSON.stringify of a response produces an unhandled rejection #1680

Description

@xuelongmu

toJSON is the method JSON.stringify invokes automatically, and in FetchHttpClientResponse it is both async and body-consuming:

async toJSON() {
  if (!this._res.headers.get("content-type")?.includes("application/json")) return null;
  const rawBody = await this._res.text();

The SDK has normally already read that body (return { data: await res.toJSON() }), so any later stringify of an object graph containing the response calls .text() a second time and gets TypeError: Body is unusable: Body has already been read.

Because toJSON is async, that surfaces as a rejected promise JSON.stringify discards — an unhandled rejection, with no useful stack to trace it back from:

TypeError: Body is unusable: Body has already been read
    at _Response.text (node:internal/deps/undici/undici:6821)
    at FetchHttpClientResponse.toJSON (@workos-inc/node)
    at stringify (<anonymous>)

Where this bites

Anything that serializes values reachable from a WorkOS call. We hit it through Next.js 16's dev error overlay, which re-fires on every poll of routes that call WorkOS, so the log fills continuously.

The same shape applies to error reporters. Sentry's normalize() calls toJSON() on any value that has one, inside a try/catch that cannot help here — an async toJSON rejects rather than throwing synchronously, so the catch never runs:

if (valueWithToJSON && typeof valueWithToJSON.toJSON === "function") {
  try { const jsonValue = valueWithToJSON.toJSON(); }
  catch {}
}

Reproduce

Make any SDK call, then JSON.stringify({ res }) on the resulting FetchHttpClientResponse.

Versions

Reproduced on @workos-inc/node 10.7.0, still present in 10.10.0 (checked against a clean install). Node 24.13.

Suggested fix

Rename the body-consuming method (e.g. parseBody()) so JSON.stringify no longer triggers it, and/or cache the body text on first read so subsequent reads resolve instead of throwing. Either would make the object safe to serialize.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions