Skip to content

fix(postgrest): return a structured error for non-JSON body on successful responses#2398

Merged
mandarini merged 2 commits into
supabase:masterfrom
youcefzemmar:fix/postgrest-json-parse-on-ok-response
May 28, 2026
Merged

fix(postgrest): return a structured error for non-JSON body on successful responses#2398
mandarini merged 2 commits into
supabase:masterfrom
youcefzemmar:fix/postgrest-json-parse-on-ok-response

Conversation

@youcefzemmar
Copy link
Copy Markdown
Contributor

Description

On the success path (res.ok === true), postgrest-js calls JSON.parse(body) with no error handling. A 2xx status does not guarantee a JSON body:

  • A proxy / gateway / CDN in front of PostgREST (Cloudflare, nginx, a corporate proxy, a service worker, etc.) can return a non-JSON body — e.g. an HTML error page or a plaintext upstream connect error — with a success status.
  • A connection dropped mid-stream yields a truncated body (Unexpected end of JSON input) even from a perfectly well-behaved server.

When that happens today the raw SyntaxError escapes processResponse, and the result depends on how the query was awaited:

  1. With .throwOnError() → the promise rejects with a SyntaxError, not a PostgrestError. This breaks the documented contract — catch blocks that read error.code / error.hint / error.details receive an unexpected shape.
  2. Without it → the error is caught by the network-error handler and returned as { status: 0, error: { message: "SyntaxError: Unexpected token ..." } }. status: 0 signals a client-side network failure, which is misleading: the request actually reached the server and returned a real HTTP status.

The non-2xx branch already tolerates non-JSON bodies (it falls back to error = { message: body }). This PR makes the success path symmetric with it.

Change

Wrap the success-path JSON.parse in a try/catch. On parse failure:

  • return a structured error (message, details with a truncated body snippet, an actionable hint, empty code — matching the existing client-side error shape),
  • preserve the real HTTP status / statusText instead of reporting status: 0,
  • throw a PostgrestError when .throwOnError() is set.

Valid-JSON responses are completely unaffected — this is purely defensive.

Type of Change

  • Bug fix (fix)

Testing

  • Unit tests added (packages/core/postgrest-js/test/fetch-errors.test.ts, mocked fetch — no Docker): HTML body on 200, truncated JSON, real-status preservation, oversized-body truncation in details, and the throwOnError → PostgrestError contract.
  • nx type-check / nx type-check:test pass.
  • nx format:check passes; mock-based suites (fetch-errors, retry, timeout-and-url-length) pass.

Checklist

  • Code formatted (nx format)
  • Used conventional commits (fix(postgrest): ...)

Related context: supabase/postgrest-js#646 and supabase/postgrest-js#282 (the "Unexpected token < in JSON" class of reports). This PR intentionally scopes to client-side robustness/contract-correctness rather than expecting upstream proxies to always emit JSON.

🤖 Generated with Claude Code

…sful responses

A 2xx response is not a guarantee of a valid JSON body. A proxy, gateway,
or CDN in front of PostgREST can return a non-JSON body (e.g. an HTML error
page) with a success status, and a dropped connection can yield a truncated
body. Previously `JSON.parse` ran unguarded on the success path, so a raw
`SyntaxError` would either reject `.throwOnError()` (breaking its documented
`PostgrestError` contract) or be swallowed by the network-error handler and
reported as a misleading `status: 0` failure even though the request reached
the server.

Wrap the success-path parse in a try/catch that mirrors the existing non-2xx
branch: surface a structured error, preserve the real HTTP status, and throw
a `PostgrestError` when `throwOnError()` is set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@youcefzemmar youcefzemmar requested review from a team as code owners May 25, 2026 22:29
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

Open in StackBlitz

@supabase/auth-js

npm i https://pkg.pr.new/@supabase/auth-js@2398

@supabase/functions-js

npm i https://pkg.pr.new/@supabase/functions-js@2398

@supabase/postgrest-js

npm i https://pkg.pr.new/@supabase/postgrest-js@2398

@supabase/realtime-js

npm i https://pkg.pr.new/@supabase/realtime-js@2398

@supabase/storage-js

npm i https://pkg.pr.new/@supabase/storage-js@2398

@supabase/supabase-js

npm i https://pkg.pr.new/@supabase/supabase-js@2398

commit: 597a29d

Copy link
Copy Markdown
Contributor

@mandarini mandarini left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the careful analysis, the two failure modes you identified
(SyntaxError escaping .throwOnError() and the misleading status: 0 when
the request actually reached the server) are real, and the tests are
well-targeted. A few things I'd like to see before this merges, all in the
direction of less code:

  1. Mirror the existing non-2xx fallback exactly. The non-2xx branch
    already handles non-JSON bodies as error = { message: body } — three
    lines, no truncation, no hint, no custom message format. The success path
    should do the same so the two non-JSON branches produce the same error
    shape. Something like:

    try {
      data = JSON.parse(body)
    } catch {
      error = { message: body }
      data = null
      if (this.shouldThrowOnError) {
        throw new PostgrestError({ message: body, details: '', hint: '', code: '' })
      }
    }

    That fixes both bugs (the SyntaxError leak, and status: 0 — which is
    fixed implicitly because we no longer fall into the network-error handler)
    without introducing a third error shape alongside the existing PostgREST
    structured errors and the non-2xx fallback.

  2. Drop the 500-byte snippet truncation, the hint paragraph, and the
    "Failed to parse … : <parseError.message>" message format.
    These are
    speculative UX polish, not part of the bug fix:

    • The hint guesses at the user's infrastructure ("proxy, gateway, or
      CDN…") — it could be wrong and is worse than no hint when it is.
    • The 500-byte cap is arbitrary and needs its own test (the one you
      wrote) just to defend that arbitrary choice.
    • The custom message format becomes a new string consumers may match
      against.

    If we later want a richer error shape, that's a separate refactor that
    should bring both the success and non-2xx fallbacks along together.

  3. Drop the long inline comment. The rationale already lives in the PR
    description and the commit message — that's where readers go for "why
    this exists." A one-line comment is fine if it adds something the code
    doesn't already say.

With those changes the tests will simplify too, the truncation test goes
away, and the others stay roughly the same since the structured-error
assertions become error.message === <body> checks.

@mandarini mandarini self-assigned this May 28, 2026
… fallback

Address review feedback on supabase#2398. The success-path JSON.parse guard now
mirrors the existing non-2xx fallback exactly (error = { message: body })
instead of introducing a separate structured-error shape. This still fixes
both bugs the PR targets — the SyntaxError escaping .throwOnError() and the
misleading status: 0 — since the fix is the try/catch itself, not the error
shape. Drops the arbitrary 500-byte body truncation, the speculative hint,
the custom message format, and the long inline comment (and the test that
only existed to defend the truncation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@youcefzemmar
Copy link
Copy Markdown
Contributor Author

Thanks for the careful analysis, the two failure modes you identified (SyntaxError escaping .throwOnError() and the misleading status: 0 when the request actually reached the server) are real, and the tests are well-targeted. A few things I'd like to see before this merges, all in the direction of less code:

1. **Mirror the existing non-2xx fallback exactly.** The non-2xx branch
   already handles non-JSON bodies as `error = { message: body }` — three
   lines, no truncation, no hint, no custom message format. The success path
   should do the same so the two non-JSON branches produce the same error
   shape. Something like:
   ```ts
   try {
     data = JSON.parse(body)
   } catch {
     error = { message: body }
     data = null
     if (this.shouldThrowOnError) {
       throw new PostgrestError({ message: body, details: '', hint: '', code: '' })
     }
   }
   ```
   
   
       
         
       
   
         
       
   
       
     
   That fixes both bugs (the `SyntaxError` leak, and `status: 0` — which is
   fixed implicitly because we no longer fall into the network-error handler)
   without introducing a third error shape alongside the existing PostgREST
   structured errors and the non-2xx fallback.

2. **Drop the 500-byte snippet truncation, the `hint` paragraph, and the
   `"Failed to parse … : <parseError.message>"` message format.** These are
   speculative UX polish, not part of the bug fix:
   
   * The hint guesses at the user's infrastructure ("proxy, gateway, or
     CDN…") — it could be wrong and is worse than no hint when it is.
   * The 500-byte cap is arbitrary and needs its own test (the one you
     wrote) just to defend that arbitrary choice.
   * The custom message format becomes a new string consumers may match
     against.
   
   If we later want a richer error shape, that's a separate refactor that
   should bring both the success and non-2xx fallbacks along together.

3. **Drop the long inline comment.** The rationale already lives in the PR
   description and the commit message — that's where readers go for "why
   this exists." A one-line comment is fine if it adds something the code
   doesn't already say.

With those changes the tests will simplify too, the truncation test goes away, and the others stay roughly the same since the structured-error assertions become error.message === <body> checks.

as you asked

@youcefzemmar youcefzemmar requested a review from mandarini May 28, 2026 14:19
@mandarini mandarini merged commit b5390d2 into supabase:master May 28, 2026
21 checks passed
@mandarini
Copy link
Copy Markdown
Contributor

Thank you!

mandarini pushed a commit to supabase/supabase that referenced this pull request Jun 2, 2026
This PR updates @supabase/*-js libraries to version 2.107.0.

**Source**: manual

**Changes**:
- Updated @supabase/supabase-js to 2.107.0
- Updated @supabase/auth-js to 2.107.0
- Updated @supabase/realtime-js to 2.107.0
- Updated @supabase/postgest-js to 2.107.0
- Refreshed pnpm-lock.yaml

---

## Release Notes

## v2.107.0

## 2.107.0 (2026-06-02)

### 🚀 Features

- **auth:** remove navigator.locks-based mutex; introduce commit guard +
dispose() ([#2392](supabase/supabase-js#2392))
- **realtime:** allow httpSend to send binary payload
([#2400](supabase/supabase-js#2400))
- **supabase:** update X-Client-Info to structured metadata format
([#2359](supabase/supabase-js#2359))

### 🩹 Fixes

- **auth:** return AuthInvalidJwtError from getClaims for expired JWT
([#2395](supabase/supabase-js#2395))
- **auth:** recognize ?error= redirects in implicit grant gate
([#2407](supabase/supabase-js#2407))
- **auth): revert fix(auth:** encode client-id in oauth requests
([#2383](supabase/supabase-js#2383),
[#2417](supabase/supabase-js#2417))
- **postgrest:** return a structured error for non-JSON body on
successful responses
([#2398](supabase/supabase-js#2398))
- **release:** pin workspace:* sibling deps before JSR publish
([#2418](supabase/supabase-js#2418))
- **release:** publish gotrue-js legacy mirror via pnpm
([#2419](supabase/supabase-js#2419))

### ❤️ Thank You

- Claude Opus 4.7 (1M context)
- Claude Sonnet 4.6
- Eduardo Gurgel
- Guilherme Souza
- Katerina Skroumpelou @mandarini
- Omar Al Matar @Bewinxed
- youcef zr @youcefzemmar
- youcefzemmar

This PR was created automatically.

Co-authored-by: supabase-workflow-trigger[bot] <266661614+supabase-workflow-trigger[bot]@users.noreply.github.com>
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.

2 participants