Skip to content

fix: surface a clear error on non-JSON API responses instead of crashing - #1093

Open
ralphstodomingo wants to merge 3 commits into
mainfrom
fix/sdk-client-non-json-response
Open

fix: surface a clear error on non-JSON API responses instead of crashing#1093
ralphstodomingo wants to merge 3 commits into
mainfrom
fix/sdk-client-non-json-response

Conversation

@ralphstodomingo

@ralphstodomingo ralphstodomingo commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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 '<'. parseAs falls back to "json" (?? "json") whenever Content-Type is 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: json is split out of the shared fall-through group so 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 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:

Case main (before) this PR
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 JSON parsed parsed (unchanged)
json + empty body {} {} (unchanged)
parseAs: blob Blob Blob (unchanged)
parseAs: arrayBuffer ArrayBuffer ArrayBuffer (unchanged)
parseAs: text exact string exact string (unchanged)
parseAs: formData (real multipart) FormData field=value FormData field=value (unchanged)
interrupted body mid-read (raw-TCP reset) socket error propagates socket error propagates (unchanged — body read kept outside the guard, per Codex review)

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/js typecheck (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:

  • The v2 fix ships via script/build.ts, not the gen file: clean: true regenerates src/v2/gen on 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.ts excludes them from marker checks); v1's hunk survives because src/gen is a frozen snapshot, not because of markers.
  • Coverage: mislabeled-as-JSON bodies → guard; honestly-labeled text/html (incl. ; charset=utf-8) → normalized v2 interceptor. Correction to the original text: an absent content-type resolves parseAs to stream (not json); stream/blob-resolved bodies remain out of scope for this PR and would need interceptor-level handling.
  • Errors are traceable: method + URL + status + honest content-type in the message; parse error, status, and a 200-char body slice on cause; body stays out of the message.
  • Declared v1 behavior change: v1's case "json" previously threw SyntaxError on an empty body (the original matrix here was wrong); it now returns {}, aligned with v2, which also means responseValidator now runs against {} for chunked-empty 200 responses where it was previously unreachable.
  • Verification: full build.ts run 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

    • Improved handling of unexpected HTML responses returned where JSON is expected.
    • Error messages now include request details, HTTP status, content type, and a truncated response body.
    • HTML responses are detected consistently, including varying capitalization and charset parameters.
    • Build validation now alerts when generated client code cannot be safely updated.
  • Tests

    • Added coverage for generated-client safeguards, build validation, and HTML response failures.

@ralphstodomingo ralphstodomingo self-assigned this Aug 12, 2026
@github-actions

Copy link
Copy Markdown

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

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.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c8e69d1-dfb0-482a-9cda-6fdfed0a6f04

📥 Commits

Reviewing files that changed from the base of the PR and between 54a8f32 and fcfb24b.

⛔ Files ignored due to path filters (2)
  • packages/sdk/js/src/gen/client/client.gen.ts is excluded by !**/gen/**
  • packages/sdk/js/src/v2/gen/client/client.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (3)
  • packages/opencode/test/sdk-json-guard.test.ts
  • packages/sdk/js/script/build.ts
  • packages/sdk/js/src/v2/client.ts

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The SDK build script patches generated clients with descriptive JSON parsing errors. The response interceptor normalizes Content-Type values before rejecting HTML responses. Tests cover generated-client drift and live proxy responses with misleading or explicit HTML content types.

Changes

SDK JSON response guards

Layer / File(s) Summary
Generated client JSON guard
packages/sdk/js/script/build.ts, packages/opencode/test/sdk-json-guard.test.ts
The build script replaces unguarded generated JSON.parse calls with descriptive errors and fails when the expected generated code is absent. Drift tests verify the guard in both generated clients and the build patch marker.
HTML response rejection
packages/sdk/js/src/v2/client.ts, packages/opencode/test/sdk-json-guard.test.ts
The interceptor trims parameters and compares media types case-insensitively. Live tests verify actionable errors for mislabeled and explicitly labeled HTML responses.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to fcfb2

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
Loading

Suggested reviewers: anandgupta42

Poem

A rabbit checks the JSON gate,
HTML pages must now wait.
Guards catch the proxy’s trick,
Content types are parsed quick.
Clear errors hop across the stream.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The reviewed changes match issue #1119, but the v1 and generated client files are excluded by !/gen/, so full compliance cannot be verified. Include reviewable evidence for the excluded generated clients, or adjust the filters, to verify the required v1 and v2 success-path guards.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The reviewed tests, build changes, and interceptor normalization directly support actionable errors for non-JSON API responses.
Title check ✅ Passed The title clearly summarizes the main change: replacing raw crashes with clear errors for non-JSON API responses.
Description check ✅ Passed The description covers the issue, implementation, scope, verification, and review revisions; omitted template checkboxes are non-critical.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sdk-client-non-json-response

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@ralphstodomingo

Copy link
Copy Markdown
Contributor Author

Verified via E2E repro (Bun)

Drove the actual @opencode-ai/sdk/v2 client under Bun against a mock server returning a 200 with an HTML body (a proxy/gateway error page that keeps a JSON content-type):

Before — unpatched client on main:

RESULT: THREW  name=SyntaxError  message=JSON Parse error: Unrecognized token '<'

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 client.gen.ts.

After — this PR:

RESULT: THREW  name=Error  message=Expected a JSON response but received application/json (HTTP 200). This is usually a proxy or gateway error page, not the API.

Control — valid JSON 200 against the patched client: parses fine ({"ok":true,"hello":"world"}), no regression on the happy path.

@ralphstodomingo
ralphstodomingo force-pushed the fix/sdk-client-non-json-response branch 2 times, most recently from 1c24cce to 8249569 Compare August 12, 2026 17:01
@ralphstodomingo

Copy link
Copy Markdown
Contributor Author

Re-verified after the review round: both hunks now wrapped in altimate_change start — upstream_fix: markers (the repo's bridge-merge convention for local deviations), v1's json case split out so arrayBuffer/blob/formData/text keep their native dispatch, and the full parse-mode matrix re-run E2E under Bun on both clients, before and after — 28/28 as expected (see updated PR description). packages/sdk/js typecheck passes.

@ralphstodomingo

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@ralphstodomingo
ralphstodomingo force-pushed the fix/sdk-client-non-json-response branch from 8249569 to b090fd4 Compare August 12, 2026 17:19
@ralphstodomingo
ralphstodomingo marked this pull request as ready for review August 13, 2026 00:30
Copilot AI lite review requested due to automatic review settings August 13, 2026 00:30

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.parse on the success path for the v2 generated client when parsing JSON from response.text().
  • Split "json" out of the v1 client’s fall-through parse switch so JSON parsing can be guarded without affecting other parseAs modes.

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.

Comment on lines +177 to +182
} 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.`,
)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +130 to +135
} 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.`,
)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@kilo-code-bot

kilo-code-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

Code Review Summary

This review did not run. Your provider API key hit its rate limit, so the
request was rejected before the review started. Kilo does not retry
automatically, because the quota is your provider's; push a new commit once it
resets. Any inline comments below are from an earlier review.

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)
  • packages/sdk/js/src/gen/client/client.gen.ts
  • packages/sdk/js/src/v2/gen/client/client.gen.ts

Notes:

  • Incremental change since b090fd4a0: both clients now capture the caught value (catch (cause)) and attach it via new Error(msg, { cause }), resolving the two prior Copilot findings that the original SyntaxError was discarded. Standard ES2022 Error cause; cause is always defined here since the guarded body is only JSON.parse(text).
  • Body read (await response.text()) remains outside the try in both files, so the earlier Codex concern (network/body-read failures mislabeled as proxy errors) stays fixed — only a true JSON.parse syntax failure is translated. Verified at client.gen.ts:127-136 (v1) and v2/.../client.gen.ts:171-184.
  • v1's case "json": is split out of the fall-through group, preserving response[parseAs]() dispatch for arrayBuffer/blob/formData/text.
  • altimate_change start/end markers correctly wrap all diverging lines in both files; the newly added catch (cause) and { cause } lines sit inside the existing marked block.
  • Byte-identical guard logic across v1/v2 is intentional for independently-regenerated gen/ files; shared extraction would be non-idiomatic.

Previous review (commit b090fd4)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (2 files)
  • packages/sdk/js/src/gen/client/client.gen.ts
  • packages/sdk/js/src/v2/gen/client/client.gen.ts

Notes:

  • Both diffs guard the JSON success-path parse so a non-JSON (proxy/gateway/CDN HTML) 200 body throws an actionable error instead of the raw JSON Parse error: Unrecognized token '<'. Correct and well-scoped.
  • The earlier Codex concern (broad catch swallowing body-read/socket errors) is correctly addressed: const text = await response.text() sits outside the try in both files, so only a true JSON.parse syntax failure is translated — network errors keep their own message. Verified in code at client.gen.ts:127-136 (v1) and v2/.../client.gen.ts:171-184.
  • v1's case "json": split out of the fall-through group preserves the response[parseAs]() dispatch for arrayBuffer/blob/formData/text; only JSON parsing changed.
  • altimate_change start — upstream_fix: / end markers wrap all diverging lines in both files with no nesting/misuse — consistent with the repo's fork-merge convention.
  • No incomplete fix: the two other client.gen.ts files are createClient re-export barrels with no parse logic.
  • The byte-identical error string across v1/v2 is intentional for independently-regenerated gen/ files (self-contained markers must survive per-file regen); extracting shared code would be non-idiomatic here.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

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).
@ralphstodomingo

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 15a452ae4e

ℹ️ 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".

@sahrizvi sahrizvi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.error and the throwOnError: false contract. Both wrappers register client.interceptors.error.use(wrapClientError), and those run only on the non-ok branch. A success-path throw skips them — including any consumer-registered telemetry hook — and escapes regardless of throwOnError: false, which otherwise promises a { data, error } tuple. This is not a regression: JSON.parse(text) threw a raw SyntaxError from 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 to wrapClientError.
  • cause shape diverges from the error-path convention. error-interceptor.ts:31,35,41 attaches cause: { body, status }; this attaches the raw SyntaxError. Defensible — different failure classes — and the inline suggestion on the message resolves it incidentally.

Nits

  • gen/client/client.gen.ts:132 uses "content-type"; line 110 in the same function uses "Content-Type". Headers.get is case-insensitive so it works, but pick one.
  • Twelve byte-identical lines across the two clients. A parseJsonOrThrow(text, request, response) helper in packages/sdk/js/src/error-interceptor.ts is 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

  1. 200 + application/json + HTML body → actionable error, cause is a SyntaxError. v1 and v2. (the shipped bug)
  2. 200 + text/html; charset=utf-8 + HTML body → currently returns a string as data.
  3. 200 + no Content-Type + HTML body → currently returns a stream as data.
  4. v1 chunked 200, empty body, no Content-Length → asserts the new {} rather than the old throw.
  5. 200 + application/json + valid JSON, and valid JSON under a wrong content-type → guard must not fire.
  6. responseValidator / responseTransformer still run after a successful parse (v1 regression guard).
  7. Body-read failure mid-stream still surfaces the socket error, not the proxy message — pins the outside-the-try placement against future edits.
  8. Codegen idempotence: run packages/sdk/js/script/build.ts, assert the guard survives.

Comment on lines +172 to +184
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The correct pattern is twelve lines below the generation call. build.ts:43-59 re-applies the SseFn codegen 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). The replace assertion catches a template change; the test catches someone removing the patch step.

  2. The markers don't protect these files. script/upstream/analyze.ts:707-721 excludes 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_change marker to land inside src/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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) : {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +178 to +182
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 },
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) : {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
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 sahrizvi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • jsonGuardNeedle is byte-identical to line 190 of the pinned @hey-api/openapi-ts@0.90.10 fetch 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.ts run against a clean checkout wipes src/v2/gen and lands the guard in the regenerated, prettified output. request, text and response are all in scope at the injection point, and bun tsc at build.ts:94 compiles the result before publish.
  • bun test test/sdk-json-guard.test.ts passes 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.ts now spells the same concept three ways in one function: Content-Length (:100), Content-Type (:111), content-type (:135). Headers.get is 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:63 asserts only cause.body; cause.parseError, cause.status and the 200-char truncation could all go missing undetected.
  • sdk-json-guard.test.ts:73expect(String(err)).toContain("text/html") does correctly isolate the interceptor (that route resolves parseAs to "text", so the guard cannot fire on it, and removing the normalization makes err null). 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:12 is 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 %20 and Bun.file cannot open it, so the canary throws instead of asserting.

Still untested

  1. v1: mislabeled application/json + HTML body.
  2. v1: parseAs: "text" round-trip.
  3. v1: chunked-empty 200 → {}.
  4. Reproducible codegen: build, then git diff --exit-code src/v2/gen.
  5. Interceptor: TEXT/HTML and text/html ; charset=utf-8 — the normalization handles both, nothing pins it.
  6. cause.parseError / cause.status presence and the body truncation.

Nothing here is blocking.

Comment on lines +25 to +30
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")
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsprepareReleaseFiles() — 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:

  1. 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.
  2. Add the real detector. The template is on disk, so it costs nothing — but resolve it through the package root, since @hey-api/openapi-ts exports only ., ./internal and ./package.json, and it is a dependency of packages/sdk/js, not packages/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}`)

Comment on lines +172 to +175
// 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ` +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
`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.

Comment on lines +2 to +3
import { createClient } from "../../sdk/js/src/v2/gen/client/client.gen"
import { createOpencodeClient } from "../../sdk/js/src/v2/client"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. HTML body mislabeled application/json → the actionable error (the twin of the /lying-proxy case below).
  2. parseAs: "text" → the exact string back, proving arrayBuffer/blob/formData/text still dispatch through response[parseAs]() after the switch split.
  3. 200, chunked, empty body → {}. This pins the declared behavior change (previously SyntaxError); right now that change is documented but nothing stops a future refactor from silently undoing it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDK client crashes with a raw JSON parse error on non-JSON (HTML) API responses

3 participants