Client-Side Error Handling
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.
Error Format: All ObjectStack API errors follow the ErrorResponseSchema structure. See Wire Format for the full JSON shape.
1. Error Response Structure
Every error response from ObjectStack follows this shape:
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:
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:
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:
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 |
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:
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:
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:
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,
});
}
}Request ID: Always log the requestId from error responses. This lets you correlate client errors with server logs for debugging.