Skip to content

fix: don't throw SyntaxError on non-JSON error response bodies#605

Open
spokodev wants to merge 2 commits into
unjs:mainfrom
spokodev:fix/error-response-json-parse
Open

fix: don't throw SyntaxError on non-JSON error response bodies#605
spokodev wants to merge 2 commits into
unjs:mainfrom
spokodev:fix/error-response-json-parse

Conversation

@spokodev

@spokodev spokodev commented Jul 1, 2026

Copy link
Copy Markdown

A non-JSON error response with no Content-Type header throws a bare SyntaxError that bypasses retry and error handling.

Repro

A local server returns a plain-text 503 with no Content-Type header, like a bare error page from nginx/HAProxy/a CDN:

HTTP/1.1 503 Service Unavailable

Service Unavailable

Fetching it:

await $fetch(url); // 503, no Content-Type, body "Service Unavailable"

Actual: a SyntaxError ("Unexpected token 'S'...") with no .status, .data, or .response. Because it is not a FetchError, the retry path is skipped for the retryable 503, and ignoreResponseError / onResponseError never run.

Expected: a FetchError with .status === 503 and .data === "Service Unavailable", retried per retryStatusCodes, and swallowed by ignoreResponseError.

Root cause

The response body is parsed before the status check and outside any try/catch. When there is no Content-Type, detectResponseType("") defaults to json, so the raw body runs through native JSON.parse. A non-JSON body throws, and the throw escapes before the >= 400 handling in onError, so it never becomes a FetchError and never reaches retry or the response-error hooks.

Fix

Wrap the parse and fall back to the raw text when the body is not valid JSON:

try {
  context.response._data = parseFunction(data);
} catch {
  context.response._data = data;
}

Valid JSON still parses to an object via native JSON.parse. Error and non-JSON bodies now resolve to a FetchError carrying the status and the raw text, so they flow through the normal retry and error-handling paths. This restores the non-throwing behavior from before #520 switched the success path to native JSON.parse, without re-adding the destr dependency.

Tests

Added regression tests in test/index.test.ts using the existing local H3 server: a plain-text 503 without Content-Type resolves to a FetchError (status 503, data equal to the text) instead of a SyntaxError, that 503 is retried per retryStatusCodes, ignoreResponseError swallows it, and valid JSON still parses to an object.

Verified red before the change (SyntaxError, no retry) and green after. Full suite: 32/32 passing, lint and prettier clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved parsing behavior for responses with invalid or non-JSON bodies, so error handling continues reliably.
    • When error responses lack a Content-Type, the original plain text body is preserved instead of failing during parsing.
    • Retry behavior now handles transient non-JSON failures more consistently.
  • Tests

    • Added scenarios covering non-JSON 4xx/5xx responses without Content-Type, malformed JSON cases, and verification of retry and ignore-error behavior.

When a response has no Content-Type header, detectResponseType defaults to
"json", so the response body is run through native JSON.parse. That parse ran
before the status check and outside any try/catch, so a plain-text 4xx/5xx body
(common from nginx/HAProxy/CDN error pages) threw a bare SyntaxError instead of a
FetchError. That error carried no status/data, silently skipped retry for
retryable codes such as 503, and bypassed ignoreResponseError/onResponseError.

Wrap the parse and fall back to the raw text when it is not valid JSON. Valid
JSON still parses to an object; error and non-JSON bodies resolve to a FetchError
and go through the normal retry and error-handling paths. This restores the
non-throwing behavior from before the switch to native JSON.parse, without
re-adding the destr dependency.
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb4e8dc9-6e19-4fb0-9a13-fbe7b96bf11e

📥 Commits

Reviewing files that changed from the base of the PR and between 8a36bee and aec74d5.

📒 Files selected for processing (2)
  • src/fetch.ts
  • test/index.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/fetch.ts

📝 Walkthrough

Walkthrough

Updates $fetchRaw to catch JSON parse failures and fall back to raw text for non-OK responses. Adds test routes and assertions for plain-text 503s, retry behavior, missing Content-Type, and malformed JSON on successful responses.

Changes

Non-JSON Response Handling

Layer / File(s) Summary
Fallback parsing implementation
src/fetch.ts
Wraps JSON parsing in try/catch; non-OK parse failures store raw response text in context.response._data, while successful responses still rethrow.
Routes and response cases
test/index.test.ts
Adds a flaky attempt counter plus routes for plain-text 503s without Content-Type, a retryable 503-to-JSON flow, and malformed JSON on 200 responses.
Error and parsing tests
test/index.test.ts
Adds coverage for raw-text error surfaces, retry behavior, ignoreResponseError, missing Content-Type JSON parsing, and malformed JSON throwing on success.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: preventing SyntaxError on non-JSON error responses.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with 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.

Inline comments:
In `@src/fetch.ts`:
- Around line 220-227: Scope the JSON parse fallback in fetch.ts so it only
applies to error responses, not all responses. Update the try/catch around
parseFunction(data) to preserve the current raw-text fallback only when the
response status indicates an error (for example, in the FetchResponse handling
path that builds context.response._data), and let malformed JSON on 2xx
responses surface normally. Use the existing parseFunction,
context.response._data, and FetchError/ignoreResponseError flow to locate and
adjust the logic.

In `@test/index.test.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 27c6b238-170f-44e3-b17e-cb48d7eedb27

📥 Commits

Reviewing files that changed from the base of the PR and between 9102908 and 8a36bee.

📒 Files selected for processing (2)
  • src/fetch.ts
  • test/index.test.ts

Comment thread src/fetch.ts
Comment thread test/index.test.ts
Comment on lines +67 to +75
.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 };
})

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant