mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
Running Studio locally without the incident.io env vars produces a 500 from `/api/status-page` on every fetch, plus a server-side log and a client `console.error`. Retries and the 5-minute refetch keep repeating them, so the dev console stays noisy. **Changed:** - `lib/api/status-page.ts` – when `INCIDENT_IO_WIDGET_URL` is unset outside production, return an empty (degraded, short-cached) status page instead of throwing. The missing API key / status page ID log is also limited to production. Production behavior is unchanged. - `data/platform/status-page-query.ts` – remove the client-side `console.error`, which repeated the error that React Query already exposes **Added:** - Tests for the missing widget URL in dev (empty payload, no error log) and in production (still throws) ## To test - Run Studio locally in platform mode without `INCIDENT_IO_*` set: no status page errors in the terminal or the browser console, and `/api/status-page` returns 200 with empty arrays - Support form and project creation still render normally (no status banner, status page link points to status.supabase.com) Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
236 lines
7.2 KiB
TypeScript
236 lines
7.2 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
import customContentRaw from '@/hooks/custom-content/custom-content.json'
|
|
import { InternalServerError } from '@/lib/api/apiHelpers'
|
|
import {
|
|
annotateWidget,
|
|
mergeLinkedFlags,
|
|
type ResponseIncident,
|
|
type ResponseIncidentFlags,
|
|
} from '@/lib/api/status-page.utils'
|
|
import {
|
|
WidgetResponseSchema,
|
|
type StatusPageResponse,
|
|
type WidgetResponse,
|
|
} from '@/lib/status-page/status-page.schema'
|
|
|
|
const HIDE_BANNER_FIELD_ID = '01M3D30MY0GQ1284ERHHV4DFAT'
|
|
const BANNER_LEAD_DAYS_FIELD_ID = '01M3D332BQVSDE4ZGK86X6X69V'
|
|
|
|
const WIDGET_TIMEOUT_MS = 10_000
|
|
const LINK_TIMEOUT_MS = 5_000
|
|
const INCIDENT_TIMEOUT_MS = 5_000
|
|
const MAX_RETRIES = 2
|
|
|
|
export type StatusPageResult = { data: StatusPageResponse; isDegraded: boolean }
|
|
|
|
const EMPTY_STATUS_PAGE: StatusPageResponse = {
|
|
page_title: 'Supabase Status',
|
|
page_url: 'https://status.supabase.com/',
|
|
ongoing_incidents: [],
|
|
in_progress_maintenances: [],
|
|
scheduled_maintenances: [],
|
|
}
|
|
|
|
const LinkedResponseIncidentsSchema = z.object({
|
|
incidents: z.array(z.object({ id: z.string(), linked_at: z.string() })),
|
|
})
|
|
|
|
const CustomFieldValueSchema = z.object({
|
|
value_option: z.object({ value: z.string() }).nullish(),
|
|
value_text: z.string().nullish(),
|
|
value_numeric: z.string().nullish(),
|
|
})
|
|
|
|
const ResponseIncidentSchema = z.object({
|
|
incident: z.object({
|
|
id: z.string(),
|
|
custom_field_entries: z.array(
|
|
z.object({
|
|
custom_field: z.object({ id: z.string() }),
|
|
values: z.array(CustomFieldValueSchema),
|
|
})
|
|
),
|
|
}),
|
|
})
|
|
|
|
function parseRetryAfterMs(header: string | null): number | undefined {
|
|
if (!header) return undefined
|
|
|
|
const seconds = Number(header)
|
|
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
|
|
|
|
const dateMs = Date.parse(header)
|
|
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now())
|
|
|
|
return undefined
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
}
|
|
|
|
function backoffWithJitter(attempt: number): number {
|
|
const base = 1000 * 2 ** attempt
|
|
return base / 2 + Math.random() * (base / 2)
|
|
}
|
|
|
|
// Shared by all three incident.io calls: on a 429, wait for the Retry-After hint (or an
|
|
// exponential fallback) and retry, up to MAX_RETRIES times, then return the still-429 response
|
|
// to the caller.
|
|
async function fetchWithRetry(
|
|
url: string,
|
|
init: RequestInit,
|
|
timeoutMs: number
|
|
): Promise<Response> {
|
|
for (let attempt = 0; ; attempt++) {
|
|
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
|
|
|
if (response.status !== 429 || attempt >= MAX_RETRIES) {
|
|
return response
|
|
}
|
|
|
|
const retryAfterMs = parseRetryAfterMs(response.headers.get('Retry-After'))
|
|
await sleep(Math.max(retryAfterMs ?? 0, backoffWithJitter(attempt)))
|
|
}
|
|
}
|
|
|
|
async function fetchWidget(url: string): Promise<WidgetResponse> {
|
|
const response = await fetchWithRetry(url, { cache: 'no-store' }, WIDGET_TIMEOUT_MS)
|
|
|
|
if (!response.ok) {
|
|
const retryAfter = response.headers.get('Retry-After') ?? undefined
|
|
const body = await response.text()
|
|
throw new InternalServerError(`incident.io Widget API responded with ${response.status}`, {
|
|
status: response.status,
|
|
body,
|
|
...(retryAfter !== undefined && { retryAfter }),
|
|
})
|
|
}
|
|
|
|
const json = await response.json()
|
|
const result = WidgetResponseSchema.safeParse(json)
|
|
if (!result.success) {
|
|
throw new InternalServerError('incident.io Widget API response did not match expected schema', {
|
|
issues: result.error.issues,
|
|
})
|
|
}
|
|
|
|
return result.data
|
|
}
|
|
|
|
async function fetchLinkedResponseIncidentIds(
|
|
apiKey: string,
|
|
statusPageId: string,
|
|
itemId: string
|
|
): Promise<Array<string>> {
|
|
const response = await fetchWithRetry(
|
|
`https://api.incident.io/v1/status-pages/${statusPageId}/incidents/${itemId}/response-incidents`,
|
|
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
LINK_TIMEOUT_MS
|
|
)
|
|
|
|
if (response.status === 404) return []
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`incident.io response-incidents lookup responded with ${response.status}`)
|
|
}
|
|
|
|
const json = await response.json()
|
|
const result = LinkedResponseIncidentsSchema.parse(json)
|
|
return result.incidents.map((incident) => incident.id)
|
|
}
|
|
|
|
async function fetchResponseIncident(apiKey: string, id: string): Promise<ResponseIncident> {
|
|
const response = await fetchWithRetry(
|
|
`https://api.incident.io/v2/incidents/${id}`,
|
|
{ headers: { Authorization: `Bearer ${apiKey}` } },
|
|
INCIDENT_TIMEOUT_MS
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`incident.io incident lookup responded with ${response.status}`)
|
|
}
|
|
|
|
const json = await response.json()
|
|
return ResponseIncidentSchema.parse(json).incident
|
|
}
|
|
|
|
export async function getStatusPage(): Promise<StatusPageResult> {
|
|
// Missing incident.io config is expected in local dev, so only treat it as an error in production
|
|
const isProduction = process.env.NODE_ENV === 'production'
|
|
|
|
const widgetUrl = process.env.INCIDENT_IO_WIDGET_URL
|
|
if (!widgetUrl) {
|
|
if (isProduction) throw new InternalServerError('INCIDENT_IO_WIDGET_URL is not set')
|
|
return { data: EMPTY_STATUS_PAGE, isDegraded: true }
|
|
}
|
|
|
|
const widget = await fetchWidget(widgetUrl)
|
|
|
|
const modeFieldIds = customContentRaw['status_page:visibility_field_ids'] ?? ['']
|
|
|
|
const apiKey = process.env.INCIDENT_IO_API_KEY
|
|
const statusPageId = process.env.INCIDENT_IO_STATUS_PAGE_ID
|
|
|
|
if (!apiKey || !statusPageId) {
|
|
if (isProduction) console.error('INCIDENT_IO_API_KEY or INCIDENT_IO_STATUS_PAGE_ID is not set')
|
|
return { data: annotateWidget(widget, new Map(), modeFieldIds), isDegraded: true }
|
|
}
|
|
|
|
let isDegraded = false
|
|
|
|
const allItems = [
|
|
...widget.ongoing_incidents,
|
|
...widget.in_progress_maintenances,
|
|
...widget.scheduled_maintenances,
|
|
]
|
|
|
|
const linkResults = await Promise.allSettled(
|
|
allItems.map((item) => fetchLinkedResponseIncidentIds(apiKey, statusPageId, item.id))
|
|
)
|
|
|
|
const linkedIdsByItemId = new Map<string, Array<string>>()
|
|
linkResults.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
linkedIdsByItemId.set(allItems[index].id, result.value)
|
|
} else {
|
|
isDegraded = true
|
|
}
|
|
})
|
|
|
|
const uniqueLinkedIds = Array.from(new Set(Array.from(linkedIdsByItemId.values()).flat()))
|
|
|
|
const incidentResults = await Promise.allSettled(
|
|
uniqueLinkedIds.map((id) => fetchResponseIncident(apiKey, id))
|
|
)
|
|
|
|
const incidentsById = new Map<string, ResponseIncident>()
|
|
incidentResults.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
incidentsById.set(uniqueLinkedIds[index], result.value)
|
|
} else {
|
|
isDegraded = true
|
|
}
|
|
})
|
|
|
|
const flagsById = new Map<string, ResponseIncidentFlags>()
|
|
for (const item of allItems) {
|
|
const linkedIncidents = (linkedIdsByItemId.get(item.id) ?? [])
|
|
.map((id) => incidentsById.get(id))
|
|
.filter((incident): incident is ResponseIncident => incident !== undefined)
|
|
|
|
if (linkedIncidents.length > 0) {
|
|
flagsById.set(
|
|
item.id,
|
|
mergeLinkedFlags(linkedIncidents, modeFieldIds, {
|
|
hideBannerFieldId: HIDE_BANNER_FIELD_ID,
|
|
leadDaysFieldId: BANNER_LEAD_DAYS_FIELD_ID,
|
|
})
|
|
)
|
|
}
|
|
}
|
|
|
|
return { data: annotateWidget(widget, flagsById, modeFieldIds), isDegraded }
|
|
}
|