Skip to content

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:

{
  "error": {
    "code": "not_found",
    "message": "recording not found",
    "details": null
  }
}
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

[What failed] + [Why, if known] + [What to do next]

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-invalid and aria-describedby on 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.