SavvySolve Docs

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:

  1. Global Error Boundaries - Catch React rendering errors at the page level
  2. tRPC Error Formatting - Transform API errors into user-friendly messages
  3. WebSocket Reliability - Auto-reconnect with message queueing
  4. WebRTC Fallbacks - Browser compatibility checks and graceful degradation
  5. 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.

app/error.tsx
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 PatternUser-Facing TitleUser-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:

components/ErrorState.tsx
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.

lib/trpc/errors.ts
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:

lib/trpc/errors.ts
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

hooks/use-ably.tsx
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:

components/ConnectionStatus.tsx
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:

hooks/use-screen-share.tsx
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

lib/db/retry.ts
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

lib/stripe/errors.ts
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 TypeCoverage
Unit testsError formatting, retry logic, message mapping
Component testsErrorState rendering, ConnectionStatus states
Hook testsuseScreenShare error flows, useAbly reconnection
Integration testsSimulated 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} />}
    </>
  );
}

On this page