---
title: Client-Side Error Handling
description: Best practices for handling ObjectStack errors in client applications — parsing, display, retry strategies, and React patterns
---

# Client-Side Error Handling

This guide covers best practices for handling ObjectStack API errors in client applications — from parsing error responses to displaying validation errors in forms and implementing retry strategies.

<Callout type="info">
**Error Format:** All ObjectStack API errors follow the `ErrorResponseSchema` structure. See [Wire Format](/docs/guides/cheatsheets/wire-format#7-error-response-format) for the full JSON shape.
</Callout>

---

## 1. Error Response Structure

Every error response from ObjectStack follows this shape:

```typescript
interface ErrorResponse {
  error: {
    code: string;        // Machine-readable error code
    message: string;     // Human-readable message
    status: number;      // HTTP status code
    details?: Array<{
      field?: string;    // Field name (for validation errors)
      message: string;   // Detail message
      code: string;      // Detail error code
    }>;
  };
  meta: {
    requestId: string;   // Unique request identifier
    timestamp: string;   // ISO 8601 timestamp
  };
}
```

---

## 2. Parsing Errors with TypeScript

Create a typed error parser to extract structured information from API responses:

```typescript
import type { ErrorResponse } from '@objectstack/spec/api';

class ObjectStackError extends Error {
  code: string;
  status: number;
  details: ErrorResponse['error']['details'];
  requestId: string;

  constructor(response: ErrorResponse) {
    super(response.error.message);
    this.name = 'ObjectStackError';
    this.code = response.error.code;
    this.status = response.error.status;
    this.details = response.error.details;
    this.requestId = response.meta.requestId;
  }

  get isValidation(): boolean {
    return this.code === 'VALIDATION_ERROR';
  }

  get isNotFound(): boolean {
    return this.code === 'NOT_FOUND';
  }

  get isPermission(): boolean {
    return this.code === 'PERMISSION_DENIED';
  }

  get isRateLimit(): boolean {
    return this.code === 'RATE_LIMITED';
  }

  get isServerError(): boolean {
    return this.status >= 500;
  }

  /** Extract field-level errors as a map */
  get fieldErrors(): Record<string, string> {
    const errors: Record<string, string> = {};
    for (const detail of this.details ?? []) {
      if (detail.field) {
        errors[detail.field] = detail.message;
      }
    }
    return errors;
  }
}

/** Parse an API response into an ObjectStackError */
async function parseApiError(response: Response): Promise<ObjectStackError> {
  const body = await response.json() as ErrorResponse;
  return new ObjectStackError(body);
}
```

---

## 3. Displaying Validation Errors in Forms

Map field-level errors from the API response directly to form fields:

```typescript
import { useState } from 'react';

interface FormErrors {
  [fieldName: string]: string;
}

function TaskForm() {
  const [errors, setErrors] = useState<FormErrors>({});

  async function handleSubmit(data: Record<string, unknown>) {
    try {
      const response = await fetch('/api/v1/data/task', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ data }),
      });

      if (!response.ok) {
        const error = await parseApiError(response);

        if (error.isValidation) {
          // Map field errors directly to form state
          setErrors(error.fieldErrors);
          return;
        }

        throw error;
      }

      setErrors({});
      // Handle success...
    } catch (err) {
      // Handle unexpected errors
      console.error('Unexpected error:', err);
    }
  }

  return (
    <form onSubmit={(e) => { e.preventDefault(); handleSubmit(Object.fromEntries(new FormData(e.currentTarget))); }}>
      <input name="title" />
      {errors.title && <span className="error">{errors.title}</span>}

      <select name="status">
        <option value="open">Open</option>
        <option value="in_progress">In Progress</option>
      </select>
      {errors.status && <span className="error">{errors.status}</span>}

      <button type="submit">Create Task</button>
    </form>
  );
}
```

---

## 4. Handling Network Errors

Network errors (no response from server) need special handling separate from API errors:

```typescript
async function apiRequest<T>(url: string, options?: RequestInit): Promise<T> {
  let response: Response;

  try {
    response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
    });
  } catch (networkError) {
    // No response at all — network failure
    throw new ObjectStackError({
      error: {
        code: 'NETWORK_ERROR',
        message: 'Unable to reach the server. Check your connection.',
        status: 0,
      },
      meta: {
        requestId: 'local',
        timestamp: new Date().toISOString(),
      },
    });
  }

  if (!response.ok) {
    throw await parseApiError(response);
  }

  return response.json() as Promise<T>;
}
```

---

## 5. Retry Strategies by Error Category

Not all errors should be retried. Use this decision matrix:

| Error Category | Status | Retry? | Strategy |
|:---|:---|:---|:---|
| Validation errors | 400 | ❌ No | Fix input and resubmit |
| Authentication errors | 401 | ⚠️ Once | Refresh token, then retry |
| Permission errors | 403 | ❌ No | Show access denied message |
| Not found | 404 | ❌ No | Show not found message |
| Rate limited | 429 | ✅ Yes | Wait for `resetAt`, then retry |
| Server errors | 500 | ✅ Yes | Exponential backoff |
| Network errors | 0 | ✅ Yes | Exponential backoff |

```typescript
async function withRetry<T>(
  fn: () => Promise<T>,
  options: { maxRetries?: number; baseDelay?: number } = {}
): Promise<T> {
  const { maxRetries = 3, baseDelay = 1000 } = options;
  let lastError: unknown;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error;

      if (error instanceof ObjectStackError) {
        // Don't retry client errors (except rate limits)
        if (error.status >= 400 && error.status < 500 && !error.isRateLimit) {
          throw error;
        }

        // Rate limited: wait for the reset time
        if (error.isRateLimit && error.details?.[0]) {
          const resetAt = new Date((error.details[0] as any).resetAt).getTime();
          const waitMs = Math.max(resetAt - Date.now(), 1000);
          await sleep(waitMs);
          continue;
        }
      }

      // Exponential backoff for server/network errors
      if (attempt < maxRetries) {
        const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
        await sleep(delay);
      }
    }
  }

  throw lastError;
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
```

---

## 6. React Error Boundary Pattern

Catch unexpected errors at the component tree level:

```typescript
import { Component, type ReactNode } from 'react';

interface ErrorBoundaryState {
  error: ObjectStackError | Error | null;
}

class ObjectStackErrorBoundary extends Component<
  { children: ReactNode; fallback?: (error: Error) => ReactNode },
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { error: null };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { error };
  }

  componentDidCatch(error: Error) {
    // Log to analytics
    logErrorToAnalytics(error);
  }

  render() {
    const { error } = this.state;

    if (error) {
      if (this.props.fallback) {
        return this.props.fallback(error);
      }

      if (error instanceof ObjectStackError) {
        if (error.isPermission) {
          return <AccessDeniedPage />;
        }
        if (error.isNotFound) {
          return <NotFoundPage />;
        }
      }

      return <GenericErrorPage error={error} />;
    }

    return this.props.children;
  }
}
```

---

## 7. Toast Notification Patterns

Show non-blocking error notifications for background operations:

```typescript
import { toast } from 'your-toast-library';

function showErrorToast(error: ObjectStackError) {
  const config = getToastConfig(error);
  toast[config.type](config.message, { duration: config.duration });
}

function getToastConfig(error: ObjectStackError) {
  switch (error.code) {
    case 'VALIDATION_ERROR':
      return {
        type: 'warning' as const,
        message: `Please fix ${Object.keys(error.fieldErrors).length} field error(s)`,
        duration: 5000,
      };
    case 'PERMISSION_DENIED':
      return {
        type: 'error' as const,
        message: 'You don\'t have permission to perform this action',
        duration: 4000,
      };
    case 'RATE_LIMITED':
      return {
        type: 'warning' as const,
        message: 'Too many requests. Please wait a moment.',
        duration: 3000,
      };
    case 'NETWORK_ERROR':
      return {
        type: 'error' as const,
        message: 'Connection lost. Retrying...',
        duration: 0, // persistent until resolved
      };
    default:
      return {
        type: 'error' as const,
        message: error.message || 'An unexpected error occurred',
        duration: 5000,
      };
  }
}
```

---

## 8. Error Logging to Analytics

Track errors for monitoring and debugging:

```typescript
interface ErrorLogEntry {
  code: string;
  message: string;
  status: number;
  requestId: string;
  timestamp: string;
  url: string;
  userId?: string;
  metadata?: Record<string, unknown>;
}

function logErrorToAnalytics(error: unknown) {
  if (error instanceof ObjectStackError) {
    const entry: ErrorLogEntry = {
      code: error.code,
      message: error.message,
      status: error.status,
      requestId: error.requestId,
      timestamp: new Date().toISOString(),
      url: window.location.href,
      userId: getCurrentUserId(),
    };

    // Send to your analytics provider
    analytics.track('api_error', entry);

    // Log server errors with higher severity
    if (error.isServerError) {
      analytics.track('server_error', {
        ...entry,
        severity: 'high',
      });
    }
  } else {
    // Unexpected non-API error
    analytics.track('client_error', {
      message: error instanceof Error ? error.message : String(error),
      stack: error instanceof Error ? error.stack : undefined,
      timestamp: new Date().toISOString(),
      url: window.location.href,
    });
  }
}
```

<Callout type="tip">
**Request ID:** Always log the `requestId` from error responses. This lets you correlate client errors with server logs for debugging.
</Callout>
