Error Handling
Error Handling
Error handling patterns for both the Toast API (server-side) and the Admin panel (client-side).
Client-Side Error Handling (React)
API Calls with apiFetch
Always use the apiFetch utility from apps/admin/src/lib/api.ts for API calls. It handles API URL resolution, JSON parsing, and error extraction in one call. Never use raw fetch — it throws on non-JSON responses and requires manual error extraction and URL construction:
import { apiFetch } from '../lib/api';
// Returns { ok: true, data: T } | { ok: false, error: string }
const result = await apiFetch<Settings>('/api/settings');
if (!result.ok) {
// result.error contains a user-friendly message
// (including "API URL is not configured" if getApiUrl() returns undefined)
return { error: result.error, success: false };
}Form Submission Errors with useActionState
Use React 19's useActionState to manage form submission state (loading, error, success). The action function should catch errors and return state — never throw:
const [state, submitAction, isPending] = useActionState(
async (_prev: FormState, formData: FormData): Promise<FormState> => {
const result = await apiFetch<User>('/api/users', {
method: 'POST',
body: JSON.stringify(Object.fromEntries(formData)),
});
if (!result.ok) {
return { error: result.error, success: false };
}
return { error: null, success: true };
},
{ error: null, success: false }
);Displaying Errors Accessibly
Error messages must use role="alert" so screen readers announce them:
{state.error && (
<p role="alert" className="text-red-500">{state.error}</p>
)}Error Boundaries
Error Boundaries catch render-time errors in child components and display fallback UI instead of crashing the entire application. React 19 removes duplicate console error logs for caught errors and adds root-level error hooks (onCaughtError, onUncaughtError, onRecoverableError) for finer control over error reporting.
The ErrorBoundary component lives in packages/ui/ and is used at the route level in the root layout (apps/admin/src/routes/__root.tsx).
import { ErrorBoundary } from '@toast/ui';
// Static fallback
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<MyComponent />
</ErrorBoundary>
// Dynamic fallback with error details and retry
<ErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<div role="alert">
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
onError={(error) => console.error('Caught by boundary:', error)}
resetKeys={[router.state.location.pathname]} // router from useRouter() (TanStack Router)
>
<MyComponent />
</ErrorBoundary>Key points:
- The root layout wraps
<Outlet />with anErrorBoundarythat auto-resets on navigation (viaresetKeys) - The fallback UI uses
role="alert"for screen reader announcements - In development mode, error details are shown in a collapsible section
- Error Boundaries catch render-time, constructor, and class lifecycle method errors, but not event handler, effect, or async errors (use
apiFetch/useActionStatefor those)
Server-Side Error Handling (API)
Error Handler Middleware
All errors are processed by the global error handler in apps/api/src/middleware/error-handler.ts.
Behavior
| Environment | Response Body | Logs |
|---|---|---|
| Development | Full error + stack | Full details |
| Production | Generic message + requestId | Full details (structured) |
Response Format
{
"error": "Internal Server Error",
"message": "An unexpected error occurred",
"requestId": "abc-123-def"
}In development, also includes:
{
"error": "Error",
"message": "Specific error message",
"stack": "Error: ...\n at ...",
"requestId": "abc-123-def"
}Throwing Errors
In Controllers
Return appropriate HTTP status codes:
export async function getById(c: Context) {
const id = c.req.param('id');
const item = await contentService.findById(id);
if (!item) {
return c.json({ error: 'Not found' }, 404);
}
return c.json(item, 200);
}In Services
Throw errors for business rule violations:
export async function publish(id: string): Promise<ContentResponse> {
const content = await contentRepository.findById(id);
if (!content) {
throw new Error('Content not found');
}
if (content.status === 'published') {
throw new Error('Content is already published');
}
// ... proceed with publishing
}Validation Errors
Zod validation errors are handled automatically by @hono/zod-openapi:
// In route definition
const createRoute = createRoute({
request: {
body: {
content: {
'application/json': { schema: CreateContentSchema },
},
},
},
// ...
});
// Invalid request automatically returns 400 with validation detailsError Types
HTTP Status Codes
| Code | When to Use |
|---|---|
| 400 | Invalid request (validation failed) |
| 401 | Not authenticated |
| 403 | Authenticated but not authorized |
| 404 | Resource not found |
| 409 | Conflict (duplicate, invalid state) |
| 500 | Unexpected server error |
Custom Error Classes (Future)
For more structured error handling, consider:
// Not yet implemented, but a pattern to consider
class NotFoundError extends Error {
status = 404;
constructor(resource: string, id: string) {
super(`${resource} with id ${id} not found`);
}
}
class ConflictError extends Error {
status = 409;
constructor(message: string) {
super(message);
}
}Logging Errors
Errors are logged with structured data:
// In error-handler.ts
logger.error(
{
requestId: c.get('requestId'),
path: c.req.path,
method: c.req.method,
status: 500,
error: err.message,
stack: err.stack,
},
'Request failed'
);See logging.md for more on structured logging.
Testing Error Cases
Always test error paths:
describe('GET /api/content/:id', () => {
it('returns 404 for non-existent content', async () => {
mockFindById.mockResolvedValue(null);
const res = await app.request('/api/content/non-existent-id');
expect(res.status).toBe(404);
expect(await res.json()).toEqual({
error: 'Not found',
});
});
});