Skip to content

Keep the judge response body readable under Bun - #35

Merged
DevMortimer merged 2 commits into
mainfrom
fix/bun-response-body
Oct 28, 2026
Merged

DevMortimer merged 2 commits into
mainfrom
fix/bun-response-body

Conversation

@DevMortimer

@DevMortimer DevMortimer commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

What and why

Under Bun — the runtime Pi's compiled binary embeds — every judge request fails with TypeSafe returned an unreadable or unexpected response. The cause is masked: @typesafe-ai/sdk buffers a response by taking a reader on response.clone().body and another on response.body, releases both locks, and then reads response.text(). Releasing a lock without reading leaves a Node body usable; Bun marks it used, so the SDK's own text() throws TypeError: Body already used.

This change reads the body once inside the transport this package hands the SDK, and hands back a response with no body whose text() and json() answer from the bytes already read. The SDK's buffering step then finds no reader to take, so the body it later reads is the one we hold. Status, status text and headers survive; content-length and content-encoding are dropped because the body is re-emitted. The wrapper sits ahead of both the backend path rewrite and any caller-supplied fetch, so an integration that already works around this on its own keeps working — two replays stacked are harmless.

Node is untouched: with no fetch option, no transport is passed at all; with one, the caller's own goes through unchanged.

Cost: under Bun a response is buffered before the SDK sees it and no longer streams. The SDK buffers every response anyway — that is the mechanism that broke here.

Verification

  • npm ci and npm run check pass.
  • Changed behavior has offline tests, or this PR does not change behavior.
  • Documentation and changelog notes are updated where needed.

tests/bun-transport.test.ts covers, against a real local HTTP server: one evaluate() through the public entry point, a non-2xx status with its headers reported unchanged, an abort during body delivery rejecting as aborted, the transport each runtime is handed, a caller-supplied fetch called once per request, and a backend that rewrites its model list.

Measured on this machine (Node 26.12.1, Bun 1.4.4): before, the Bun call fails with the response error while Node returns the parsed answers; after, both return them with exactly one HTTP request per call. Wall time over 20 calls is inside run-to-run noise on both runtimes, before and after. Without the fix, five of the six new tests fail under Bun.

A new Check (Bun 1.4.4) job builds, typechecks and runs that test file under Bun, and joins the CI passed gate. Every one of its shell steps was run here; its two setup actions cannot run outside GitHub.

Live test: run once, under Bun 1.4.4, against the real service with the synthetic message from scripts/live-smoke.mjs. It passed: one request, jev-1.14.0, 408 input and 70 output tokens, 230 ms, and the three answers its assertions require. No key, request state or response body is reproduced here. The failing path itself was reproduced against a loopback server, where the same code reported the response error before this change.

Compatibility and release notes

No public API change, no new dependency, no version bump. Behavior differs only under Bun; Node callers see no change at all. The one thing a Bun caller could notice is that a response is buffered rather than streamed — which the SDK already did.

A judge request made from a Bun process failed with "TypeSafe returned an
unreadable or unexpected response.": @typesafe-ai/sdk 0.7.0 buffers a response
by taking a reader on its body and releasing it, and Bun counts that as using
the body, so the SDK's own `response.text()` threw `TypeError: Body already
used`. A response this package builds itself is stamped by Bun the same way, so
the fault is in the transport, not in one backend or one caller.

Under Bun, the transport handed to the client now reads the body and hands over
a response with none, answering `text()` and `json()` from the bytes already
read; status, statusText and headers are carried across, without the length and
encoding that described bytes no longer on the wire. The wrapper sits ahead of
both the backend's path rewrite and the caller's own transport, so a caller
that wraps fetch stays harmless next to it. Under Node the transport is exactly
what it was: the caller's fetch, or none at all.

Verified against a local HTTP server with @typesafe-ai/sdk 0.7.0: the parsed
answers returned and one request per judgment, a 429 with its Retry-After header
reported unchanged, an aborted request rejected as aborted, and a caller-supplied
fetch still called once per request. The same tests pass under Bun 1.4.4 and
Node 26 here, and the file is part of the Node test suite.
The body-buffering fix can only regress on a runtime that runs it, so the
workflow gains a Bun 1.4.4 job beside the Node matrix: it installs the lockfile,
builds, typechecks, and runs tests/bun-transport.test.ts under Bun, and CI passed
requires it. Five of that file's six tests fail under Bun without the fix.

The README says Bun is supported and why the transport differs there, the CI doc
lists the new job, and the CHANGELOG records the fix, the SDK and Bun versions
measured, and how it was verified.
@DevMortimer
DevMortimer merged commit ce25e76 into main Oct 28, 2026
9 checks passed
@DevMortimer
DevMortimer deleted the fix/bun-response-body branch October 28, 2026 00:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant