Add custom error handler around fetch method (#35562)

* Add custom error handler for fetch error 'Failed to fetch'

* Refactor EdgeFunctionTesterSheet to use react query instead of manually calling fetch

* Use new fetchHandler for where we're calling fetch

* Revert
This commit is contained in:
Joshen Lim authored and GitHub committed 2025-05-09 15:22:55 +08:00
1 parent 31a804f6d4
commit 232a26a68d
12 files changed
+193 -117

No files matched your search

@@ -9,10 +9,10 @@ import { RoleImpersonationPopover } from 'components/interfaces/RoleImpersonatio
import { useSessionAccessTokenQuery } from 'data/auth/session-access-token-query'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { constructHeaders } from 'data/fetchers'
import { useEdgeFunctionTestMutation } from 'data/edge-functions/edge-function-test-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { IS_PLATFORM } from 'lib/constants'
import { prettifyJSON } from 'lib/helpers'
import { getRoleImpersonationJWT } from 'lib/role-impersonation'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
@@ -76,19 +76,34 @@ const FormSchema = z.object({
type FormValues = z.infer<typeof FormSchema>
export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTesterSheetProps) => {
const { ref: projectRef, functionSlug } = useParams()
const { mutate: sendEvent } = useSendEventMutation()
const org = useSelectedOrganization()
const { ref: projectRef, functionSlug } = useParams()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const [response, setResponse] = useState<ResponseData | null>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const { data: config } = useProjectPostgrestConfigQuery({ projectRef })
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM })
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const { serviceKey } = getAPIKeys(settings)
const { mutate: sendEvent } = useSendEventMutation()
const { mutate: testEdgeFunction, isLoading } = useEdgeFunctionTestMutation({
onSuccess: (res) => setResponse(res),
onError: (err) => {
setError(err instanceof Error ? err.message : 'An unknown error occurred')
if (err instanceof Error) {
const errorWithStatus = err as ErrorWithStatus
setResponse({
status: errorWithStatus.cause?.status || 500,
headers: {},
body: '',
})
}
},
})
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.endpoint ?? ''
const url = `${protocol}://${endpoint}/functions/v1/${functionSlug}`
@@ -139,98 +154,66 @@ export const EdgeFunctionTesterSheet = ({ visible, onClose }: EdgeFunctionTester
}
const onSubmit = async (values: FormValues) => {
setError(null)
setResponse(null)
// Validate that the body is valid JSON
try {
setIsLoading(true)
setError(null)
setResponse(null)
// Validate that the body is valid JSON
try {
JSON.parse(values.body)
} catch (e) {
form.setError('body', { message: 'Must be a valid JSON string' })
return
}
let testAuthorization: string | undefined
const role = getImpersonatedRoleState().role
if (
projectRef !== undefined &&
config?.jwt_secret !== undefined &&
role !== undefined &&
role.type === 'postgrest'
) {
try {
const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role)
testAuthorization = 'Bearer ' + token
} catch (err: any) {
console.error('Failed to generate JWT:', {
error: err.message,
roleDetails: role,
})
}
}
// Construct custom headers
const customHeaders: Record<string, string> = {}
headerFields.forEach(({ key, value }) => {
if (key && value) {
customHeaders[key] = value
}
})
// Construct query parameters
const queryString = queryParamFields
.filter(({ key, value }) => key && value)
.map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
const finalUrl = queryString ? `${url}?${queryString}` : url
const defaultHeaders = await constructHeaders()
const res = await fetch(`${BASE_PATH}/api/edge-functions/test`, {
method: 'POST',
headers: {
...defaultHeaders,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: finalUrl,
method: values.method,
body: values.body,
headers: {
...(accessToken && {
Authorization: `Bearer ${accessToken}`,
}),
'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`,
'Content-Type': 'application/json',
...customHeaders,
},
}),
})
const data = await res.json()
if (!res.ok) {
throw new Error(data.error?.message || 'Failed to test edge function', {
cause: { status: data.status },
})
}
setResponse(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred')
if (err instanceof Error) {
const errorWithStatus = err as ErrorWithStatus
setResponse({
status: errorWithStatus.cause?.status || 500,
headers: {},
body: '',
})
}
} finally {
setIsLoading(false)
JSON.parse(values.body)
} catch (e) {
form.setError('body', { message: 'Must be a valid JSON string' })
return
}
let testAuthorization: string | undefined
const role = getImpersonatedRoleState().role
if (
projectRef !== undefined &&
config?.jwt_secret !== undefined &&
role !== undefined &&
role.type === 'postgrest'
) {
try {
const token = await getRoleImpersonationJWT(projectRef, config.jwt_secret, role)
testAuthorization = 'Bearer ' + token
} catch (err: any) {
console.error('Failed to generate JWT:', {
error: err.message,
roleDetails: role,
})
}
}
// Construct custom headers
const customHeaders: Record<string, string> = {}
headerFields.forEach(({ key, value }) => {
if (key && value) {
customHeaders[key] = value
}
})
// Construct query parameters
const queryString = queryParamFields
.filter(({ key, value }) => key && value)
.map(({ key, value }) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&')
const finalUrl = queryString ? `${url}?${queryString}` : url
testEdgeFunction({
url: finalUrl,
method: values.method,
body: values.body,
headers: {
...(accessToken && {
Authorization: `Bearer ${accessToken}`,
}),
'x-test-authorization': testAuthorization ?? `Bearer ${serviceKey?.api_key}`,
'Content-Type': 'application/json',
...customHeaders,
},
})
}
const renderKeyValuePairs = (type: 'headers' | 'queryParams', label: string) => (
@@ -1,3 +1,4 @@
import { fetchHandler } from 'data/fetchers'
import { BASE_PATH } from 'lib/constants'
import { useCallback, useEffect, useState } from 'react'
import { Button } from 'ui'
@@ -9,7 +10,7 @@ const CLOCK_SKEW_CHECK_INTERVAL = 30 * 60 * 1000
const isClockSkewed = async () => {
try {
const response = await fetch(`${BASE_PATH}/api/get-utc-time`)
const response = await fetchHandler(`${BASE_PATH}/api/get-utc-time`)
const data = await response.json()
// The received time is in UTC timezone, add Z at the end to make JS understand that
const serverTime = new Date(data.utcTime).getTime()
+5 -2
View File
@@ -1,6 +1,6 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { constructHeaders } from 'data/fetchers'
import { constructHeaders, fetchHandler } from 'data/fetchers'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { ResponseError } from 'types'
import { aiKeys } from './keys'
@@ -10,7 +10,10 @@ import { aiKeys } from './keys'
export async function checkOpenAIKey(signal?: AbortSignal) {
const headers = await constructHeaders()
const response = await fetch(`${BASE_PATH}/api/ai/sql/check-api-key`, { headers, signal })
const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/check-api-key`, {
headers,
signal,
})
let body: any
try {
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { toast } from 'sonner'
import { constructHeaders } from 'data/fetchers'
import { constructHeaders, fetchHandler } from 'data/fetchers'
import { BASE_PATH } from 'lib/constants'
import { ResponseError } from 'types'
@@ -16,7 +16,7 @@ export type SqlTitleGenerateVariables = {
export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) {
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
const response = await fetch(`${BASE_PATH}/api/ai/sql/title`, {
const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/title`, {
headers,
method: 'POST',
body: JSON.stringify({
@@ -1,4 +1,5 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchHandler } from 'data/fetchers'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
@@ -21,7 +22,7 @@ export type CheckCNAMERecordResponse = {
// [Joshen] Should tally with https://github.com/supabase/cli/blob/63790a1bd43bee06f82c4f510e709925526a4daa/internal/utils/api.go#L98
export async function checkCNAMERecord({ domain }: CheckCNAMERecordVariables) {
const res = await fetch(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, {
const res = await fetchHandler(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, {
method: 'GET',
headers: { accept: 'application/dns-json' },
})
@@ -1,5 +1,5 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { constructHeaders, handleError } from 'data/fetchers'
import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { ResponseError } from 'types'
import { edgeFunctionsKeys } from './keys'
@@ -32,7 +32,7 @@ export async function getEdgeFunctionBody(
})
// Send to our API for processing (the API will handle the fetch from v1 endpoint)
const parseResponse = await fetch(`${BASE_PATH}/api/edge-functions/body`, {
const parseResponse = await fetchHandler(`${BASE_PATH}/api/edge-functions/body`, {
method: 'POST',
body: JSON.stringify({ projectRef, slug }),
headers,
@@ -0,0 +1,71 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { toast } from 'sonner'
import { ResponseData } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.types'
import { constructHeaders, fetchHandler } from 'data/fetchers'
import { BASE_PATH } from 'lib/constants'
import { ResponseError } from 'types'
export type EdgeFunctionTestResponse = {
title: string
description: string
}
export type EdgeFunctionTestVariables = {
url: string
method: string
body: string
headers: { [key: string]: string }
}
export async function testEdgeFunction({ url, method, body, headers }: EdgeFunctionTestVariables) {
const defaultHeaders = await constructHeaders()
const response = await fetchHandler(`${BASE_PATH}/api/edge-functions/test`, {
method: 'POST',
headers: { ...defaultHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ url, method, body, headers }),
})
let data: any
try {
data = await response.json()
} catch {}
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to test edge function', {
cause: { status: data.status },
})
}
return data as ResponseData
}
type EdgeFunctionTestData = Awaited<ReturnType<typeof testEdgeFunction>>
export const useEdgeFunctionTestMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<EdgeFunctionTestData, ResponseError, EdgeFunctionTestVariables>,
'mutationFn'
> = {}) => {
return useMutation<EdgeFunctionTestData, ResponseError, EdgeFunctionTestVariables>(
(vars) => testEdgeFunction(vars),
{
async onSuccess(data, variables, context) {
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to test edge function: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
+15 -6
View File
@@ -1,19 +1,28 @@
import * as Sentry from '@sentry/nextjs'
import createClient from 'openapi-fetch'
import { IS_PLATFORM } from 'common'
import { API_URL } from 'lib/constants'
import { getAccessToken } from 'lib/gotrue'
import { uuidv4 } from 'lib/helpers'
import createClient from 'openapi-fetch'
import { ResponseError } from 'types'
import type { paths } from './api' // generated from openapi-typescript
import { IS_PLATFORM } from 'common'
const DEFAULT_HEADERS = {
Accept: 'application/json',
const DEFAULT_HEADERS = { Accept: 'application/json' }
export const fetchHandler: typeof fetch = async (input, init) => {
try {
return await fetch(input, init)
} catch (err: any) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
throw new Error('Unable to reach the server. Please check your network or try again later.')
}
throw err
}
}
// This file will eventually replace what we currently have in lib/fetchWrapper, but will be currently unused until we get to that refactor
const client = createClient<paths>({
fetch: fetchHandler,
// [Joshen] Just FYI, the replace is temporary until we update env vars API_URL to remove /platform or /v1 - should just be the base URL
baseUrl: API_URL?.replace('/platform', ''),
referrerPolicy: 'no-referrer-when-downgrade',
@@ -1,12 +1,15 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { fetchHandler } from 'data/fetchers'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { miscKeys } from './keys'
export async function getCLIReleaseVersion() {
try {
const data = await fetch(`${BASE_PATH}/api/cli-release-version`).then((res) => res.json())
const data = await fetchHandler(`${BASE_PATH}/api/cli-release-version`).then((res) =>
res.json()
)
return data as { current?: string; latest?: string; beta?: string; published_at?: string }
} catch (error) {
throw error
@@ -1,15 +1,16 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import type { ResponseError } from 'types'
import { miscKeys } from './keys'
import { COUNTRY_LAT_LON } from 'components/interfaces/ProjectCreation/ProjectCreation.constants'
import { getDistanceLatLonKM } from 'lib/helpers'
import {
AWS_REGIONS_COORDINATES,
FLY_REGIONS_COORDINATES,
} from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import { fetchHandler } from 'data/fetchers'
import { getDistanceLatLonKM } from 'lib/helpers'
import type { CloudProvider } from 'shared-data'
import { AWS_REGIONS, FLY_REGIONS } from 'shared-data'
import type { ResponseError } from 'types'
import { miscKeys } from './keys'
const RESTRICTED_POOL = [
'EAST_US_2',
@@ -32,7 +33,9 @@ export async function getDefaultRegionOption({
if (!cloudProvider) throw new Error('Cloud provider is required')
try {
const data = await fetch('https://www.cloudflare.com/cdn-cgi/trace').then((res) => res.text())
const data = await fetchHandler('https://www.cloudflare.com/cdn-cgi/trace').then((res) =>
res.text()
)
const locationCode: keyof typeof COUNTRY_LAT_LON = Object.fromEntries(
data.split('\n').map((item) => item.split('='))
)['loc']
@@ -1,12 +1,13 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { fetchHandler } from 'data/fetchers'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { miscKeys } from './keys'
export async function getUserIPAddress() {
try {
const data = await fetch(`${BASE_PATH}/api/get-ip-address`).then((res) => res.json())
const data = await fetchHandler(`${BASE_PATH}/api/get-ip-address`).then((res) => res.json())
return data.ipAddress
} catch (error) {
throw error
+2 -1
View File
@@ -1,9 +1,10 @@
import { fetchHandler } from 'data/fetchers'
import type { Integration } from 'data/integrations/integrations.types'
import { ResponseError, type SupaResponse } from 'types'
import { isResponseOk } from './common/fetch'
async function fetchGitHub<T = any>(url: string, responseJson = true): Promise<SupaResponse<T>> {
const response = await fetch(url)
const response = await fetchHandler(url)
if (!response.ok) {
return {
error: new ResponseError(response.statusText, response.status),