Error States & Recovery¶
Status: Canonical Applies to: All error surfaces across SageOx (API responses, form validation, data loading, auth, upload, destructive operations, network failures)
Purpose¶
This spec governs how SageOx communicates errors to users. It defines error classes, copy standards, recovery patterns, and visibility rules. It is a corrective design pass -- existing patterns are evaluated against Apple/Linear/Tufte standards and replaced where they fall short.
The core principle: Errors are honest, specific, and actionable. If the user can't do anything about it, suppress the error or reduce its surface.
SageOx handles sensitive data (team discussions, recordings, code context). When things go wrong, users need clarity about what happened, whether their data is safe, and what to do next. They do not need reassurance theater, vague apologies, or generic fallbacks.
Part 1: Evidence Summary¶
Backend Error Contract¶
The Go backend (apps/api-go) uses a consistent JSON envelope:
| HTTP Status | Error Code | Retryable | Meaning |
|---|---|---|---|
| 400 | invalid_request |
No | Malformed request, invalid params |
| 401 | unauthorized |
Maybe | Missing/expired auth token |
| 403 | forbidden |
No | Authenticated but not authorized |
| 404 | not_found |
No | Resource doesn't exist |
| 409 | conflict |
No | Unique constraint violation |
| 410 | gone |
No | Deprecated endpoint |
| 422 | validation_error |
No | Field-level validation failure |
| 429 | too_many_requests |
Yes | Rate limit exceeded; Retry-After header present |
| 500 | internal_error |
Maybe | Unexpected backend failure |
| 503 | service_unavailable |
Yes | Temporary disruption |
Legacy codes (NOT_FOUND, BAD_REQUEST, etc.) coexist with modern snake_case codes. Frontend error classes (APIError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, NetworkError) map status codes to typed exceptions.
Frontend Error Surfaces¶
| Surface | Count | Current Behavior |
|---|---|---|
| Toast notifications | 47+ files | 5s auto-dismiss, bottom-right, 4 types (success/error/warning/info) |
| Form validation | 17 forms | Red text below field, aria-invalid, zod schemas |
| Query error states | 52+ hooks | isError check, fallback to generic message |
| Alert components | 65 files | 4 variants (default/success/warning/danger), often inline |
| Error boundaries | 2 | Theme system, infrastructure graph |
| Banners | 5+ | Deletion, visibility change, danger zone |
| Widget errors | 1 component | WidgetError with retry button |
Current Error Message Inventory¶
Specific and actionable (15% of errors): - "Microphone permission denied. Please allow access in your browser settings." - "Invalid 2FA code" - "Name does not match. Please type the exact name to confirm." - "Unsupported file type: {filename}" - "File too large: {filename} ({size})"
Generic with recovery (60% of errors): - "Failed to load data" + Try Again button - "Failed to send invitation. Please try again." - "Upload failed" + retry - "Failed to disconnect {provider}" + error message
Generic and dead-end (25% of errors): - "An unexpected error occurred" - "An error occurred" - "Sign in failed. Please try again." - "Account creation failed. Please try again."
Part 2: Senior-Level Diagnosis¶
Keep¶
| Pattern | Why It Earns Its Place |
|---|---|
Backend error envelope ({error: {code, message, details}}) |
Clean contract. Messages are user-safe. Details carry structured field errors. |
Frontend error class hierarchy (APIError -> AuthenticationError, etc.) |
Type-safe error handling. Enables instanceof checks for routing. |
createErrorFromResponse() factory |
Centralizes HTTP-to-error mapping. One place to fix. |
Device/media permission errors in useMicrophoneTest |
Specific, actionable. Maps browser error types to human copy. |
WidgetError component |
Contained error state with retry. Right pattern for cards. |
| Error boundaries (both) | Prevent full-page crashes. Scope failure to the broken component. |
| Toast system architecture | Clean API (toast.error(title, {description, action})). Theming, accessibility, dismiss. |
| Form validation via zod + react-hook-form | Field-level errors that clear on fix. Proper ARIA. |
DeletionStatusBanner persistence |
Blocking errors that survive page refresh. Correct for destructive operations. |
Replace¶
| Pattern | Problem | Replacement |
|---|---|---|
Generic fallback "An unexpected error occurred" |
Tells user nothing. Dead-end. | Map error codes to specific copy. Fall back to "Something went wrong. Try again, or contact support if it continues." |
"Failed to X. Please try again." (40+ instances) |
Vague about cause. Same message for network timeout, 500, and 403. | Use error code to differentiate: network vs server vs permission. |
"Sign in failed. Please try again." |
Doesn't distinguish wrong password from server error. | "Invalid email or password" for 401. "Sign-in is temporarily unavailable" for 500/503. |
"Account creation failed. Please try again." |
Same problem -- cause unknown. | Map status codes to specific messages. |
| Toast for blocking errors | 5s dismiss means the error vanishes before the user reads it. | Use inline Alert for errors that block the current task. |
Raw error messages in toast descriptions (err.message, getErrorMessage(error)) |
Backend implementation details leak to UI. | Filter through toUserMessage(error) that maps codes to copy. |
"Please try again or contact support" as blanket recovery |
Often neither action will help. | Only suggest retry for retryable errors. Only suggest support for persistent failures. |
Delete¶
| Pattern | Why |
|---|---|
Two EmptyState components |
Already flagged in loading-processing-and-status.md. ui/EmptyState.tsx is deprecated. |
"Please try again later" without context |
Vague time promise. Either retry immediately or contact support. No middle ground. |
Error messages that include "Unknown error" |
If the system doesn't know what happened, say "Something went wrong" -- not "Unknown error", which sounds like a bug report. |
| Success toasts that confirm obvious actions | "Invite link copied" is a clipboard confirmation, not an error. But "Settings saved successfully" after a save button click is noise -- the absence of error is confirmation enough. Covered in loading-processing-and-status.md. |
Part 3: Canonical Error Model¶
All user-facing errors belong to exactly one of four classes. No exceptions.
Class 1: Validation Error¶
What it is: The user provided invalid input. The system cannot proceed until they fix it.
HTTP codes: 400, 422
Visibility: Inline, at the field or form level. Never a toast. Never a banner.
Persistence: Until the user fixes the input and resubmits.
Recovery: Fix the input. The form stays open. The error clears on valid input.
Copy rules: - State what's wrong with the specific field. - Never blame the user ("Invalid input" is fine; "You entered an invalid email" is not). - Include the constraint when non-obvious: "Must be at least 8 characters", "Must be a valid URL".
Examples:
| Good | Bad |
|---|---|
| "Must be a valid email address" | "Please enter a valid email address" (unnecessary "please") |
| "Password must be at least 8 characters" | "Password is too short" (doesn't say how long) |
| "Webhook URL must start with https://" | "Invalid Slack webhook URL" (doesn't say why) |
| "File exceeds 500 MB limit" | "File too large" (doesn't say the limit) |
Visual treatment: Red text below the field. aria-invalid="true". aria-describedby linking to error text. No icon needed -- position and color are sufficient.
Class 2: Operational Error¶
What it is: The user's action was valid, but the operation failed due to system conditions (network, server, dependency). The user might succeed if they try again.
HTTP codes: 429, 500, 503, network failures
Visibility: Context-dependent. See Visibility & Severity Mapping.
Persistence: Until dismissed or until the user retries.
Recovery: Retry (automatic or manual, depending on context).
Copy rules:
- Name the operation that failed.
- State whether retry is likely to help.
- For rate limits: state when they can retry (use Retry-After header).
Examples:
| Good | Bad |
|---|---|
| "Could not save webhook. Try again." | "Failed to add Slack webhook" (no recovery) |
| "Upload interrupted. Retry to continue." | "Upload failed" (no guidance) |
| "Rate limit reached. Try again in {N} seconds." | "Too many requests" (no timeline) |
| "SageOx could not reach the server. Check your connection." | "An unexpected error occurred" (useless) |
| "Could not load discussions. Try again." | "Failed to load data" (what data?) |
Visual treatment: Toast for non-blocking. Inline Alert for context-specific. Banner for page-level. See Part 6.
Class 3: Access Error¶
What it is: The user doesn't have permission, or their session has expired. Retrying won't help without a change in credentials or permissions.
HTTP codes: 401, 403
Visibility: Inline or page-level, depending on scope.
Persistence: Until the user takes corrective action (sign in, request access).
Recovery: - 401 (expired session): Redirect to sign-in. Silent if possible (auto-redirect). - 403 (permission denied): Show who to contact. Never suggest retry.
Copy rules:
- For 401: Don't show an error if you can silently redirect. If you must show one: "Session expired. Sign in to continue."
- For 403: Name the resource and the required permission. Include contact info if available (backend provides details.contact).
- Never say "access denied" without context.
Examples:
| Good | Bad |
|---|---|
| "You don't have access to this team. Contact {owner_name} to request access." | "Forbidden" |
| "Session expired. Sign in to continue." | "Unauthorized" |
| "You need the Owner role to delete this team." | "Insufficient permissions" |
Visual treatment: Full-page for 404-like scope (team/repo level). Inline Alert for feature-level (settings tab). Never a toast -- access errors don't auto-dismiss.
Class 4: Not Found¶
What it is: The requested resource doesn't exist. This is a terminal state.
HTTP codes: 404, 410
Visibility: Page-level or inline, depending on navigation context.
Persistence: Permanent until the user navigates away.
Recovery: Navigate elsewhere. Optionally offer related resources.
Copy rules: - Name the resource type that wasn't found. - Don't apologize. Don't say "Oops." - For 410 (gone): Explain that the resource was removed, not that it never existed.
Examples:
| Good | Bad |
|---|---|
| "Recording not found" | "Resource not found" (what resource?) |
| "This team no longer exists" | "404 Not Found" |
| "This endpoint has been deprecated" | "Gone" |
Visual treatment: Page-level empty state with navigation back. For inline contexts (widget, card), collapse the component and show nothing -- the absence is the message.
Part 4: Copy & Tone¶
Voice¶
SageOx error copy follows the same voice as all product copy: calm, factual, confident, brief. See brand-in-product.md.
Error-specific additions:
| Principle | Meaning |
|---|---|
| Honest | State what failed. Don't soften or euphemize. "Failed" not "couldn't complete". |
| Specific | Name the thing that failed. "Could not save webhook" not "Something went wrong". |
| Actionable | If the user can do something, say what. If they can't, don't pretend they can. |
| Brief | One sentence for the error. One sentence for recovery. Maximum two sentences total. |
Forbidden Language¶
| Banned | Why | Alternative |
|---|---|---|
| "Oops" / "Uh oh" / "Whoops" | Infantile. Undermines trust. | State the error directly. |
| "Something went wrong" (as primary message) | Says nothing. | Name the operation that failed. |
| "We're sorry" / "Sorry" | Apologizing doesn't fix the problem. | State what happened and what to do. |
| "Please try again later" | Vague time promise. "Later" when? | "Try again" (immediate) or "Contact support" (persistent). |
| "An unexpected error occurred" (as primary message) | Means "we have no idea." | Use as last-resort fallback only, with support contact. |
| "Successfully" as prefix | Covered in loading-processing-and-status.md. Not an error issue but same principle. | Suppress success. Absence of error = success. |
| "Hang tight" / "Almost there" / "Working on it" | Optimism theater. Banned in async-and-status-states checklist. | Factual status or nothing. |
| "Please" (in error messages) | Unnecessary politeness dilutes urgency. | Direct imperative: "Enter a valid email" not "Please enter a valid email". |
| "Unknown error" | Sounds like a bug report, not a product message. | "Something went wrong. Try again, or contact support." |
Error Copy Formula¶
Examples: - "Could not save webhook. Server error. Try again." - "Microphone permission denied. Allow access in browser settings." - "File exceeds 500 MB limit. Choose a smaller file." - "Session expired. Sign in to continue."
When "why" is unknown: - "Could not save webhook. Try again, or contact support if this continues."
When there's nothing the user can do: - "Could not connect to server." (no false recovery suggestion)
SageOx Name in Errors¶
Per brand-in-product.md, use the SageOx name sparingly in errors:
| Context | Use SageOx? | Example |
|---|---|---|
| Server/infrastructure error | Yes | "SageOx could not reach the server" |
| Feature-specific failure | No | "Could not save webhook" (not "SageOx could not save...") |
| Data safety reassurance | Yes | "SageOx has your recording. Processing will resume." |
Part 5: Recovery Rules¶
Recovery Decision Tree¶
Is the error retryable?
├── Yes (429, 500, 503, network)
│ ├── Is the user actively waiting?
│ │ ├── Yes → Auto-retry with backoff (max 3 attempts), then show manual retry
│ │ └── No → Show error with manual retry button
│ └── Was it a rate limit (429)?
│ └── Yes → Show countdown using Retry-After header
├── No, but user can fix it (400, 422)
│ └── Show inline validation error. Keep form/dialog open.
├── No, access issue (401, 403)
│ ├── 401 → Redirect to sign-in (silent if possible)
│ └── 403 → Show access error with contact info
└── No, terminal (404, 410)
└── Show not-found state. Offer navigation elsewhere.
Auto-Retry Rules¶
| Condition | Max Attempts | Backoff | After Exhaustion |
|---|---|---|---|
| Network failure during data fetch | 3 | 500ms, 1s, 2s | Show error with manual retry |
| Upload chunk failure | 3 | 1s, 2s, 4s | Show error, preserve upload state for resume |
| Background refresh (polling) | 3 | 2s, 4s, 8s | Stop polling, show stale data indicator |
| User-initiated action (save, delete) | 0 | N/A | Show error immediately. Don't auto-retry destructive operations. |
Manual Retry Rules¶
| Button Label | When to Use |
|---|---|
| "Try again" | Operation can be retried immediately. This is the default. |
| "Retry" | Same as "Try again". Either is acceptable; pick one per component. |
| "Reload" | Page-level refresh needed. |
| "Sign in" | Session expired. Links to auth flow. |
Never use: - "Refresh" (ambiguous -- browser refresh or data refresh?) - "Resend" for non-messaging operations - "Try again later" as a button label
Error Persistence by Surface¶
| Surface | Persistence | When It Clears |
|---|---|---|
| Toast | 5 seconds, then auto-dismiss | Timer or manual dismiss |
| Inline validation | Until input changes | User modifies the field |
| Inline Alert | Until action resolves | Successful retry or navigation |
| Banner | Survives page refresh | Resolution of the blocking condition |
| Error boundary | Until retry | User clicks "Try Again" |
| Page-level (404) | Permanent | User navigates away |
Critical Rule: Toasts Must Not Be the Only Error Surface for Blocking Errors¶
If an error prevents the user from completing their current task, it is a blocking error.
Blocking errors must use a persistent surface (inline Alert, banner, or dialog). Toasts auto-dismiss and can be missed. A user who blinks through a 5-second toast and sees no error state has no idea what happened.
Blocking error examples: save failure, delete failure, upload failure, permission denied. Non-blocking error examples: background refresh failure, clipboard copy failure, notification delivery failure.
Part 6: Visibility & Severity Mapping¶
Error Surface Selection¶
What scope does this error affect?
├── Single field → Inline validation (red text below field)
├── Single operation in a card/widget → Inline Alert inside the card
├── Current page task → Toast (non-blocking) or inline Alert (blocking)
├── Navigation-level (can't access this page) → Page-level error state
└── System-wide (server down) → Banner at top of layout
Severity-to-Surface Matrix¶
| Error Class | Non-Blocking | Blocking |
|---|---|---|
| Validation | N/A (always blocking -- form won't submit) | Inline field error |
| Operational | Toast with retry action | Inline Alert with retry button |
| Access | N/A (always blocking) | Page-level (401 redirect) or inline Alert (403) |
| Not Found | N/A (always blocking) | Page-level empty state |
Visual Hierarchy¶
Errors must never dominate the page. They inform; they don't alarm.
| Principle | Rule |
|---|---|
| Subordinate to content | Error states should not be louder than the content they describe. A failed widget shows a quiet error; it doesn't flash red. |
| Color as signal, not decoration | Red (ox-error-*) for errors. Amber (ox-warn-*) for warnings. Never red backgrounds -- red text or red left-border only. |
| One error indicator per context | A card shows one error message, not an error icon AND error text AND error border. Pick the most informative. |
| No animated errors | Errors don't pulse, bounce, or shake. Motion implies activity; errors are static states. |
Toast Configuration¶
| Parameter | Value | Rationale |
|---|---|---|
| Position | Bottom-right | Out of the way. Doesn't block content or navigation. |
| Duration | 5 seconds | Enough to read two sentences. |
| Max visible | 3 | Prevents toast storms from overwhelming the screen. |
| Error toast action | Optional retry button | {action: {label: 'Try again', onClick: retry}} |
| Stacking | Bottom-up, newest on top | Most recent error is most visible. |
Part 7: System Boundaries¶
Backend-to-Frontend Error Contract¶
The backend error response is never rendered directly in the UI. Every backend error code maps through a translation layer to user-visible copy.
Backend: {"error": {"code": "forbidden", "message": "not a member of this team"}}
↓
Frontend: createErrorFromResponse(response) → AuthorizationError
↓
UI Layer: toUserMessage(error) → "You don't have access to this team."
Hard rules:
| Rule | Enforced By |
|---|---|
Backend message field is user-safe (no stack traces, no SQL, no internal names) |
Backend code review. apierror package enforces this. |
Frontend never renders error.message directly without mapping |
toUserMessage() function (to be implemented). |
| Error codes are stable API contract; messages may change | Frontend matches on error.code, not error.message. |
details field is structured data (validation errors, contact info), never free-text |
Backend type system. |
What the Frontend Knows vs What the User Sees¶
| Frontend Knows | User Sees |
|---|---|
AuthorizationError with code forbidden |
"You don't have access to this team." |
NetworkError |
"Could not reach the server. Check your connection." |
APIError with status 500 |
"Something went wrong. Try again." |
ValidationError with details: {email: "already exists"} |
"This email is already registered." (inline, at the email field) |
NotFoundError with message "recording not found" |
"Recording not found" (page-level) |
APIError with status 429, Retry-After: 45 |
"Rate limit reached. Try again in 45 seconds." |
Error Codes the Frontend Must Handle¶
These are the error codes that require specific UI behavior beyond the default:
| Code | Required Behavior |
|---|---|
unauthorized (401) |
Redirect to sign-in. Clear session state. |
forbidden (403) |
Show access error. Check details.contact for owner info. |
validation_error (422) |
Map details to field-level errors. |
too_many_requests (429) |
Read Retry-After header. Show countdown. |
conflict (409) |
Context-specific: "already exists" for creation, "already in progress" for operations. |
gone (410) |
Show deprecation notice. No retry. |
All other codes fall through to the default operational error handler.
Part 8: Agent Rules¶
Error Display Decision Tree¶
When implementing a new feature that can fail, agents must follow this decision tree:
1. Can the operation fail?
└── No → No error handling needed
└── Yes → Continue
2. What class of error? (See Part 3)
├── Validation → Inline field error, never toast
├── Operational → Toast (non-blocking) or Alert (blocking)
├── Access → Redirect (401) or page-level Alert (403)
└── Not Found → Page-level empty state
3. Is the error blocking?
├── Yes → Persistent surface (Alert, banner, or dialog)
└── No → Toast with optional retry action
4. Is the error retryable?
├── Yes → Include retry button/action
└── No → State what the user should do instead (or nothing, if terminal)
What Agents May Use¶
| Component | When | Import |
|---|---|---|
toast.error(title, {description?, action?}) |
Non-blocking operational errors | @/components/ui/toast/useToast |
toast.warning(title, {description?}) |
Non-blocking warnings | Same |
<Alert variant="danger"> |
Blocking errors within a card/section | @/components/ui/alert |
<Alert variant="warning"> |
Blocking warnings (e.g., approaching limits) | Same |
| Form field error (zod + react-hook-form) | Validation errors | Standard form pattern |
WidgetError |
Card/widget-level data fetch failure | @/components/admin/widgets/WidgetError |
| Error boundaries | Component-level crash protection (rare, only for complex widgets) | React ErrorBoundary |
getErrorMessage(error) |
Extract message from unknown error type | @/types/api-error |
isAPIError(error), isNetworkError(error) |
Type guards for error routing | @/lib/api/errors |
What Agents Must NOT Do¶
| Forbidden | Why |
|---|---|
| Invent new error display patterns (custom modals, inline spinners for errors, etc.) | Use existing components from the list above. |
Show raw error.message from backend in UI |
Map through toUserMessage() or use error class to select copy. |
| Use toast for blocking errors (save failures, permission denials) | Toasts auto-dismiss. Blocking errors need persistent surfaces. |
| Add "Successfully" prefix to success messages | Covered in loading-processing-and-status.md. |
| Use "please" in error messages | Direct imperatives. "Enter a valid email" not "Please enter a valid email". |
| Add error handling for impossible conditions | Only handle errors that can actually occur. Don't defensively catch what can't throw. |
| Use different words for the same error class | "Failed", "Error", "Problem", "Issue" -- pick one per context and stick with it. |
| Show implementation details (status codes, error codes, stack traces) in production UI | These are for logging, not for users. |
| Auto-retry destructive operations (delete, remove, revoke) | Destructive operations fail once, then wait for explicit user retry. |
| Create "error" variants of existing components | Use <Alert variant="danger"> or toast, not a red version of the component. |
Error Copy Checklist (for agents)¶
Before shipping any error-displaying UI, verify:
- Error message names the operation that failed (not generic "An error occurred")
- Error message includes recovery action if one exists
- Blocking errors use a persistent surface (not toast)
- Validation errors are inline at the field, not in a toast or alert
- No banned language (see Part 4 forbidden list)
- Error doesn't dominate the visual hierarchy
- Error clears when the condition resolves (no stale error states)
-
aria-invalidandaria-describedbyon form fields with errors - Backend error codes are not exposed to users
- Retry is offered only for retryable errors
Cross-References¶
| Document | Relationship |
|---|---|
| Loading, Processing & Status States | Companion spec. Covers async states; this covers failure states. |
| Brand in Product | Copy voice, SageOx name usage in errors, forbidden language. |
| UI Density & Confidence | Visual restraint rules that apply to error displays. |
| Lists & Pagination | Error states in list views (empty vs error vs loading). |
| UX Lint: Async & Status States | Checklist that enforces error handling rules from both this spec and loading-processing-and-status.md. |