fix: surface a clear error on non-JSON API responses instead of crashing - #1093
fix: surface a clear error on non-JSON API responses instead of crashing#1093ralphstodomingo wants to merge 3 commits into
Conversation
|
This PR doesn't fully meet our contributing guidelines and PR template. What needs to be fixed:
Please edit this PR description to address the above within 2 hours, or it will be automatically closed. If you believe this was flagged incorrectly, please let a maintainer know. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (3)
Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe SDK build script patches generated clients with descriptive JSON parsing errors. The response interceptor normalizes ChangesSDK JSON response guards
Estimated code review effort: 3 (Moderate) | ~20 minutes Merge Risk: ⚪ Minimal · up to The PR changes SDK handling of non-JSON responses to return an actionable error while preserving other response modes; no actionable merge-blocking risk remains beyond normal checks and review. Sequence Diagram(s)sequenceDiagram
participant LocalBunHTTPServer
participant GeneratedSDKClient
participant ResponseInterceptor
LocalBunHTTPServer->>GeneratedSDKClient: Return HTML response
GeneratedSDKClient->>ResponseInterceptor: Inspect response content type
ResponseInterceptor->>GeneratedSDKClient: Reject HTML or guarded JSON parse failure
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Thanks for your contribution! This PR doesn't have a linked issue. All PRs must reference an existing issue. Please:
See CONTRIBUTING.md for details. |
Verified via E2E repro (Bun)Drove the actual Before — unpatched client on The exact string from telemetry. The JavaScriptCore phrasing confirms it runs in the Bun CLI (not the Node extension), and the throw pins the crash to the JSON success-path parse in After — this PR: Control — valid JSON 200 against the patched client: parses fine ( |
1c24cce to
8249569
Compare
|
Re-verified after the review round: both hunks now wrapped in |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 82495695bb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case "json": | ||
| try { | ||
| data = await response.json() | ||
| } catch { |
There was a problem hiding this comment.
Preserve body-read failures outside the JSON parse guard
When a response stream fails after the headers arrive—for example, because the transfer is interrupted or the request is aborted—response.json() rejects with that network/body-read error before parsing JSON. This broad catch replaces it with a misleading proxy/non-JSON message, losing the error needed for diagnosis or retry classification. Keep body consumption outside the parse guard as the v2 implementation does, or only translate actual JSON syntax errors.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed empirically and fixed in b090fd4a0 — reproduced the exact failure with a raw-TCP mid-body reset (Content-Length promised, socket terminated after a partial body): the previous v1 guard swallowed the socket error and mislabeled it as the proxy/gateway message; v2 was unaffected. v1 now mirrors v2 (body read outside the guard, only JSON.parse inside): the reset case propagates The socket connection was closed unexpectedly, the HTML-body case keeps the actionable error, and the full parse-mode matrix + typecheck still pass.
When a proxy, gateway or CDN returns an HTTP 200 with an HTML body (an error or interstitial page) instead of JSON, the generated SDK client JSON-parses it and throws a raw `JSON Parse error: Unrecognized token '<'`. `parseAs` falls back to "json" whenever Content-Type is missing or unrecognized, so a non-JSON body reaches the parser. The error path was already guarded; the success path was not. Guard the JSON parse in both the v1 and v2 generated clients: on a parse failure, throw an actionable error (non-JSON response, likely a proxy/gateway error page, with HTTP status + content-type) instead of the raw parse crash. In v1, "json" is split out of the shared fall-through group so the other parse modes (arrayBuffer/blob/formData/text) keep dispatching via response[parseAs](). Both hunks are wrapped in `altimate_change start — upstream_fix:` markers, the repo convention for local deviations that should survive upstream bridge merges and eventually land upstream. Surfaced from telemetry as a recurring extension sendMessageError.
8249569 to
b090fd4
Compare
There was a problem hiding this comment.
Claude Code Review
This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.
Tip: disable this comment in your organization's Code Review settings.
There was a problem hiding this comment.
Pull request overview
This PR hardens the generated JavaScript SDK clients to handle non-JSON bodies on successful (2xx) responses by converting raw JSON parse crashes into an actionable error that calls out the received Content-Type and HTTP status.
Changes:
- Add a guarded
JSON.parseon the success path for the v2 generated client when parsing JSON fromresponse.text(). - Split
"json"out of the v1 client’s fall-through parse switch so JSON parsing can be guarded without affecting otherparseAsmodes.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/sdk/js/src/v2/gen/client/client.gen.ts | Wraps JSON parsing in a try/catch on 2xx responses to replace raw parse crashes with a clearer error. |
| packages/sdk/js/src/gen/client/client.gen.ts | Separates "json" parsing from the generic response[parseAs]() path to guard JSON parse failures while keeping other parse modes unchanged. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| } catch { | ||
| throw new Error( | ||
| `Expected a JSON response but received ${response.headers.get("content-type") || "an unknown content type"} ` + | ||
| `(HTTP ${response.status}). This is usually a proxy or gateway error page, not the API.`, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Done in 15a452ae4 — original SyntaxError now attached via new Error(msg, { cause }), matching the SDK's error-interceptor.ts convention. E2E-verified the cause is present ([cause=SyntaxError] on the HTML-body case) and the full parse-mode + interrupted-body matrix still passes on both clients.
| } catch { | ||
| throw new Error( | ||
| `Expected a JSON response but received ${response.headers.get("content-type") || "an unknown content type"} ` + | ||
| `(HTTP ${response.status}). This is usually a proxy or gateway error page, not the API.`, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Done in 15a452ae4 — original SyntaxError now attached via new Error(msg, { cause }), matching the SDK's error-interceptor.ts convention. E2E-verified the cause is present ([cause=SyntaxError] on the HTML-body case) and the full parse-mode + interrupted-body matrix still passes on both clients.
Code Review SummaryThis review did not run. Your provider API key hit its rate limit, so the Previous Review Summaries (2 snapshots, latest commit 15a452a)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 15a452a)Status: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Notes:
Previous review (commit b090fd4)Status: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Notes:
|
Addresses Copilot review: the guard's actionable message discarded the
underlying SyntaxError (token/position detail). Attach it via
new Error(msg, { cause }) — the SDK's existing convention
(error-interceptor.ts).
|
@codex review |
|
Codex Review: Didn't find any major issues. What shall we delve into next? Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
sahrizvi
left a comment
There was a problem hiding this comment.
Review summary
Verdict: request changes. One blocking issue, one coverage gap, one missing test. Details on the first three are inline.
The diagnosis is right and the implementation is careful in the places that matter. Reading the body outside the try is the non-obvious call and it's the correct one — a socket reset or abort mid-read keeps its own error identity instead of getting mislabeled a proxy error page, and the inline comment says why. { cause } preserves the original SyntaxError, lib: ["es2022"] in packages/sdk/js/tsconfig.json makes the two-arg Error constructor typecheck, and splitting "json" out of the v1 fall-through is mechanically clean — arrayBuffer/blob/formData/text still dispatch through response[parseAs]() and parseAs resolution is unchanged. The marker format is right too (balanced, upstream_fix: prefix, no redundant nesting).
Major: no regression test
packages/sdk/js has no test suite, but the SDK is exercised from packages/opencode/test/server/ — sdk-error-shape.test.ts, sdk-v1-smoke.test.ts, httpapi-sdk.test.ts all build a client with an injected fetch, which makes faking this a ten-liner:
const sdk = createOpencodeClient({
baseUrl: "http://test",
fetch: (async () =>
new Response("<!DOCTYPE html><html>502</html>", {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch,
})
await expect(sdk.session.list()).rejects.toThrow(/not JSON/)Two reasons this is more than a box-tick. First, a test is the only mechanism that catches the regeneration wipe. Second, the trigger is counter-intuitive: the instinct is to return Content-Type: text/html, which never reaches the guard — the test has to use application/json with an HTML body. That subtlety belongs in a committed test rather than a PR description. The E2E matrix in the description is real work; it just isn't running anywhere.
REVIEW.md is explicit that CI here covers types and marker presence, not runtime behavior.
Minor
- The throw bypasses
interceptors.errorand thethrowOnError: falsecontract. Both wrappers registerclient.interceptors.error.use(wrapClientError), and those run only on the non-ok branch. A success-paththrowskips them — including any consumer-registered telemetry hook — and escapes regardless ofthrowOnError: false, which otherwise promises a{ data, error }tuple. This is not a regression:JSON.parse(text)threw a rawSyntaxErrorfrom the identical position before, so no caller ever got a result tuple for this failure class. But this was the natural moment to route it through the normal error path, and that's also why this error class is invisible towrapClientError. causeshape diverges from the error-path convention.error-interceptor.ts:31,35,41attachescause: { body, status }; this attaches the rawSyntaxError. Defensible — different failure classes — and the inline suggestion on the message resolves it incidentally.
Nits
gen/client/client.gen.ts:132uses"content-type"; line 110 in the same function uses"Content-Type".Headers.getis case-insensitive so it works, but pick one.- Twelve byte-identical lines across the two clients. A
parseJsonOrThrow(text, request, response)helper inpackages/sdk/js/src/—error-interceptor.tsis the precedent for shared non-generated client logic — would shrink the fork delta to two one-line calls and compose cleanly with the post-gen patch. - The v1 comment runs six lines to v2's three for identical logic.
Test matrix worth committing
- 200 +
application/json+ HTML body → actionable error,causeis aSyntaxError. v1 and v2. (the shipped bug) - 200 +
text/html; charset=utf-8+ HTML body → currently returns a string asdata. - 200 + no
Content-Type+ HTML body → currently returns a stream asdata. - v1 chunked 200, empty body, no
Content-Length→ asserts the new{}rather than the old throw. - 200 +
application/json+ valid JSON, and valid JSON under a wrong content-type → guard must not fire. responseValidator/responseTransformerstill run after a successful parse (v1 regression guard).- Body-read failure mid-stream still surfaces the socket error, not the proxy message — pins the outside-the-
tryplacement against future edits. - Codegen idempotence: run
packages/sdk/js/script/build.ts, assert the guard survives.
| // altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies | ||
| // A 200 whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a | ||
| // raw "JSON Parse error: Unrecognized token '<'". Surface an actionable error instead. | ||
| try { | ||
| data = text ? JSON.parse(text) : {} | ||
| } catch (cause) { | ||
| throw new Error( | ||
| `Expected a JSON response but received ${response.headers.get("content-type") || "an unknown content type"} ` + | ||
| `(HTTP ${response.status}). This is usually a proxy or gateway error page, not the API.`, | ||
| { cause }, | ||
| ) | ||
| } | ||
| // altimate_change end |
There was a problem hiding this comment.
Blocking: this hunk is deleted by the release build, so it never ships.
packages/sdk/js/script/build.ts:16-22 regenerates this whole tree:
await createClient({
input: "./openapi.json",
output: { path: "./src/v2/gen", tsConfigPath: ..., clean: true },
...
})clean: true wipes src/v2/gen and regenerates client/client.gen.ts from the @hey-api/client-fetch template — no guard, no markers — and nothing re-applies it afterwards.
This isn't "if someone runs generate". script/publish.ts:19-28 calls ./packages/sdk/js/script/build.ts inside prepareReleaseFiles(), which runs on every release, before the CLI and SDK are packed. So the published @opencode-ai/sdk/v2 — and the altimate binary that bundles it — ships without this fix, and the crash in the telemetry keeps firing.
Two things worth flagging:
-
The correct pattern is twelve lines below the generation call.
build.ts:43-59re-applies theSseFncodegen patch post-generation and throws if the needle stops matching. That's exactly what this needs:const v2ClientPath = "./src/v2/gen/client/client.gen.ts" const v2ClientSource = await Bun.file(v2ClientPath).text() const needle = "data = text ? JSON.parse(text) : {}" const v2ClientPatched = v2ClientSource.replace(needle, guardedBlock) if (v2ClientPatched === v2ClientSource) { throw new Error(`json-guard patch did not apply; @hey-api/client-fetch output may have changed (${v2ClientPath})`) } await Bun.write(v2ClientPath, v2ClientPatched)
Pair it with a codegen-idempotence check (run
build.ts, assert the guard is still there). Thereplaceassertion catches a template change; the test catches someone removing the patch step. -
The markers don't protect these files.
script/upstream/analyze.ts:707-721excludes both gen trees from marker checks outright:const markerExcludePatterns = [ ..., "packages/sdk/js/src/gen/**", "packages/sdk/js/src/v2/gen/**", ... ]
So the PR description's rationale — markers here mean the bridge-merge process sees and carries them — doesn't hold for these two paths. The marker format is right; the file is the problem. This is the first
altimate_changemarker to land insidesrc/v2/gen.
Note the asymmetry the description presents as equivalence: src/gen (v1) isn't regenerated by build.ts (only prettier --write), so the v1 hunk survives — by accident of v1 being a frozen snapshot, not because of the markers. Worth saying so in the v1 comment.
There was a problem hiding this comment.
Fixed in fcfb24b9f — you caught the fatal one; thank you. build.ts now re-applies the guard after codegen using exactly the SseFn pattern you pointed at, with one addition learned by running the full build: raw codegen emits the statement with a trailing semicolon (prettier strips it later), so the needle includes it — my first needle left the ; dangling, saved only by landing inside a comment. Verified end-to-end: ran script/build.ts, confirmed the regenerated tree carries the guard. Drift canaries pin both halves (guard present in both gen files; build.ts contains the re-apply with the exact needle). The committed v2 hunk now says 'edit it in build.ts, not here', and the PR description drops the marker-protection claim for the gen trees per your analyze.ts point — the v1 note now says plainly it survives because v1 is a frozen snapshot.
| // A 200 whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a | ||
| // raw "JSON Parse error: Unrecognized token '<'". Surface an actionable error instead. | ||
| try { | ||
| data = text ? JSON.parse(text) : {} |
There was a problem hiding this comment.
The guard only fires when parseAs resolves to "json", which leaves the common proxy shape untouched.
Both clients default to parseAs: "auto", so getParseAs() picks the arm (utils.gen.ts:61-90, v1 twin at :59-88):
| Response content-type | resolves to | with an HTML body, after this PR |
|---|---|---|
application/json (proxy lies) |
json |
✅ fixed |
unrecognized, e.g. foo/bar (?? "json") |
json |
✅ fixed |
text/html / text/html; charset=utf-8 |
text |
❌ HTML returned as a string in data, no error |
| absent | stream |
❌ response.body returned as data, no error |
application/octet-stream |
blob |
❌ Blob returned as data, no error |
Driven against a local server, both clients resolve rather than reject for text/html, text/html; charset=utf-8, and no Content-Type, under both throwOnError settings.
So this covers only the mislabeled-as-JSON case. A gateway that labels its error page honestly — most of them — still returns a "successful" result whose data is an HTML string, and fails further downstream with a worse message than the one this replaces.
Also, the description says parseAs falls back to "json" when Content-Type is missing. It doesn't — a missing Content-Type resolves to "stream" (utils.gen.ts:62-66).
The cheapest way to close most of this is one line in code this PR doesn't touch. packages/sdk/js/src/v2/client.ts:84-89 already guards this exact failure:
if (contentType === "text/html")
throw new Error("Request is not supported by this version of OpenCode Server (Server responded with text/html)")Exact equality misses text/html; charset=utf-8 — the form proxies and CDNs actually send. Normalizing it covers strictly more cases than this hunk does:
if (contentType?.split(";")[0]?.trim().toLowerCase() === "text/html")(v1 has no such interceptor at all, so v1 has neither layer.) Pre-existing and outside the diff, raised only because it's load-bearing for the gap above and is a one-liner.
There was a problem hiding this comment.
Fixed in fcfb24b9f — your one-liner is in: the v2 interceptor normalizes (split(";")[0].trim().toLowerCase()), so text/html; charset=utf-8 is caught. Verified with a live local server (bun test spins one up): honestly-labeled HTML with charset now rejects at the interceptor, mislabeled-as-json rejects at the guard. You're right about the description's parseAs claim — corrected (absent content-type resolves to stream, not json); the stream/blob columns of your table remain uncovered by this PR and the description now says so explicitly.
| throw new Error( | ||
| `Expected a JSON response but received ${response.headers.get("content-type") || "an unknown content type"} ` + | ||
| `(HTTP ${response.status}). This is usually a proxy or gateway error page, not the API.`, | ||
| { cause }, | ||
| ) |
There was a problem hiding this comment.
The message names the content-type, which in the only case that fires is application/json.
Because the guard runs only when parseAs resolved to "json", what users actually see is:
Expected a JSON response but received application/json (HTTP 200).
That's the string in the PR's own verification table, and it reads as self-contradictory — the content-type is the one field that isn't discriminating here.
The more concrete loss is that the error carries no request identity. packages/sdk/js/src/error-interceptor.ts (describe()) deliberately puts method + URL + status into every wrapped client error so formatters and telemetry have something traceable. A telemetry event carrying this message can't be traced to an endpoint or a host, and request is in scope at both sites.
Keeping the body out of the message is right — embedding a gateway page risks logging something sensitive — but it can live on cause for anyone debugging:
| throw new Error( | |
| `Expected a JSON response but received ${response.headers.get("content-type") || "an unknown content type"} ` + | |
| `(HTTP ${response.status}). This is usually a proxy or gateway error page, not the API.`, | |
| { cause }, | |
| ) | |
| throw new Error( | |
| `Expected a JSON response from ${request.method} ${request.url} but the body was not JSON ` + | |
| `(HTTP ${response.status}, content-type ${response.headers.get("content-type") ?? "unset"}). ` + | |
| `This is usually a proxy or gateway error page, not the API.`, | |
| { cause: { parseError: cause, status: response.status, body: text.slice(0, 200) } }, | |
| ) |
Same applies to the v1 copy at gen/client/client.gen.ts:131-135.
There was a problem hiding this comment.
Fixed in fcfb24b9f — your suggested message adopted nearly verbatim in both copies (and in the build.ts template, which is now the authoritative v2 source): method + URL for traceability, content-type named honestly with ?? "unset", body kept out of the message but a 200-char slice on cause alongside the parse error and status. The live-server test asserts the message carries the URL and the cause carries the body slice.
| case "json": { | ||
| const text = await response.text() | ||
| try { | ||
| data = text ? JSON.parse(text) : {} |
There was a problem hiding this comment.
Undeclared v1 behavior change: an empty body used to throw, now returns {}.
v1's case "json" was await response.json(), which throws SyntaxError: Unexpected end of JSON input on an empty body. text ? JSON.parse(text) : {} returns {} instead.
The early return above only covers status === 204 and Content-Length === "0" (client.gen.ts:100-107), so a chunked 200 with an empty body and no Content-Length reaches this switch and now silently yields {}.
Aligning v1 with v2 is probably the right call, but it's outside the stated scope and the PR body's matrix says v1 already returned {} before — it didn't. One knock-on: responseValidator (client.gen.ts:150) now runs against {} for empty bodies where it was previously never reached.
Either call it out in the description or split it into its own commit.
There was a problem hiding this comment.
Declared in fcfb24b9f's description update — you're right that the PR body's matrix claimed v1 already returned {} on empty bodies; it threw. The alignment with v2 is intentional and now stated explicitly, including the responseValidator knock-on (it now runs against {} for chunked-empty 200s where it was previously unreachable).
…rrors Addresses the human review: - the v2 guard now ships: script/build.ts re-applies it after codegen (clean: true wipes src/v2/gen on every release build), using the SseFn-patch pattern — needle-match against raw codegen output (trailing semicolon included) with a loud failure if the template drifts. Verified by running the full build: the regenerated tree carries the guard. Drift canaries pin both halves. - the v2 html interceptor normalizes content-type before comparing, so 'text/html; charset=utf-8' — the form proxies actually send — is caught instead of returned as a string payload. - the error carries request identity (method + URL), names the content-type honestly, and keeps a 200-char body slice on cause for debugging; telemetry stays body-free. - live-server tests drive both failure shapes end to end: HTML mislabeled as application/json (guard) and honestly-labeled text/html with charset (interceptor).
There was a problem hiding this comment.
1 issue found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/opencode/test/sdk-json-guard.test.ts">
<violation number="1" location="packages/opencode/test/sdk-json-guard.test.ts:12">
P3: The `read` helper passes `new URL(p, import.meta.url).pathname` to `Bun.file`. `URL.pathname` is percent-encoded and, on Windows, is prefixed with a drive letter (e.g. `/C:/...`), so `Bun.file` fails to resolve the file there and the drift-canary tests spuriously fail. `Bun.file` accepts a `URL` directly, so drop the `.pathname` (or use `fileURLToPath`, as `script/build.ts` does).</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| // an HTML error page as application/json, and one labeling it honestly). | ||
|
|
||
| describe("sdk json guard — drift canaries", () => { | ||
| const read = (p: string) => Bun.file(new URL(p, import.meta.url).pathname).text() |
There was a problem hiding this comment.
P3: The read helper passes new URL(p, import.meta.url).pathname to Bun.file. URL.pathname is percent-encoded and, on Windows, is prefixed with a drive letter (e.g. /C:/...), so Bun.file fails to resolve the file there and the drift-canary tests spuriously fail. Bun.file accepts a URL directly, so drop the .pathname (or use fileURLToPath, as script/build.ts does).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/test/sdk-json-guard.test.ts, line 12:
<comment>The `read` helper passes `new URL(p, import.meta.url).pathname` to `Bun.file`. `URL.pathname` is percent-encoded and, on Windows, is prefixed with a drive letter (e.g. `/C:/...`), so `Bun.file` fails to resolve the file there and the drift-canary tests spuriously fail. `Bun.file` accepts a `URL` directly, so drop the `.pathname` (or use `fileURLToPath`, as `script/build.ts` does).</comment>
<file context>
@@ -0,0 +1,75 @@
+// an HTML error page as application/json, and one labeling it honestly).
+
+describe("sdk json guard — drift canaries", () => {
+ const read = (p: string) => Bun.file(new URL(p, import.meta.url).pathname).text()
+
+ it("both generated clients carry the guard", async () => {
</file context>
| const read = (p: string) => Bun.file(new URL(p, import.meta.url).pathname).text() | |
| const read = (p: string) => Bun.file(new URL(p, import.meta.url)).text() |
sahrizvi
left a comment
There was a problem hiding this comment.
The release-build problem is genuinely fixed, and worth confirming explicitly since it was the blocking one. I checked it three ways rather than relying on the description:
jsonGuardNeedleis byte-identical to line 190 of the pinned@hey-api/openapi-ts@0.90.10fetch template — ten leading spaces, trailing semicolon, and exactly one occurrence in the file. The trailing-semicolon detail was the right catch; it is precisely what would have made this fail silently.- A full
script/build.tsrun against a clean checkout wipessrc/v2/genand lands the guard in the regenerated, prettified output.request,textandresponseare all in scope at the injection point, andbun tscatbuild.ts:94compiles the result before publish. bun test test/sdk-json-guard.test.tspasses 4/4.
Also confirmed: the interceptor normalization is correct and runs at client.gen.ts:118, before parseAs resolution at :130, so it fires regardless of parse mode — which is why the honestly-labeled text/html case is now covered end to end. Leaving stream and blob uncovered and saying so in the description is the right call. And the body read staying outside the guard, so socket resets keep their own error identity, survived this rewrite rather than being lost in it.
Four inline comments above. The rest:
No CI job runs the SDK build. The only script/build.ts invocations in the workflows are packages/opencode/script/build.ts (ci.yml:502, release.yml:92) — the binary build. packages/sdk/js/script/build.ts first executes inside script/publish.ts:28. The loud throw is the right failure mode but fires at the worst moment. The exact version pin bounds this to dependency-bump PRs, which is precisely the PR that goes green and then breaks the next release. The reproducible-codegen step in the second inline comment fixes this and the drift-detection gap together.
cause is no longer an Error. 15a452ae4 attached the SyntaxError directly as cause; this commit nests it as cause.parseError. So err.cause instanceof SyntaxError now fails, and default cause-chain printing no longer surfaces the parse detail on its own. Matching error-interceptor.ts's { body, status } shape is defensible — the new shape is a superset — but the earlier thread was resolved on the old behavior, so this is worth a line there rather than a silent change.
Nits
packages/sdk/js/src/gen/client/client.gen.tsnow spells the same concept three ways in one function:Content-Length(:100),Content-Type(:111),content-type(:135).Headers.getis case-insensitive, so purely cosmetic.- The guard body now exists in three places — v1's file, v2's committed file, and the string array in
build.ts. The second inline comment is what that already cost on day one. sdk-json-guard.test.ts:63asserts onlycause.body;cause.parseError,cause.statusand the 200-char truncation could all go missing undetected.sdk-json-guard.test.ts:73—expect(String(err)).toContain("text/html")does correctly isolate the interceptor (that route resolvesparseAsto"text", so the guard cannot fire on it, and removing the normalization makeserrnull). Exact-message matching would additionally pin the message contract, but nothing is broken as written.- The open automated-review comment on
sdk-json-guard.test.ts:12is worth taking. It is framed as Windows-only, but the percent-encoding half bites anywhere: a checkout under a directory containing a space resolves to%20andBun.filecannot open it, so the canary throws instead of asserting.
Still untested
- v1: mislabeled
application/json+ HTML body. - v1:
parseAs: "text"round-trip. - v1: chunked-empty 200 →
{}. - Reproducible codegen: build, then
git diff --exit-code src/v2/gen. - Interceptor:
TEXT/HTMLandtext/html ; charset=utf-8— the normalization handles both, nothing pins it. cause.parseError/cause.statuspresence and the body truncation.
Nothing here is blocking.
| it("build.ts re-applies the v2 guard after codegen with a matching needle", async () => { | ||
| const build = await read("../../sdk/js/script/build.ts") | ||
| expect(build).toContain("json-guard patch did not apply") | ||
| expect(build).toContain('const jsonGuardNeedle = " data = text ? JSON.parse(text) : {};"') | ||
| expect(build).toContain("but the body was not JSON") | ||
| }) |
There was a problem hiding this comment.
This canary cannot detect the drift it is named for.
expect(build).toContain('const jsonGuardNeedle = "…"') asserts that build.ts contains its own source literal. It passes for exactly as long as nobody edits that line — which is not the failure mode. The named failure is the needle ceasing to match generator output, and if @hey-api/openapi-ts changed its template tomorrow this test would stay green while build.ts:86-88 threw. The only detector today is that runtime throw, which fires inside publish.ts → prepareReleaseFiles() — mid-release.
That matters because it is load-bearing for the claim that the canaries "pin both halves". They pin that the strings are present; they do not pin that the patch still applies.
Two changes:
- Rename this to what it does — it pins the needle literal. Useful friction (nobody changes the needle without consciously updating the test), just not drift detection.
- Add the real detector. The template is on disk, so it costs nothing — but resolve it through the package root, since
@hey-api/openapi-tsexports only.,./internaland./package.json, and it is a dependency ofpackages/sdk/js, notpackages/opencode:
const sdk = fileURLToPath(new URL("../../sdk/js/", import.meta.url))
const root = path.dirname(require.resolve("@hey-api/openapi-ts/package.json", { paths: [sdk] }))
const tpl = await Bun.file(path.join(root, "dist/clients/fetch/client.ts")).text()
expect(tpl).toContain(" data = text ? JSON.parse(text) : {};")That fails in CI on the dependency-bump PR, which is where you want it.
Separately, in build.ts: String.prototype.replace silently takes the first match. Assert the insertion point is unique, so a template that grows a second occurrence fails loudly rather than patching the wrong one:
const matches = jsonGuardSource.split(jsonGuardNeedle).length - 1
if (matches !== 1) throw new Error(`expected one JSON guard insertion point, found ${matches}`)| // altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies | ||
| // A 200 whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a | ||
| // raw "JSON Parse error: Unrecognized token '<'". Surface an actionable error instead. | ||
| // Re-applied by script/build.ts after codegen (clean: true wipes this tree); edit it THERE. |
There was a problem hiding this comment.
This committed block is not what the build produces, so build.ts is not actually authoritative for this region.
Running script/build.ts against a clean checkout and diffing the result against this file gives exactly one hunk:
// altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies
- // A 200 whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a
- // raw "JSON Parse error: Unrecognized token '<'". Surface an actionable error instead.
- // Re-applied by script/build.ts after codegen (clean: true wipes this tree); edit it THERE.
+ // Re-applied by script/build.ts after codegen; edit it THERE, not here.Everything else in the regenerated tree matches, and the executable code is byte-identical — so there is no runtime impact. But:
- every release build leaves a spurious modification to a committed file, which reads as accidental drift and gets committed or reverted inconsistently;
- the two lines explaining why the guard exists are dropped from the code that actually ships;
- it blocks the check that would close the drift-detection gap for good.
Make jsonGuardBlock emit the same four comment lines this file carries. Then this becomes possible, and it validates the entire chain — needle match, identifier scope, prettier, tsc — without duplicating any of it:
- name: SDK codegen is reproducible
working-directory: packages/sdk/js
run: bun run script/build.ts && git diff --exit-code src/v2/gen| data = text ? JSON.parse(text) : {} | ||
| } catch (cause) { | ||
| throw new Error( | ||
| `Expected a JSON response from ${request.method} ${request.url} but the body was not JSON ` + |
There was a problem hiding this comment.
request.url includes the query string, which carries the user's absolute working directory.
v2/client.ts:34-37 (and the v1 twin at client.ts:19-27) set directory=<encodeURIComponent(absolute path)> as a query parameter on every GET/HEAD. So this message can render as:
Expected a JSON response from GET http://host/api/session?directory=%2FUsers%2Fjane%2Fwork%2Fclient-repo but the body was not JSON …
For the default http://localhost:4096 base URL the internal-host rule at packages/opencode/src/altimate/telemetry/index.ts:1405 redacts the whole URL, query included, so the common path is safe today. For a non-internal base URL that rule does not match and the path survives; percent-encoding also defeats path-shaped masking, so a future path-masking rule would not catch it either.
Request identity was the goal, and method + path supplies that without the query:
| `Expected a JSON response from ${request.method} ${request.url} but the body was not JSON ` + | |
| `Expected a JSON response from ${request.method} ${new URL(request.url).pathname} but the body was not JSON ` + |
Same change needed in the v1 copy at packages/sdk/js/src/gen/client/client.gen.ts:132 and in the build.ts template at :77, since those are separate copies.
| import { createClient } from "../../sdk/js/src/v2/gen/client/client.gen" | ||
| import { createOpencodeClient } from "../../sdk/js/src/v2/client" |
There was a problem hiding this comment.
Both live tests import v2, so v1 has no runtime coverage — only a string grep.
v1's edit is the structurally riskier one: it pulled case "json" out of a four-way fall-through group, changing switch control flow. It is also the root @opencode-ai/sdk export that external plugin authors consume (packages/plugin/src/index.ts:12), and packages/opencode/test/server/sdk-v1-smoke.test.ts shows the harness already exists.
Three cases would close it, all against v1's createClient:
- HTML body mislabeled
application/json→ the actionable error (the twin of the/lying-proxycase below). parseAs: "text"→ the exact string back, provingarrayBuffer/blob/formData/textstill dispatch throughresponse[parseAs]()after the switch split.- 200, chunked, empty body →
{}. This pins the declared behavior change (previouslySyntaxError); right now that change is documented but nothing stops a future refactor from silently undoing it.
Closes #1119
What
When a proxy / gateway / CDN returns an HTTP 200 with an HTML body (an error or interstitial page) instead of JSON, the generated SDK client crashes with a raw
JSON Parse error: Unrecognized token '<'.parseAsfalls back to"json"(?? "json") wheneverContent-Typeis missing or unrecognized, so a non-JSON body reaches the parser. The error response path was already guarded; the success path was not.Fix
Guard the JSON parse in the success path of both generated clients. On a parse failure, throw an actionable error naming the received content-type + HTTP status ("…usually a proxy or gateway error page, not the API") instead of the raw parse crash.
packages/sdk/js/src/v2/gen/client/client.gen.ts— the client the CLI imports (@opencode-ai/sdk/v2)packages/sdk/js/src/gen/client/client.gen.ts— v1:jsonis split out of the shared fall-through group soarrayBuffer/blob/formData/textkeep dispatching viaresponse[parseAs]()Both hunks are wrapped in
altimate_change start — upstream_fix:markers — the repo convention for local deviations from upstream, so the bridge-merge process sees and carries them, and they can be retired if/when the fix lands upstream.Verification (E2E under Bun, full parse-mode matrix)
Drove each actual client file against a local server. 7 cases × v1/v2 × before/after:
json+ HTML body (JSON content-type)SyntaxError: JSON Parse error: Unrecognized token '<'(v2) /Failed to parse JSON(v1)Expected a JSON response but received application/json (HTTP 200). This is usually a proxy or gateway error page, not the API.json+ valid JSONjson+ empty body{}{}(unchanged)parseAs: blobBlobBlob(unchanged)parseAs: arrayBufferArrayBufferArrayBuffer(unchanged)parseAs: textparseAs: formData(real multipart)FormDatafield=valueFormDatafield=value (unchanged)The v2 "before" error is the exact string seen in telemetry (JavaScriptCore phrasing → confirms the crash runs in the Bun CLI, not the Node extension).
packages/sdk/jstypecheck (tsgo --noEmit) passes.Where it came from
Surfaced by the extension telemetry-triage bot as a recurring
ChatPanel:chat:sendMessageError(~11 machines / 7d).Post-review revision (2026-08-21)
The human review reshaped this PR; the description above predates it. Current state:
script/build.ts, not the gen file:clean: trueregeneratessrc/v2/genon every release build, so the guard is re-applied post-codegen (the SseFn-patch pattern), needle-matched against raw codegen output with a loud failure on template drift. The in-tree gen copy mirrors the post-build state. The earlier marker rationale doesn't apply to the gen trees (analyze.tsexcludes them from marker checks); v1's hunk survives becausesrc/genis a frozen snapshot, not because of markers.text/html(incl.; charset=utf-8) → normalized v2 interceptor. Correction to the original text: an absent content-type resolvesparseAstostream(notjson); stream/blob-resolved bodies remain out of scope for this PR and would need interceptor-level handling.cause; body stays out of the message.case "json"previously threwSyntaxErroron an empty body (the original matrix here was wrong); it now returns{}, aligned with v2, which also meansresponseValidatornow runs against{}for chunked-empty 200 responses where it was previously unreachable.build.tsrun confirms the patch applies to freshly generated output; drift canaries pin both gen files and the build-script needle; live-server tests (packages/opencode/test/sdk-json-guard.test.ts) drive both failure shapes end to end.🤖 Generated with Claude Code
https://claude.ai/code/session_01LKJeLDMhBaYu16LrjGCf25
Summary by CodeRabbit
Bug Fixes
Tests