Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,19 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch {
const data = await context.response.text();
if (data) {
const parseFunction = context.options.parseResponse || JSON.parse;
context.response._data = parseFunction(data);
try {
context.response._data = parseFunction(data);
} catch (error) {
// A successful response with a malformed JSON body is a real
// problem, so keep throwing there. For an error response the body
// is often not JSON (an HTML error page, plain text), and throwing
// a SyntaxError would bypass retry and ignoreResponseError, so keep
// the raw text and let it resolve to a FetchError instead.
if (context.response.ok) {
throw error;
}
context.response._data = data;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
break;
}
Expand Down
68 changes: 68 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 || true

Repository: 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"; }
done

Repository: 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]}")
PY

Repository: unjs/ofetch

Length of output: 7367


/flaky-503 still sets Content-Type on the success path.
return { ok: true } is auto-serialized by h3 as JSON with Content-Type: application/json, so this test doesn’t exercise the missing-header case it describes. Use a Response and delete the header there too.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.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 };
})
.all("/flaky-503", () => {
flaky503Attempts++;
if (flaky503Attempts < 2) {
const response = new Response("Service Unavailable", { status: 503 });
response.headers.delete("content-type");
return response;
}
const response = new Response(JSON.stringify({ ok: true }));
response.headers.delete("content-type");
return response;
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/index.test.ts` around lines 67 - 75, The `/flaky-503` test path in
`test/index.test.ts` is still returning a plain object on success, which lets h3
auto-add `Content-Type: application/json` and defeats the missing-header
scenario. Update the `all("/flaky-503", ...)` handler so both the retry failure
and the eventual success branch return a `Response`, and explicitly delete the
`content-type` header in the success branch as well. Use the `flaky503Attempts`
logic and the `/flaky-503` handler to keep the test behavior focused on the
missing-header case.

// 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
Expand Down Expand Up @@ -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);
Expand Down