fix: preserve function responses (#47920)

- adds up to: https://github.com/supabase/cli/pull/5862

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added an “Error docs” link in Edge Function testing UI when an
`sb-error-code` header is present.

* **Bug Fixes**
* Improved the Edge Function test proxy to consistently preserve
upstream status, headers (including repeated headers), and response
bodies without transformation.
* Enhanced handling for invalid function URLs and upstream fetch
failures.

* **Tests**
* Added unit, API, and Playwright E2E coverage for error docs linking
and response proxy behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Vaibhav authored and GitHub committed 2026-07-20 18:58:30 +01:00
1 parent 4864031466
commit 83e6552d71
7 files changed
+301 -40

No files matched your search

@@ -1,6 +1,6 @@
export type ResponseData = {
status: number
headers: Record<string, string>
headers: Record<string, string | string[]>
body: string
}
@@ -1,4 +1,7 @@
import { getAnchor } from '@ui/components/CustomHTMLElements/CustomHTMLElements.utils'
import { EdgeFunction } from '@/data/edge-functions/edge-function-query'
import { DOCS_URL } from '@/lib/constants'
export const generateCLICommands = ({
selectedFunction,
@@ -99,3 +102,18 @@ export const generateCLICommands = ({
return { managementCommands, secretCommands, invokeCommands }
}
export const getEdgeFunctionErrorDocs = (headers: Record<string, string | string[]>) => {
const header = Object.entries(headers).find(
([name]) => name.toLowerCase() === 'sb-error-code'
)?.[1]
const code = (Array.isArray(header) ? header[0] : header)?.trim()
const anchor = code ? getAnchor(code) : undefined
if (!code || !anchor) return undefined
return {
code,
href: `${DOCS_URL}/guides/functions/error-codes#${anchor}`,
}
}
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { Loader2, Plus, Send, X } from 'lucide-react'
import { BookOpen, Loader2, Plus, Send, X } from 'lucide-react'
import { useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import {
@@ -37,6 +37,7 @@ import * as z from 'zod'
import { HTTP_METHODS } from './EdgeFunctionDetails.constants'
import { ErrorWithStatus, ResponseData } from './EdgeFunctionDetails.types'
import { getEdgeFunctionErrorDocs } from './EdgeFunctionDetails.utils'
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useAPIKeys } from '@/data/api-keys/api-keys-query'
@@ -100,6 +101,7 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
const [response, setResponse] = useState<ResponseData | null>(null)
const [error, setError] = useState<string | null>(null)
const errorDocs = response ? getEdgeFunctionErrorDocs(response.headers) : undefined
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeysData } = useAPIKeys({ projectRef }, { enabled: canReadAPIKeys })
@@ -423,12 +425,28 @@ const EdgeFunctionTesterSheetContent = ({ visible, onClose }: EdgeFunctionTester
Headers
</TabsTrigger>
</div>
<Badge
variant={response.status >= 400 ? 'destructive' : 'success'}
className="-translate-y-1"
>
{response.status}
</Badge>
<div className="-translate-y-1 flex items-center gap-2">
{errorDocs !== undefined && (
<Button
asChild
variant="text"
size="tiny"
icon={<BookOpen size={14} />}
>
<a
href={errorDocs.href}
target="_blank"
rel="noreferrer"
aria-label={`View documentation for ${errorDocs.code} (opens in new tab)`}
>
Error docs
</a>
</Button>
)}
<Badge variant={response.status >= 400 ? 'destructive' : 'success'}>
{response.status}
</Badge>
</div>
</TabsList>
<TabsContent value="body" className="mt-0 flex-1 overflow-auto p-0">
<CodeBlock
+11 -32
View File
@@ -77,42 +77,21 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
redirect: 'manual', // don't follow the redirect and return response as is
})
// Handle non-JSON responses
let responseBody: string
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
// If JSON, parse and stringify to ensure it's valid JSON
const jsonBody = await response.json()
responseBody = JSON.stringify(jsonBody)
} else {
// For non-JSON responses, get raw text
responseBody = await response.text()
}
const responseBody = await response.text()
if (!response.ok) {
// Try to parse error response if it's JSON
try {
const errorBody = JSON.parse(responseBody)
return res.status(response.status).json({
status: response.status,
error: { message: errorBody?.error || 'Edge function returned an error' },
})
} catch (parseError) {
// If not JSON, return the raw error
return res.status(response.status).json({
status: response.status,
error: { message: responseBody || 'Edge function returned an error' },
})
}
}
const responseHeaders: Record<string, string> = {}
const responseHeaders: Record<string, string | string[]> = {}
response.headers.forEach((value, key) => {
responseHeaders[key] = value
const existing = responseHeaders[key]
if (existing === undefined) {
responseHeaders[key] = value
} else if (Array.isArray(existing)) {
existing.push(value)
} else {
responseHeaders[key] = [existing, value]
}
})
return res.status(response.status).json({
return res.status(200).json({
status: response.status,
headers: responseHeaders,
body: responseBody,
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import { getEdgeFunctionErrorDocs } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.utils'
import { DOCS_URL } from '@/lib/constants'
describe('getEdgeFunctionErrorDocs', () => {
it('builds a documentation link from the error code header', () => {
expect(
getEdgeFunctionErrorDocs({ 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' })
).toEqual({
code: 'UNAUTHORIZED_INVALID_JWT_FORMAT',
href: `${DOCS_URL}/guides/functions/error-codes#unauthorizedinvalidjwtformat`,
})
})
it('uses the same anchor format as documentation headings', () => {
expect(getEdgeFunctionErrorDocs({ 'sb-error-code': 'WORKER_RESOURCE_LIMIT' })).toEqual({
code: 'WORKER_RESOURCE_LIMIT',
href: `${DOCS_URL}/guides/functions/error-codes#workerresourcelimit`,
})
})
it('matches the error code header case-insensitively', () => {
expect(getEdgeFunctionErrorDocs({ 'Sb-Error-Code': 'BOOT_ERROR' })).toEqual({
code: 'BOOT_ERROR',
href: `${DOCS_URL}/guides/functions/error-codes#booterror`,
})
})
it('uses the first value when the header contains multiple values', () => {
expect(getEdgeFunctionErrorDocs({ 'sb-error-code': ['WORKER_ERROR', 'BOOT_ERROR'] })).toEqual({
code: 'WORKER_ERROR',
href: `${DOCS_URL}/guides/functions/error-codes#workererror`,
})
})
it('omits the link when the error code header is missing', () => {
expect(getEdgeFunctionErrorDocs({})).toBeUndefined()
})
it.each(['', '___'])('omits the link for an unusable error code', (code) => {
expect(getEdgeFunctionErrorDocs({ 'sb-error-code': code })).toBeUndefined()
})
})
@@ -0,0 +1,141 @@
import { createMocks } from 'node-mocks-http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import handler from '../../../../pages/api/edge-functions/test'
vi.mock('common', () => ({ IS_PLATFORM: true }))
const createRequest = (url = 'https://abcdefghijklmnopqrst.supabase.co/functions/v1/test') =>
createMocks({
method: 'POST',
body: {
url,
method: 'POST',
body: '{}',
headers: {},
},
})
describe('/api/edge-functions/test', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('preserves unsuccessful edge function responses', async () => {
const body = JSON.stringify({
code: 'UNAUTHORIZED_NO_AUTH_HEADER',
message: 'Missing authorization header',
})
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(body, {
status: 401,
headers: {
'content-type': 'application/json',
'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER',
},
})
)
)
const { req, res } = createRequest()
await handler(req, res)
expect(res._getStatusCode()).toBe(200)
expect(JSON.parse(res._getData())).toEqual({
status: 401,
headers: {
'content-type': 'application/json',
'sb-error-code': 'UNAUTHORIZED_NO_AUTH_HEADER',
},
body,
})
})
it('preserves multiple Set-Cookie headers', async () => {
const headers = new Headers()
headers.append('set-cookie', 'session=one; Path=/; HttpOnly')
headers.append('set-cookie', 'csrf=two; Path=/; SameSite=Lax')
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { headers })))
const { req, res } = createRequest()
await handler(req, res)
expect(JSON.parse(res._getData())).toEqual({
status: 200,
headers: {
'set-cookie': ['session=one; Path=/; HttpOnly', 'csrf=two; Path=/; SameSite=Lax'],
},
body: '',
})
})
it.each([
['successful JSON', JSON.stringify({ ok: true }), 'application/json', 200],
[
'gateway JSON',
JSON.stringify({ message: 'Name resolution failed' }),
'application/json',
503,
],
['legacy JSON', JSON.stringify({ msg: 'Invalid JWT' }), 'application/json', 503],
[
'nested JSON',
JSON.stringify({ error: { message: 'Function failed' } }),
'application/json',
503,
],
['arbitrary JSON', JSON.stringify({ details: ['Function failed'] }), 'application/json', 503],
['plain text', 'Bad Gateway', 'text/plain', 503],
['malformed JSON', '{"message": invalid json', 'application/json', 503],
])('preserves %s bodies without parsing them', async (_name, body, contentType, status) => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(body, {
status,
headers: { 'content-type': contentType },
})
)
)
const { req, res } = createRequest()
await handler(req, res)
expect(res._getStatusCode()).toBe(200)
expect(JSON.parse(res._getData())).toEqual({
status,
headers: { 'content-type': contentType },
body,
})
})
it('rejects invalid URLs without making an upstream request', async () => {
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
const { req, res } = createRequest('https://example.com/functions/v1/test')
await handler(req, res)
expect(fetchMock).not.toHaveBeenCalled()
expect(res._getStatusCode()).toBe(400)
expect(JSON.parse(res._getData())).toEqual({
status: 400,
error: { message: 'Provided URL is not a valid Supabase edge function URL' },
})
})
it('returns fetch failures as proxy errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Connection refused')))
const { req, res } = createRequest()
await handler(req, res)
expect(res._getStatusCode()).toBe(500)
expect(JSON.parse(res._getData())).toEqual({
status: 500,
error: { message: 'Connection refused' },
})
})
})
@@ -0,0 +1,61 @@
import { expect } from '@playwright/test'
import { test } from '../utils/test.js'
import { toUrl } from '../utils/to-url.js'
const FUNCTION_SLUG = 'error-code-docs'
test.describe('Edge Functions', () => {
test('links an sb-error-code response to its documentation', async ({ page, ref }) => {
await page.route(`**/api/v1/projects/${ref}/functions/${FUNCTION_SLUG}`, async (route) => {
const timestamp = Date.now()
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
id: '00000000-0000-0000-0000-000000000000',
slug: FUNCTION_SLUG,
name: FUNCTION_SLUG,
version: 1,
status: 'ACTIVE',
entrypoint_path: `supabase/functions/${FUNCTION_SLUG}/index.ts`,
created_at: timestamp,
updated_at: timestamp,
}),
})
})
await page.route('**/api/edge-functions/test', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
status: 401,
headers: { 'sb-error-code': 'UNAUTHORIZED_INVALID_JWT_FORMAT' },
body: '',
}),
})
})
await page.goto(toUrl(`/project/${ref}/functions/${FUNCTION_SLUG}`))
await page.getByRole('button', { name: 'Test', exact: true }).click()
const testResponse = page.waitForResponse(
(response) =>
response.url().includes('/api/edge-functions/test') &&
response.request().method() === 'POST'
)
await page.getByRole('button', { name: 'Send Request' }).click()
expect(
(await testResponse).ok(),
'Studio proxy should return a successful response envelope'
).toBeTruthy()
await expect(
page.getByRole('link', {
name: 'View documentation for UNAUTHORIZED_INVALID_JWT_FORMAT (opens in new tab)',
}),
'Error response should link to its documentation section'
).toHaveAttribute(
'href',
'https://supabase.com/docs/guides/functions/error-codes#unauthorizedinvalidjwtformat'
)
})
})