mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
fix: intercept responses missing content lenght and re-add (#46885)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Cases with cloudflares http/3 the content-length header is optional, so in many cases we need to make sure in this case `openapi-fetch` can safely parse this (i.e ignore when the body is empty and no header is present) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Fixed JSON parsing failures when successful API responses contain empty bodies without `Content-Length` headers. Improves compatibility with HTTP/3 and similar response types. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
359c933e77
commit
ee3bec08af
2 files changed
+132
-1
No files matched your search
@@ -63,6 +63,35 @@ export async function constructHeaders(headersInit?: HeadersInit | undefined) {
|
||||
return headers
|
||||
}
|
||||
|
||||
/**
|
||||
* openapi-fetch only treats a response body as empty when `status === 204` or the
|
||||
* response carries a `Content-Length: 0` header; otherwise it calls `response.json()`,
|
||||
* which throws "Unexpected end of JSON input" on an empty body. HTTP/3 (and HEAD
|
||||
* requests) may omit `Content-Length: 0` on empty-body responses — e.g. a `201` with
|
||||
* no body — so a request that succeeds over HTTP/2 can fail over HTTP/3.
|
||||
*
|
||||
* Normalize empty-body success responses by setting `Content-Length: 0` so the parser
|
||||
* short-circuits regardless of transport. Non-empty responses are returned untouched.
|
||||
*/
|
||||
export async function normalizeEmptyBodyResponse(response: Response): Promise<Response> {
|
||||
if (response.status === 204 || response.headers.has('Content-Length')) {
|
||||
return response
|
||||
}
|
||||
|
||||
const body = await response.clone().text()
|
||||
if (body.length > 0) {
|
||||
return response
|
||||
}
|
||||
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set('Content-Length', '0')
|
||||
return new Response(null, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
function pgMetaGuard(request: Request) {
|
||||
// Only check for /platform/pg-meta/ endpoints
|
||||
if (request.url.includes('/platform/pg-meta/')) {
|
||||
@@ -98,7 +127,7 @@ client.use(
|
||||
// Middleware to format errors
|
||||
async onResponse({ request, response }) {
|
||||
if (response.ok) {
|
||||
return response
|
||||
return normalizeEmptyBodyResponse(response)
|
||||
}
|
||||
|
||||
// handle errors
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() }))
|
||||
vi.mock('common', () => ({ IS_PLATFORM: false, getAccessToken: vi.fn() }))
|
||||
vi.mock('@/lib/constants', () => ({ API_URL: 'http://localhost' }))
|
||||
vi.mock('@/lib/helpers', () => ({ uuidv4: () => 'test-uuid' }))
|
||||
|
||||
// Import after mocks are set up
|
||||
const { normalizeEmptyBodyResponse, client } = await import('./fetchers')
|
||||
|
||||
describe('normalizeEmptyBodyResponse', () => {
|
||||
it('adds Content-Length: 0 to an empty 201 that omits it (HTTP/3 case)', async () => {
|
||||
// HTTP/3 responses can drop `Content-Length: 0` on empty bodies, which makes
|
||||
// openapi-fetch attempt to JSON-parse the empty body and throw.
|
||||
const response = new Response(null, { status: 201 })
|
||||
expect(response.headers.has('Content-Length')).toBe(false)
|
||||
|
||||
const normalized = await normalizeEmptyBodyResponse(response)
|
||||
|
||||
expect(normalized.status).toBe(201)
|
||||
expect(normalized.headers.get('Content-Length')).toBe('0')
|
||||
// The normalized empty body must not throw when parsed the way openapi-fetch parses it.
|
||||
expect(await normalized.text()).toBe('')
|
||||
})
|
||||
|
||||
it('leaves a response that already has Content-Length untouched (HTTP/2 case)', async () => {
|
||||
const response = new Response(null, {
|
||||
status: 201,
|
||||
headers: { 'Content-Length': '0' },
|
||||
})
|
||||
|
||||
const normalized = await normalizeEmptyBodyResponse(response)
|
||||
|
||||
expect(normalized).toBe(response)
|
||||
})
|
||||
|
||||
it('does not modify a non-empty body that omits Content-Length', async () => {
|
||||
const response = new Response(JSON.stringify({ id: 1 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
expect(response.headers.has('Content-Length')).toBe(false)
|
||||
|
||||
const normalized = await normalizeEmptyBodyResponse(response)
|
||||
|
||||
// Returns the original response (body still readable, not consumed by the peek).
|
||||
expect(normalized).toBe(response)
|
||||
expect(await normalized.json()).toEqual({ id: 1 })
|
||||
})
|
||||
|
||||
it('leaves 204 No Content untouched even without Content-Length', async () => {
|
||||
const response = new Response(null, { status: 204 })
|
||||
|
||||
const normalized = await normalizeEmptyBodyResponse(response)
|
||||
|
||||
expect(normalized).toBe(response)
|
||||
})
|
||||
|
||||
it('preserves status and statusText when normalizing', async () => {
|
||||
const response = new Response(null, { status: 201, statusText: 'Created' })
|
||||
|
||||
const normalized = await normalizeEmptyBodyResponse(response)
|
||||
|
||||
expect(normalized.status).toBe(201)
|
||||
expect(normalized.statusText).toBe('Created')
|
||||
})
|
||||
|
||||
it('preserves other headers when adding Content-Length', async () => {
|
||||
const response = new Response(null, {
|
||||
status: 201,
|
||||
headers: { 'X-Request-Id': 'req-123' },
|
||||
})
|
||||
|
||||
const normalized = await normalizeEmptyBodyResponse(response)
|
||||
|
||||
expect(normalized.headers.get('X-Request-Id')).toBe('req-123')
|
||||
expect(normalized.headers.get('Content-Length')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi-fetch client — empty 201 without Content-Length (HTTP/3)', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('resolves with data instead of throwing on JSON parse', async () => {
|
||||
// Simulate the HTTP/3 transport: a 201 with an empty body and no Content-Length.
|
||||
// Without normalization openapi-fetch calls response.json() and throws
|
||||
// "Unexpected end of JSON input"; the middleware should make this succeed.
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(null, { status: 201 }))
|
||||
)
|
||||
|
||||
const { data, error, response } = await client.POST('/v1/projects/{ref}/secrets' as any, {
|
||||
params: { path: { ref: 'test-ref' } },
|
||||
body: [] as any,
|
||||
})
|
||||
|
||||
expect(error).toBeUndefined()
|
||||
expect(data).toEqual({})
|
||||
expect(response.status).toBe(201)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user