mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
31a804f6d4
commit
232a26a68d
12 files changed
+193
-117
No files matched your search
+79
-96
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user