-
Notifications
You must be signed in to change notification settings - Fork 181
fix: don't throw SyntaxError on non-JSON error response bodies #605
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 | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,6 +18,9 @@ describe("ofetch", () => { | |||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| const fetch = vi.spyOn(globalThis, "fetch"); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| // Counter for the flaky route used by the non-JSON error retry test. | ||||||||||||||||||||||||||||||||||||||||||
| let flaky503Attempts = 0; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| beforeAll(async () => { | ||||||||||||||||||||||||||||||||||||||||||
| const app = new H3({ debug: true }) | ||||||||||||||||||||||||||||||||||||||||||
| // .use(async (event) => { | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -52,6 +55,31 @@ describe("ofetch", () => { | |||||||||||||||||||||||||||||||||||||||||
| () => new HTTPError({ status: 403, statusMessage: "Forbidden" }) | ||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||
| .all("/408", () => new HTTPError({ status: 408 })) | ||||||||||||||||||||||||||||||||||||||||||
| // Plain-text 503 with no Content-Type header, like a bare error page | ||||||||||||||||||||||||||||||||||||||||||
| // emitted by nginx/HAProxy/a CDN in front of the origin. | ||||||||||||||||||||||||||||||||||||||||||
| .all("/text-503", () => { | ||||||||||||||||||||||||||||||||||||||||||
| const response = new Response("Service Unavailable", { status: 503 }); | ||||||||||||||||||||||||||||||||||||||||||
| response.headers.delete("content-type"); | ||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| // Same non-JSON 503 on the first attempt, then a JSON 200, to verify the | ||||||||||||||||||||||||||||||||||||||||||
| // retry path still runs for a retryable status with a non-JSON body. | ||||||||||||||||||||||||||||||||||||||||||
| .all("/flaky-503", () => { | ||||||||||||||||||||||||||||||||||||||||||
| flaky503Attempts++; | ||||||||||||||||||||||||||||||||||||||||||
| if (flaky503Attempts < 2) { | ||||||||||||||||||||||||||||||||||||||||||
| const response = new Response("Service Unavailable", { status: 503 }); | ||||||||||||||||||||||||||||||||||||||||||
| response.headers.delete("content-type"); | ||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| return { ok: true }; | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+67
to
+75
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== Files containing flaky-503 / relevant assertion ==\n'
rg -n --hidden --no-ignore-vcs 'flaky-503|valid JSON without content-type|content-type' test/index.test.ts
printf '\n== package files mentioning h3 ==\n'
rg -n --hidden --no-ignore-vcs '"h3"|h3@|from '\''h3'\''' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null || true
printf '\n== test/index.test.ts excerpt ==\n'
sed -n '55,90p' test/index.test.ts
printf '\n---\n'
sed -n '296,315p' test/index.test.ts
printf '\n== repository files with h3 docs/source references ==\n'
rg -n --hidden --no-ignore-vcs 'JSON serializable|content-type|application/json|h3 docs' . 2>/dev/null || trueRepository: unjs/ofetch Length of output: 50369 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '\n== h3 package files ==\n'
fd -a '^$' node_modules/.pnpm/h3@2.0.1-rc.5 -t f | head -n 200
printf '\n== h3 serialization/content-type references ==\n'
rg -n --hidden --no-ignore-vcs 'content-type|application/json|JSON serializable|stringified|toWebResponse|Response\(' node_modules/.pnpm/h3@2.0.1-rc.5 -g '!**/*.map' | head -n 200
printf '\n== likely entry files ==\n'
for f in \
node_modules/.pnpm/h3@2.0.1-rc.5/node_modules/h3/dist/index.mjs \
node_modules/.pnpm/h3@2.0.1-rc.5/node_modules/h3/dist/index.cjs \
node_modules/.pnpm/h3@2.0.1-rc.5/node_modules/h3/package.json
do
[ -f "$f" ] && { echo "--- $f"; sed -n '1,260p' "$f"; }
doneRepository: unjs/ofetch Length of output: 50367 🏁 Script executed: #!/bin/bash
set -euo pipefail
f=node_modules/.pnpm/h3@2.0.1-rc.5/node_modules/h3/dist/h3.mjs
printf '\n== prepareResponse / toResponse excerpt ==\n'
sed -n '180,310p' "$f"
printf '\n== nearby helper for JSON response ==\n'
sed -n '250,285p' "$f"
printf '\n== exact lines around plain-object handling ==\n'
python3 - <<'PY'
from pathlib import Path
p = Path("node_modules/.pnpm/h3@2.0.1-rc.5/node_modules/h3/dist/h3.mjs")
lines = p.read_text().splitlines()
for i in range(180, 311):
if "jsonHeaders" in lines[i-1] or "typeof val" in lines[i-1] or "prepareResponse" in lines[i-1] or "FastResponse(JSON.stringify" in lines[i-1]:
print(f"{i}: {lines[i-1]}")
PYRepository: unjs/ofetch Length of output: 7367
Fix .all("/flaky-503", () => {
flaky503Attempts++;
if (flaky503Attempts < 2) {
const response = new Response("Service Unavailable", { status: 503 });
response.headers.delete("content-type");
return response;
}
- return { ok: true };
+ const response = new Response(JSON.stringify({ ok: true }));
+ response.headers.delete("content-type");
+ return response;
})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| // A successful (2xx) response whose JSON body is malformed. A parse | ||||||||||||||||||||||||||||||||||||||||||
| // failure here is a real error and must still surface. | ||||||||||||||||||||||||||||||||||||||||||
| .all("/bad-json-200", () => { | ||||||||||||||||||||||||||||||||||||||||||
| const response = new Response("{ not: valid", { status: 200 }); | ||||||||||||||||||||||||||||||||||||||||||
| response.headers.set("content-type", "application/json"); | ||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||
| .all( | ||||||||||||||||||||||||||||||||||||||||||
| "/204", | ||||||||||||||||||||||||||||||||||||||||||
| () => null // eslint-disable-line unicorn/no-null | ||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -250,6 +278,46 @@ describe("ofetch", () => { | |||||||||||||||||||||||||||||||||||||||||
| expect(res?.message).to.eq("Forbidden"); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("non-JSON error body without content-type resolves to a FetchError", async () => { | ||||||||||||||||||||||||||||||||||||||||||
| // A plain-text 4xx/5xx with no Content-Type must not throw a bare | ||||||||||||||||||||||||||||||||||||||||||
| // SyntaxError from JSON.parse; it must surface as a FetchError carrying | ||||||||||||||||||||||||||||||||||||||||||
| // the status and the raw text body. | ||||||||||||||||||||||||||||||||||||||||||
| const error = await $fetch(getURL("text-503"), { retry: 0 }).catch( | ||||||||||||||||||||||||||||||||||||||||||
| (error_: any) => error_ | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| expect(error.constructor.name).to.equal("FetchError"); | ||||||||||||||||||||||||||||||||||||||||||
| expect(error.status).to.equal(503); | ||||||||||||||||||||||||||||||||||||||||||
| expect(error.data).to.equal("Service Unavailable"); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("retries a retryable status whose body is non-JSON text", async () => { | ||||||||||||||||||||||||||||||||||||||||||
| flaky503Attempts = 0; | ||||||||||||||||||||||||||||||||||||||||||
| const result = await $fetch(getURL("flaky-503"), { | ||||||||||||||||||||||||||||||||||||||||||
| retry: 3, | ||||||||||||||||||||||||||||||||||||||||||
| retryDelay: 0, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| expect(result).to.deep.equal({ ok: true }); | ||||||||||||||||||||||||||||||||||||||||||
| expect(flaky503Attempts).to.equal(2); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("throws when a successful response has a malformed JSON body", async () => { | ||||||||||||||||||||||||||||||||||||||||||
| await expect($fetch(getURL("bad-json-200"))).rejects.toThrow(); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("non-JSON error body is swallowed by ignoreResponseError", async () => { | ||||||||||||||||||||||||||||||||||||||||||
| const res = await $fetch(getURL("text-503"), { | ||||||||||||||||||||||||||||||||||||||||||
| ignoreResponseError: true, | ||||||||||||||||||||||||||||||||||||||||||
| retry: 0, | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
| expect(res).to.equal("Service Unavailable"); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("valid JSON without content-type still parses to an object", async () => { | ||||||||||||||||||||||||||||||||||||||||||
| flaky503Attempts = 1; // force /flaky-503 to answer 200 JSON immediately | ||||||||||||||||||||||||||||||||||||||||||
| const res = await $fetch(getURL("flaky-503"), { retry: 0 }); | ||||||||||||||||||||||||||||||||||||||||||
| expect(res).to.deep.equal({ ok: true }); | ||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| it("204 no content", async () => { | ||||||||||||||||||||||||||||||||||||||||||
| const res = await $fetch(getURL("204")); | ||||||||||||||||||||||||||||||||||||||||||
| expect(res).toBe(undefined); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.