-
Notifications
You must be signed in to change notification settings - Fork 133
fix: surface a clear error on non-JSON API responses instead of crashing #1093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,75 @@ | ||||||
| import { describe, expect, it, beforeAll, afterAll } from "bun:test" | ||||||
| import { createClient } from "../../sdk/js/src/v2/gen/client/client.gen" | ||||||
| import { createOpencodeClient } from "../../sdk/js/src/v2/client" | ||||||
|
|
||||||
| // The JSON-parse guard lives in GENERATED code that `script/build.ts` wipes | ||||||
| // (clean: true) and re-applies on every release build. These tests pin both | ||||||
| // halves: the drift canaries fail if either copy of the patch disappears, and | ||||||
| // the live-server tests exercise the actual failure shapes (a proxy serving | ||||||
| // 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. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| it("both generated clients carry the guard", async () => { | ||||||
| for (const p of [ | ||||||
| "../../sdk/js/src/gen/client/client.gen.ts", | ||||||
| "../../sdk/js/src/v2/gen/client/client.gen.ts", | ||||||
| ]) { | ||||||
| const src = await read(p) | ||||||
| expect(src).toContain("guard JSON parse against non-JSON") | ||||||
| expect(src).toContain("but the body was not JSON") | ||||||
| } | ||||||
| }) | ||||||
|
|
||||||
| 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") | ||||||
| }) | ||||||
|
Comment on lines
+25
to
+30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This canary cannot detect the drift it is named for.
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:
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 const matches = jsonGuardSource.split(jsonGuardNeedle).length - 1
if (matches !== 1) throw new Error(`expected one JSON guard insertion point, found ${matches}`) |
||||||
| }) | ||||||
|
|
||||||
| describe("sdk json guard — live failure shapes", () => { | ||||||
| let server: ReturnType<typeof Bun.serve> | ||||||
| let base: string | ||||||
| const html = "<!DOCTYPE html><html><body>502 Bad Gateway</body></html>" | ||||||
|
|
||||||
| beforeAll(() => { | ||||||
| server = Bun.serve({ | ||||||
| port: 0, | ||||||
| fetch(req) { | ||||||
| const path = new URL(req.url).pathname | ||||||
| if (path.endsWith("/lying-proxy")) | ||||||
| return new Response(html, { status: 200, headers: { "content-type": "application/json" } }) | ||||||
| // every other route: an honest proxy error page with charset | ||||||
| return new Response(html, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }) | ||||||
| }, | ||||||
| }) | ||||||
| base = `http://localhost:${server.port}` | ||||||
| }) | ||||||
| afterAll(() => server.stop(true)) | ||||||
|
|
||||||
| it("HTML mislabeled as application/json rejects with an actionable error", async () => { | ||||||
| const client = createClient({ baseUrl: base }) | ||||||
| const err = await client | ||||||
| .get({ url: "/lying-proxy" }) | ||||||
| .then(() => null) | ||||||
| .catch((e: unknown) => e as Error & { cause?: { body?: string } }) | ||||||
| expect(err).not.toBeNull() | ||||||
| expect(err!.message).toContain("but the body was not JSON") | ||||||
| expect(err!.message).toContain("/lying-proxy") | ||||||
| expect(err!.message).toContain("content-type application/json") | ||||||
| expect(err!.cause?.body).toContain("502 Bad Gateway") | ||||||
| }) | ||||||
|
|
||||||
| it("honestly-labeled text/html (with charset) rejects at the interceptor", async () => { | ||||||
| const oc = createOpencodeClient({ baseUrl: base }) | ||||||
| const err = await oc.app | ||||||
| .log({ service: "t", level: "info", message: "x" }) | ||||||
| .then(() => null) | ||||||
| .catch((e: unknown) => e as Error) | ||||||
| expect(err).not.toBeNull() | ||||||
| expect(String(err)).toContain("text/html") | ||||||
| }) | ||||||
| }) | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -114,10 +114,30 @@ export const createClient = (config: Config = {}): Client => { | |
| case "arrayBuffer": | ||
| case "blob": | ||
| case "formData": | ||
| case "json": | ||
| case "text": | ||
| data = await response[parseAs]() | ||
| break | ||
| // altimate_change start — upstream_fix: guard JSON parse against non-JSON (HTML) response bodies | ||
| // "json" is split out of the fall-through group above so its parse can be guarded: a 200 | ||
| // whose body is an HTML error page from a proxy/gateway/CDN otherwise crashes with a raw | ||
| // "JSON Parse error: Unrecognized token '<'". The body is read OUTSIDE the guard so a | ||
| // network/body-read failure (socket reset, abort) keeps its own error; only an actual | ||
| // JSON syntax failure gets the actionable message (mirrors the v2 client). | ||
| case "json": { | ||
| const text = await response.text() | ||
| try { | ||
| data = text ? JSON.parse(text) : {} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The early return above only covers 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 Either call it out in the description or split it into its own commit.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Declared in |
||
| } catch (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) } }, | ||
| ) | ||
| } | ||
| break | ||
| } | ||
| // altimate_change end | ||
| case "stream": | ||
| return opts.responseStyle === "data" | ||
| ? response.body | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -169,7 +169,21 @@ export const createClient = (config: Config = {}): Client => { | |||||||||||||||||||||||
| // Some servers return 200 with no Content-Length and empty body. | ||||||||||||||||||||||||
| // response.json() would throw; read as text and parse if non-empty. | ||||||||||||||||||||||||
| const text = await response.text() | ||||||||||||||||||||||||
| data = text ? JSON.parse(text) : {} | ||||||||||||||||||||||||
| // 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. | ||||||||||||||||||||||||
|
Comment on lines
+172
to
+175
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This committed block is not what the build produces, so Running // 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:
Make - name: SDK codegen is reproducible
working-directory: packages/sdk/js
run: bun run script/build.ts && git diff --exit-code src/v2/gen |
||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| data = text ? JSON.parse(text) : {} | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The guard only fires when Both clients default to
Driven against a local server, both clients resolve rather than reject for 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 Also, the description says The cheapest way to close most of this is one line in code this PR doesn't touch. 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 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||||||||||||||||||||||||
| } catch (cause) { | ||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||
| `Expected a JSON response from ${request.method} ${request.url} but the body was not JSON ` + | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For the default Request identity was the goal, and method + path supplies that without the query:
Suggested change
Same change needed in the v1 copy at |
||||||||||||||||||||||||
| `(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) } }, | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
Comment on lines
+179
to
+184
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Because the guard runs only when 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. Keeping the body out of the message is right — embedding a gateway page risks logging something sensitive — but it can live on
Suggested change
Same applies to the v1 copy at
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| // altimate_change end | ||||||||||||||||||||||||
|
Comment on lines
+172
to
+186
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
await createClient({
input: "./openapi.json",
output: { path: "./src/v2/gen", tsConfigPath: ..., clean: true },
...
})
This isn't "if someone runs generate". Two things worth flagging:
Note the asymmetry the description presents as equivalence:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in |
||||||||||||||||||||||||
| break | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| case "stream": | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
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/sdkexport that external plugin authors consume (packages/plugin/src/index.ts:12), andpackages/opencode/test/server/sdk-v1-smoke.test.tsshows the harness already exists.Three cases would close it, all against v1's
createClient: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.{}. This pins the declared behavior change (previouslySyntaxError); right now that change is documented but nothing stops a future refactor from silently undoing it.