mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
When the dashboard hits a DB connection timeout, users currently see a
raw error message with no
path forward. This PR adds an inline troubleshooting system that detects
known error types and
surfaces contextual next steps — restart the DB, read the docs, or debug
with AI.
## Changes
- New ErrorDisplay component (packages/ui-patterns) — styled error card
with a title, monospace error
block, optional troubleshooting slot, and a "Contact support" link that
always renders. Accepts
typed supportFormParams to pre-fill the support form.
- Error classification in handleError (data/fetchers.ts) — on every API
error, the message is tested
against ERROR_PATTERNS. If matched, handleError throws a typed subclass
(ConnectionTimeoutError
extends ResponseError) instead of a plain ResponseError. Stack traces
now show the exact error
class. All existing instanceof ResponseError checks continue to work.
- ErrorMatcher component — reads errorType from the thrown class
instance, does an O(1) lookup into
ERROR_MAPPINGS, and renders the matching troubleshooting accordion as
children of ErrorDisplay.
Falls back to plain ErrorDisplay for unclassified errors.
- Connection timeout mapping — first error type wired up, with three
troubleshooting steps: restart
the database, link to the docs, and "Debug with AI" (opens the AI
assistant sidebar with a
pre-filled prompt).
- Telemetry — three new typed events track when the troubleshooter is
shown, when accordion steps are
toggled, and which CTAs are clicked.
## Adding a new error type
1. Add a class to types/api-errors.ts
2. Add { pattern, ErrorClass } to data/error-patterns.ts
3. Create a troubleshooting component in errorMappings/
4. Add an entry to error-mappings.tsx
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
import { ConnectionTimeoutError, UnknownAPIResponseError } from 'types/api-errors'
|
|
import { ResponseError } from 'types/base'
|
|
import { beforeEach, 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 { handleError } = await import('./fetchers')
|
|
|
|
function throwAndCatch(error: unknown): ResponseError {
|
|
try {
|
|
handleError(error)
|
|
} catch (e) {
|
|
return e as ResponseError
|
|
}
|
|
throw new Error('handleError did not throw')
|
|
}
|
|
|
|
describe('handleError — error classification', () => {
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
describe('known patterns', () => {
|
|
it('classifies connection timeout via message field', () => {
|
|
const err = throwAndCatch({ message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' })
|
|
expect(err).toBeInstanceOf(ConnectionTimeoutError)
|
|
expect((err as ConnectionTimeoutError).errorType).toBe('connection-timeout')
|
|
})
|
|
|
|
it('classifies connection timeout via msg field', () => {
|
|
const err = throwAndCatch({ msg: 'ERROR: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' })
|
|
expect(err).toBeInstanceOf(ConnectionTimeoutError)
|
|
})
|
|
|
|
it('classification is case-insensitive', () => {
|
|
const err = throwAndCatch({ message: 'connection terminated due to connection timeout' })
|
|
expect(err).toBeInstanceOf(ConnectionTimeoutError)
|
|
})
|
|
|
|
it('classified error is still instanceof ResponseError', () => {
|
|
const err = throwAndCatch({ message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' })
|
|
expect(err).toBeInstanceOf(ResponseError)
|
|
})
|
|
})
|
|
|
|
describe('unclassified errors', () => {
|
|
it('throws UnknownAPIResponseError for unmatched messages', () => {
|
|
const err = throwAndCatch({ message: 'something went wrong' })
|
|
expect(err).toBeInstanceOf(UnknownAPIResponseError)
|
|
expect(err).toBeInstanceOf(ResponseError)
|
|
})
|
|
|
|
it('throws UnknownAPIResponseError for empty message', () => {
|
|
const err = throwAndCatch({ message: '' })
|
|
expect(err).toBeInstanceOf(UnknownAPIResponseError)
|
|
})
|
|
|
|
it('throws UnknownAPIResponseError for null', () => {
|
|
const err = throwAndCatch(null)
|
|
expect(err).toBeInstanceOf(UnknownAPIResponseError)
|
|
})
|
|
|
|
it('throws UnknownAPIResponseError for non-object', () => {
|
|
const err = throwAndCatch('raw string error')
|
|
expect(err).toBeInstanceOf(UnknownAPIResponseError)
|
|
})
|
|
})
|
|
|
|
describe('field preservation', () => {
|
|
it('preserves all ResponseError fields on classified errors', () => {
|
|
const err = throwAndCatch({
|
|
message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT',
|
|
code: 503,
|
|
requestId: 'req-abc',
|
|
retryAfter: 30,
|
|
requestPathname: '/rest/v1/table',
|
|
})
|
|
expect(err.message).toBe('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT')
|
|
expect(err.code).toBe(503)
|
|
expect(err.requestId).toBe('req-abc')
|
|
expect(err.retryAfter).toBe(30)
|
|
expect(err.requestPathname).toBe('/rest/v1/table')
|
|
})
|
|
|
|
it('preserves all ResponseError fields on unclassified errors', () => {
|
|
const err = throwAndCatch({ message: 'some error', code: 500, requestId: 'req-xyz' })
|
|
expect(err.code).toBe(500)
|
|
expect(err.requestId).toBe('req-xyz')
|
|
})
|
|
|
|
it('msg field takes priority over message field for error text', () => {
|
|
const err = throwAndCatch({ msg: 'from msg field', message: 'from message field' })
|
|
expect(err.message).toBe('from msg field')
|
|
})
|
|
})
|
|
})
|