Files
supabase/apps/studio/data/handleError.test.ts
T
Jordi Enric ec26943390 feat: improve db overload debugging UX (#43564)
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
2026-03-16 11:22:30 +01:00

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')
})
})
})