Skip to content

Commit ec26943

Browse files
authored
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
1 parent 81dd47a commit ec26943

32 files changed

Lines changed: 1444 additions & 30 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
---
2+
name: studio-error-handling
3+
description: Error display and troubleshooting pattern for Supabase Studio. Use when
4+
rendering API errors in the UI, adding inline troubleshooting steps for a new
5+
error type, or wiring up the AI assistant debug button from an error state.
6+
---
7+
8+
# Studio Error Handling Pattern
9+
10+
Full docs and code examples: `apps/studio/components/interfaces/ErrorHandling/README.md`
11+
12+
## How it works
13+
14+
Classification happens in the **data layer**: `handleError` in `data/fetchers.ts` tests the error message against `ERROR_PATTERNS` and throws the matching error subclass (e.g. `ConnectionTimeoutError extends ResponseError`). The component (`ErrorMatcher`) reads `errorType` from the instance and does an O(1) lookup — it never does regex matching.
15+
16+
```
17+
handleError() → throws ConnectionTimeoutError → React Query catches → ErrorMatcher reads errorType → renders troubleshooting
18+
```
19+
20+
## Key files
21+
22+
| File | Purpose |
23+
| ------------------------------------- | ---------------------------------------------------------------- |
24+
| `data/error-patterns.ts` | Array of `{ pattern, ErrorClass }` — the regex lives here |
25+
| `types/api-errors.ts` | Error classes, `KnownErrorType` union, `ClassifiedError` type |
26+
| `ErrorMatcher.tsx` | Component — reads `errorType`, looks up mapping, renders |
27+
| `error-mappings.tsx` | `Record<KnownErrorType, { id, Troubleshooting: ComponentType }>` |
28+
| `errorMappings/ConnectionTimeout.tsx` | Reference troubleshooting component |
29+
| `TroubleshootingSections.tsx` | Reusable accordion section components |
30+
| `TroubleshootingAccordion.tsx` | Accordion wrapper with telemetry |
31+
32+
## Usage
33+
34+
Pass the **full error object** from React Query — not `error.message`:
35+
36+
```tsx
37+
{
38+
isError && (
39+
<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{ projectRef }} />
40+
)
41+
}
42+
```
43+
44+
## What NOT to do
45+
46+
- Do not pass `error.message` to `ErrorMatcher` — pass the full `error` object so the class is preserved.
47+
- Do not put regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`.
48+
- Do not use `Object.assign` to stamp `errorType` — throw a proper subclass instead.
49+
- Do not pass a raw URL string for support — use `supportFormParams={{ projectRef }}`.
50+
- Do not put the page title inside the error mapping — it belongs on the `<ErrorMatcher>` caller.
51+
- Do not add callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead.

apps/design-system/config/docs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ export const docsConfig: DocsConfig = {
167167
href: '/docs/fragments/data-input',
168168
items: [],
169169
},
170+
{
171+
title: 'Error Display',
172+
href: '/docs/fragments/error-display',
173+
items: [],
174+
},
170175
{
171176
title: 'Form Item Layout',
172177
href: '/docs/fragments/form-item-layout',
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
title: Error Display
3+
description: A card component for surfacing API errors with optional troubleshooting steps and a support link.
4+
fragment: true
5+
---
6+
7+
ErrorDisplay renders a styled error card with a warning header, a monospace error message block, an optional children slot for inline troubleshooting content, and a "Contact support" footer link that is always shown.
8+
9+
<ComponentPreview name="error-display-demo" peekCode wide />
10+
11+
Use ErrorDisplay as the base for any inline error state in the dashboard. For errors with known patterns, wire up [`ErrorMatcher`](https://github.com/supabase/supabase/tree/master/apps/studio/components/interfaces/ErrorHandling) on top to automatically inject matching troubleshooting steps.
12+
13+
## Usage
14+
15+
```tsx
16+
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
17+
```
18+
19+
```tsx
20+
<ErrorDisplay
21+
title="Failed to load tables"
22+
errorMessage="ERROR: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
23+
supportFormParams={{ projectRef: 'my-project' }}
24+
/>
25+
```
26+
27+
## Examples
28+
29+
### With troubleshooting steps
30+
31+
Pass any content as `children` — typically a `TroubleshootingAccordion` — to render inline troubleshooting between the error message and the support footer.
32+
33+
<ComponentPreview name="error-display-with-children" peekCode wide />
34+
35+
## Props
36+
37+
| Prop | Type | Default | Description |
38+
| ------------------- | -------------------- | ------------------- | --------------------------------------------------------------------- |
39+
| `title` | `string` || Displayed in the card header next to the warning icon. |
40+
| `errorMessage` | `string` || Raw error string rendered in a monospace code block. |
41+
| `supportFormParams` | `SupportFormParams?` | `undefined` | Typed params for the support form URL. The component builds the URL. |
42+
| `supportLabel` | `string?` | `"Contact support"` | Override the support link label. |
43+
| `children` | `ReactNode` | `undefined` | Slot for troubleshooting content rendered between message and footer. |
44+
| `icon` | `ReactNode` | Warning triangle | Override the header icon. |
45+
| `onRender` | `() => void?` || Fired once on mount — use for telemetry. |
46+
| `onSupportClick` | `() => void?` || Fired when the support link is clicked — use for telemetry. |
47+
| `className` | `string?` || Extra classes on the root `Card`. |
48+
49+
### SupportFormParams
50+
51+
| Field | Type | Description |
52+
| ------------ | --------- | ----------------------------- |
53+
| `projectRef` | `string?` | Project reference slug |
54+
| `orgSlug` | `string?` | Organisation slug |
55+
| `category` | `string?` | Pre-selected support category |
56+
| `subject` | `string?` | Pre-filled subject line |
57+
| `message` | `string?` | Pre-filled message body |
58+
| `error` | `string?` | Raw error string for context |
59+
| `sid` | `string?` | Sentry event ID |
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
2+
3+
export default function ErrorDisplayDemo() {
4+
return (
5+
<ErrorDisplay
6+
title="Failed to load tables"
7+
errorMessage="ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
8+
supportFormParams={{ projectRef: 'my-project' }}
9+
/>
10+
)
11+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ErrorDisplay } from 'ui-patterns/ErrorDisplay'
2+
3+
export default function ErrorDisplayWithChildren() {
4+
return (
5+
<ErrorDisplay
6+
title="Failed to load tables"
7+
errorMessage="ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
8+
supportFormParams={{ projectRef: 'my-project' }}
9+
>
10+
<div className="px-4 py-3 text-sm text-foreground-light border-b border-default">
11+
Troubleshooting steps would appear here — e.g. a{' '}
12+
<code className="font-mono text-xs">TroubleshootingAccordion</code>.
13+
</div>
14+
</ErrorDisplay>
15+
)
16+
}

apps/design-system/registry/examples.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,6 +1462,18 @@ export const examples: Registry = [
14621462
type: 'docs:example',
14631463
files: ['example/expanding-textarea-demo.tsx'],
14641464
},
1465+
{
1466+
name: 'error-display-demo',
1467+
type: 'components:example',
1468+
registryDependencies: ['error-display'],
1469+
files: ['example/error-display-demo.tsx'],
1470+
},
1471+
{
1472+
name: 'error-display-with-children',
1473+
type: 'components:example',
1474+
registryDependencies: ['error-display'],
1475+
files: ['example/error-display-with-children.tsx'],
1476+
},
14651477
{
14661478
name: 'logs-bar-chart',
14671479
type: 'components:example',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { render, screen } from '@testing-library/react'
2+
import { ConnectionTimeoutError } from 'types/api-errors'
3+
import { ResponseError } from 'types/base'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
import { ErrorMatcher } from './ErrorMatcher'
7+
8+
vi.mock('lib/telemetry/track', () => ({ useTrack: () => vi.fn() }))
9+
vi.mock('state/ai-assistant-state', () => ({
10+
useAiAssistantStateSnapshot: () => ({ newChat: vi.fn() }),
11+
}))
12+
vi.mock('state/sidebar-manager-state', () => ({
13+
useSidebarManagerSnapshot: () => ({ openSidebar: vi.fn() }),
14+
}))
15+
vi.mock('components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider', () => ({
16+
SIDEBAR_KEYS: { AI_ASSISTANT: 'ai-assistant' },
17+
}))
18+
vi.mock('./RestartProjectDialog', () => ({
19+
RestartProjectDialog: () => null,
20+
}))
21+
22+
describe('ErrorMatcher', () => {
23+
beforeEach(() => vi.clearAllMocks())
24+
25+
it('renders the provided title and error message', () => {
26+
render(
27+
<ErrorMatcher
28+
title="Failed to load tables"
29+
error="ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT."
30+
supportFormParams={{}}
31+
/>
32+
)
33+
expect(screen.getByText('Failed to load tables')).toBeInTheDocument()
34+
expect(
35+
screen.getByText(
36+
'ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT.'
37+
)
38+
).toBeInTheDocument()
39+
})
40+
41+
it('renders troubleshooting steps for classified errors', () => {
42+
const error = new ConnectionTimeoutError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT')
43+
render(<ErrorMatcher title="Failed to load tables" error={error} supportFormParams={{}} />)
44+
expect(screen.getByText('Try restarting your project')).toBeInTheDocument()
45+
expect(screen.getByText('Try our troubleshooting guide')).toBeInTheDocument()
46+
expect(screen.getByText('Debug with AI')).toBeInTheDocument()
47+
})
48+
49+
it('renders fallback for plain ResponseError (not a classified subclass)', () => {
50+
render(
51+
<ErrorMatcher
52+
title="Failed to load tables"
53+
error={new ResponseError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT')}
54+
supportFormParams={{}}
55+
/>
56+
)
57+
expect(screen.getByText('Failed to load tables')).toBeInTheDocument()
58+
expect(screen.queryByText('Try restarting your project')).not.toBeInTheDocument()
59+
})
60+
61+
it('renders fallback with provided title for unmatched errors', () => {
62+
render(
63+
<ErrorMatcher title="Failed to load tables" error="UNKNOWN ERROR" supportFormParams={{}} />
64+
)
65+
expect(screen.getByText('Failed to load tables')).toBeInTheDocument()
66+
expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument()
67+
})
68+
69+
it('accepts error as object with message property', () => {
70+
render(
71+
<ErrorMatcher
72+
title="Failed to load tables"
73+
error={{ message: 'UNKNOWN ERROR' }}
74+
supportFormParams={{}}
75+
/>
76+
)
77+
expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument()
78+
})
79+
80+
it('builds support link with projectRef param', () => {
81+
render(
82+
<ErrorMatcher
83+
title="Failed to load tables"
84+
error="UNKNOWN ERROR"
85+
supportFormParams={{ projectRef: 'my-project' }}
86+
/>
87+
)
88+
expect(screen.getByRole('link', { name: /contact support/i })).toHaveAttribute(
89+
'href',
90+
'/support/new?projectRef=my-project'
91+
)
92+
})
93+
94+
it('builds support link with no params when supportFormParams is omitted', () => {
95+
render(<ErrorMatcher title="Failed to load tables" error="UNKNOWN ERROR" />)
96+
expect(screen.getByRole('link', { name: /contact support/i })).toHaveAttribute(
97+
'href',
98+
'/support/new'
99+
)
100+
})
101+
})
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use client'
2+
3+
import { useTrack } from 'lib/telemetry/track'
4+
import { ErrorDisplay, SupportFormParams } from 'ui-patterns/ErrorDisplay'
5+
6+
import { getMappingForError } from './ErrorMatcher.utils'
7+
8+
interface ErrorMatcherProps {
9+
title: string
10+
error: string | { message: string }
11+
supportFormParams?: SupportFormParams
12+
className?: string
13+
}
14+
15+
export function ErrorMatcher({ title, error, supportFormParams, className }: ErrorMatcherProps) {
16+
const track = useTrack()
17+
18+
const message = typeof error === 'string' ? error : error.message
19+
const mapping = getMappingForError(error)
20+
const Troubleshooting = mapping?.Troubleshooting
21+
22+
return (
23+
<ErrorDisplay
24+
title={title}
25+
errorMessage={message}
26+
supportFormParams={supportFormParams}
27+
className={className}
28+
onRender={() => {
29+
track('dashboard_error_created', {
30+
source: 'error_display',
31+
errorType: mapping?.id,
32+
hasTroubleshooting: !!mapping,
33+
})
34+
if (mapping) {
35+
track('inline_error_troubleshooter_exposed', { errorType: mapping.id })
36+
}
37+
}}
38+
onSupportClick={
39+
mapping
40+
? () =>
41+
track('inline_error_troubleshooter_action_clicked', {
42+
errorType: mapping.id,
43+
ctaType: 'contact_support',
44+
})
45+
: undefined
46+
}
47+
>
48+
{Troubleshooting && <Troubleshooting />}
49+
</ErrorDisplay>
50+
)
51+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ConnectionTimeoutError, UnknownAPIResponseError } from 'types/api-errors'
2+
import { describe, expect, it } from 'vitest'
3+
4+
import { getMappingForError } from './ErrorMatcher.utils'
5+
6+
describe('getMappingForError', () => {
7+
it('returns the mapping for a classified error with a known errorType', () => {
8+
const error = new ConnectionTimeoutError('connection terminated due to connection timeout')
9+
const mapping = getMappingForError(error)
10+
expect(mapping).not.toBeNull()
11+
expect(mapping?.id).toBe('connection-timeout')
12+
})
13+
14+
it('returns null for UnknownAPIResponseError (no troubleshooting guide)', () => {
15+
const error = new UnknownAPIResponseError('something went wrong')
16+
expect(getMappingForError(error)).toBeNull()
17+
})
18+
19+
it('returns null for a plain string', () => {
20+
expect(getMappingForError('some error message')).toBeNull()
21+
})
22+
23+
it('returns null for null', () => {
24+
expect(getMappingForError(null)).toBeNull()
25+
})
26+
27+
it('returns null for an object with no errorType', () => {
28+
expect(getMappingForError({ message: 'error' })).toBeNull()
29+
})
30+
31+
it('returns null for an object with an unrecognised errorType', () => {
32+
expect(getMappingForError({ errorType: 'not-a-real-type' })).toBeNull()
33+
})
34+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ResponseError } from 'types/base'
2+
3+
import { ERROR_MAPPINGS, type ErrorMapping } from './error-mappings'
4+
5+
export function getMappingForError(error: unknown): ErrorMapping | null {
6+
const isResponseError = error instanceof ResponseError
7+
if (!isResponseError) return null
8+
for (const [ErrorClass, mapping] of ERROR_MAPPINGS) {
9+
if (error instanceof ErrorClass) return mapping
10+
}
11+
return null
12+
}

0 commit comments

Comments
 (0)