Error Handling
Comprehensive error handling architecture with user-friendly states and automatic recovery
Error Handling
SavvySolve implements a multi-layered error handling strategy that provides graceful degradation, automatic recovery, and user-friendly feedback. The goal is to never leave users stuck—every error has a clear message and an actionable next step.
This architecture addresses a key insight from the PRD: many SavvySolve users are seniors (65+) who may be less comfortable with technical error messages. Every error must be translated into plain language with a clear recovery path.
Architecture Overview
Error handling spans five distinct layers, each with specialized behavior:
- Global Error Boundaries - Catch React rendering errors at the page level
- tRPC Error Formatting - Transform API errors into user-friendly messages
- WebSocket Reliability - Auto-reconnect with message queueing
- WebRTC Fallbacks - Browser compatibility checks and graceful degradation
- Database & Payment Edge Cases - Retry transient failures, poll for webhook delays
Global Error Boundaries
Next.js 15 uses the App Router's built-in error handling with error.tsx files. The global error boundary at app/error.tsx catches any unhandled errors in page components and displays a friendly error state.
export default function ErrorPage({ error, reset }: ErrorPageProps) {
useEffect(() => {
// Log error to monitoring service
console.error("[App Error]", {
message: error.message,
digest: error.digest,
stack: error.stack,
});
}, [error]);
const { title, message } = getErrorMessage(error);
return (
<ErrorState
variant="fullscreen"
title={title}
message={message}
errorCode={error.digest}
onRetry={reset}
showHomeButton
/>
);
}The getErrorMessage function maps technical errors to plain-language explanations:
| Error Pattern | User-Facing Title | User-Facing Message |
|---|---|---|
| Network/fetch errors | "Connection Problem" | "We couldn't connect to our servers..." |
| Unauthorized/session | "Session Expired" | "Your session has expired. Please sign in again." |
| Forbidden/permission | "Access Denied" | "You don't have permission to access this page." |
| Rate limiting | "Too Many Requests" | "Please wait a moment and try again." |
| Database errors | "Service Temporarily Unavailable" | "We're experiencing technical difficulties..." |
ErrorState Component
The ErrorState component provides consistent error UI throughout the app with three variants:
export interface ErrorStateProps {
title?: string;
message?: string;
errorCode?: string;
onRetry?: () => void;
showHomeButton?: boolean;
showBackButton?: boolean;
action?: { label: string; onClick: () => void };
variant?: "default" | "compact" | "fullscreen";
}Used for page-level errors. Shows a centered error icon, title, message, and action buttons. Matches SavvySolve branding with the orange primary color.
Used for section-level errors within a page. Displays in a card with rounded corners.
Used for inline errors in lists or small components. Shows as a subtle banner with retry button.
tRPC Error Formatting
All API errors pass through a centralized error formatting system in lib/trpc/errors.ts. This ensures consistent error codes and messages across the entire API surface.
const ERROR_MESSAGES: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
UNAUTHORIZED: "Please sign in to continue.",
FORBIDDEN: "You don't have permission to perform this action.",
NOT_FOUND: "The requested resource could not be found.",
TOO_MANY_REQUESTS: "Too many requests. Please wait a moment and try again.",
INTERNAL_SERVER_ERROR: "Something went wrong on our end. Please try again later.",
// ... more mappings
};Wrapping External Errors
When database or external service errors occur, they're wrapped with appropriate tRPC codes:
export function wrapExternalError(
error: unknown,
fallbackCode: TRPC_ERROR_CODE_KEY = "INTERNAL_SERVER_ERROR",
): TRPCError {
const message = error instanceof Error ? error.message : "An unexpected error occurred";
const lowerMessage = message.toLowerCase();
if (lowerMessage.includes("not found")) {
return createError("NOT_FOUND", message, error);
}
if (lowerMessage.includes("duplicate") || lowerMessage.includes("unique constraint")) {
return createError("CONFLICT", "This record already exists.", error);
}
if (lowerMessage.includes("timeout")) {
return createError("TIMEOUT", "The operation timed out. Please try again.", error);
}
return createError(fallbackCode, message, error);
}Error Logging
All tRPC errors are logged with context for debugging:
export function logError(
error: TRPCError,
context?: { path?: string; input?: unknown; userId?: string; requestId?: string },
): void {
console.error("[tRPC Error]", {
code: error.code,
message: error.message,
path: context?.path,
userId: context?.userId,
// Input redacted in production for privacy
});
}In production, errors would be sent to a monitoring service like Sentry or LogRocket. The infrastructure is in place with logError() calls throughout.
WebSocket Reliability
Real-time features (chat, queue updates) use Ably for WebSocket connections. The useAbly hook in hooks/use-ably.tsx implements automatic reconnection with exponential backoff and message queueing.
Connection State Tracking
const [connectionState, setConnectionState] = useState<ConnectionState>("initialized");
const [connectionError, setConnectionError] = useState<ErrorInfo | null>(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);The hook tracks seven connection states: initialized, connecting, connected, disconnected, suspended, closing, closed, and failed.
Message Queueing
When disconnected, messages are queued and sent when the connection is restored:
const messageQueue = useRef<Array<{ channel: string; event: string; data: unknown }>>([]);
// On reconnection, flush the queue
useEffect(() => {
if (connectionState === "connected" && messageQueue.current.length > 0) {
const queue = [...messageQueue.current];
messageQueue.current = [];
queue.forEach(({ channel, event, data }) => {
publish(channel, event, data);
});
}
}, [connectionState]);Connection Status UI
The ConnectionStatus component provides visual feedback:
export function ConnectionStatus({ detailed = false }: ConnectionStatusProps) {
const { connectionState, connectionError, reconnectAttempts, reconnect } = useAbly();
// Shows: icon, label, reconnection attempts, and retry button
}A compact ConnectionDot variant shows just a colored dot (green/amber/red) for minimal UI footprint.
WebRTC Error Handling
Screen sharing uses WebRTC via PeerJS. The useScreenShare hook in hooks/use-screen-share.tsx provides comprehensive error handling with browser compatibility checks.
Browser Support Detection
Before attempting screen share, the hook checks browser capabilities:
const browserSupport = checkScreenShareSupport();
if (!browserSupport.isSupported) {
const supportError: ScreenShareError = {
message: browserSupport.reason || "Screen sharing not supported in this browser",
code: "NotSupportedError",
isRecoverable: false,
timestamp: new Date(),
};
setError(supportError);
throw new Error(supportError.message);
}Error Classification
Errors are classified as recoverable or not:
export interface ScreenShareError {
message: string;
code?: string;
isRecoverable: boolean;
timestamp: Date;
}Recoverable errors (network issues) trigger automatic reconnection. Non-recoverable errors (permission denied, unsupported browser) show instructions to the user.
Reconnection State
export interface ReconnectionState {
isReconnecting: boolean;
attempt: number;
maxAttempts: number;
}The UI shows reconnection progress: "Connection lost. Reconnecting (attempt 2/3)..."
Database Retry Logic
Transient database errors (deadlocks, connection timeouts) are handled with automatic retry in lib/db/retry.ts.
Retryable Error Detection
const RETRYABLE_ERROR_CODES = new Set([
"40001", // serialization_failure
"40P01", // deadlock_detected
"08006", // connection_failure
"57P01", // admin_shutdown
"53300", // too_many_connections
]);
export function isRetryableError(error: unknown): boolean {
if (error instanceof Error) {
const pgError = error as PostgresError;
if (pgError.code && RETRYABLE_ERROR_CODES.has(pgError.code)) {
return true;
}
// Also check message patterns
const message = error.message.toLowerCase();
return message.includes("timeout") || message.includes("connection refused");
}
return false;
}Exponential Backoff with Jitter
export async function withRetry<T>(
operation: () => Promise<T>,
options?: RetryOptions,
): Promise<T> {
const opts = { maxRetries: 3, baseDelay: 100, maxDelay: 5000, ...options };
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
if (!isRetryableError(error) || attempt === opts.maxRetries) {
throw error;
}
const delay = calculateDelay(attempt, opts.baseDelay, opts.maxDelay, 2);
await sleep(delay);
}
}
}Jitter (±25%) prevents thundering herd when multiple requests fail simultaneously.
Transaction Retry
For transactions, the entire transaction is retried:
export async function withRetryTransaction<T>(
fn: (client: PoolClient) => Promise<T>,
options?: RetryOptions,
): Promise<T> {
return withRetry(async () => {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await fn(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}, options);
}Payment Error Handling
Stripe payment errors are parsed into user-friendly messages in lib/stripe/errors.ts.
Error Message Mapping
const STRIPE_ERROR_MESSAGES: Record<string, string> = {
card_declined: "Your card was declined. Please try a different card.",
insufficient_funds: "Your card has insufficient funds. Please try a different card.",
expired_card: "Your card has expired. Please use a different card.",
incorrect_cvc: "The security code (CVC) is incorrect. Please check and try again.",
processing_error: "There was an error processing your card. Please try again.",
// ... 30+ more mappings
};Error Classification
export interface StripeErrorInfo {
message: string;
code?: string;
declineCode?: string;
isRetryable: boolean;
shouldTryDifferentCard: boolean;
}This tells the UI whether to show "Try Again" or "Use Different Card".
Webhook Fallback Polling
When webhooks are delayed, the app polls Stripe for payment status:
export async function pollPaymentStatusWithRetry(
paymentLinkId: string,
maxAttempts: number = 5,
intervalMs: number = 3000,
): Promise<PaymentStatusResult> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const result = await pollPaymentStatus(paymentLinkId);
if (result.status === "paid" || result.status === "failed" || result.status === "expired") {
return result;
}
if (attempt < maxAttempts - 1) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
return { status: "pending" };
}This prevents users from being stuck in a "payment pending" state when webhooks fail.
Testing Strategy
Error handling is tested at multiple levels:
| Test Type | Coverage |
|---|---|
| Unit tests | Error formatting, retry logic, message mapping |
| Component tests | ErrorState rendering, ConnectionStatus states |
| Hook tests | useScreenShare error flows, useAbly reconnection |
| Integration tests | Simulated DB errors, WebSocket disconnection |
Test files:
lib/trpc/errors.test.ts(20 tests)lib/db/retry.test.ts(24 tests)lib/stripe/errors.test.ts(27 tests)components/ErrorState.test.tsx(7 tests)components/ConnectionStatus.test.tsx(28 tests)hooks/use-screen-share.test.tsx(26 tests)
Usage Examples
Wrapping a tRPC Procedure
import { safeAsync, wrapExternalError } from "@/lib/trpc/errors";
export const myRouter = router({
create: protectedProcedure
.input(schema)
.mutation(async ({ input }) => {
return safeAsync(async () => {
// Database operation that might fail
return db.insert(table).values(input);
});
}),
});Database Operation with Retry
import { withRetry } from "@/lib/db";
const result = await withRetry(
() => db.insert(sessions).values(data).returning(),
{ operationName: "create session", maxRetries: 3 }
);Showing Connection Status in UI
import { ConnectionStatus, ConnectionDot } from "@/components/ConnectionStatus";
// Detailed status with retry button
<ConnectionStatus detailed />
// Minimal dot indicator
<header>
<ConnectionDot className="ml-2" />
</header>Handling Screen Share Errors
import { useScreenShare } from "@/hooks";
function ScreenShareButton() {
const { initializeAsSharer, error, browserSupport } = useScreenShare({
sessionId,
onError: (err) => toast.error(err.message),
});
if (!browserSupport.isSupported) {
return <p>{browserSupport.reason}</p>;
}
return (
<>
<Button onClick={() => initializeAsSharer(viewerPeerId)}>
Share Screen
</Button>
{error && <ErrorState compact message={error.message} />}
</>
);
}