Skip to content

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

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

spokodev wants to merge 4 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 handling of invalid or non-JSON response bodies.
    • Preserved raw text from unsuccessful responses without a content type.
    • Successful responses with malformed JSON now report parsing errors correctly.
    • Improved retry handling for transient non-JSON failures.
    • Valid JSON responses can now be parsed without a content type.
  • Tests

    • Added coverage for malformed responses, non-JSON errors, retry behavior, and ignore-error handling.

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 in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 15382ee6-0023-44e6-a073-b69813fd6621
📥 Commits

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

📒 Files selected for processing: 2
  • src/fetch.ts
  • test/index.test.ts
💤 Files with no reviewable changes: 1
  • src/fetch.ts

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

📝 Walkthrough

Updates $fetchRaw to retain raw text when JSON parsing fails for non-OK responses. Successful responses still rethrow parse errors. Tests cover plain-text 503 responses, retries, missing Content-Type, ignored errors, and malformed JSON.

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 rethrow.
Routes and response cases
test/index.test.ts
Adds a retry attempt counter and routes for plain-text 503 responses, retry-then-success behavior, and malformed JSON on 200 responses.
Error and parsing tests
test/index.test.ts
Adds coverage for raw-text error data, retries, ignoreResponseError, JSON without Content-Type, and malformed JSON on successful responses.

Priority: ⬇️ Low

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

Change: Bug fix

Merge Risk: ⚪ Minimal · up to b2b88

The change correctly preserves raw text for non-OK responses while retaining normal JSON parsing and success-response parse errors.

Architecture risk: 🔵 Low · up to b2b88

The change affects 2 systems.

Changed systems: src, test

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — src (service) was modified; 1 changed file maps to changed impact.
  • observed — test (service) was modified; 1 changed file maps to changed impact.

Before / after behavior

  • observed — Modified behavior in src/fetch.ts: JSON parsing errors are now handled conditionally: successful responses rethrow the parse error, while unsuccessful responses retain the unparsed response text in _data instead of failing during parsing.
  • observed — Modified behavior in test/index.test.ts: Added the flaky503Attempts counter used to control the flaky 503 test route.
  • observed — Modified behavior in test/index.test.ts: Added /text-503, /flaky-503, and /bad-json-200 test routes. The first returns a 503 text response without a content type; the second returns that response once before returning valid JSON; the third returns malformed JSON with status 200 and an explicit JSON content type.
  • observed — Modified behavior in test/index.test.ts: Added coverage for non-JSON error conversion to FetchError, retries on text 503 responses, rejection of malformed JSON on successful responses, swallowing errors with ignoreResponseError, and parsing valid JSON without a content type.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: preventing SyntaxError for non-JSON error response bodies.
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 a new PR

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