Error Handling

Task-oriented instructions for handling errors in routes, jobs, and across the stack.

How to handle errors in routes

Wrap route handlers in a try/catch and return a structured error response. For consistent error shapes, use a shared error formatter.

export function errorResponse(message: string, status: number, details?: unknown) {
  return Response.json({ error: message, details }, { status });
}
import { ModelNotFoundError } from '@roostjs/orm';
import { errorResponse } from '../../lib/errors';

export async function GET(request: Request, { params }) {
  try {
    const user = await User.findOrFail(params.id);
    return Response.json(user.attributes);
  } catch (error) {
    if (error instanceof ModelNotFoundError) {
      return errorResponse('User not found', 404);
    }
    console.error('GET /api/users/:id failed', error);
    return errorResponse('Internal server error', 500);
  }
}

In TanStack Start route loaders, throw a redirect() or use the error boundary pattern. Thrown non-redirect errors propagate to the nearest errorComponent.

import { createFileRoute, redirect } from '@tanstack/react-router';

export const Route = createFileRoute('/dashboard')({
  loader: async ({ context }) => {
    const user = await getCurrentUser();
    if (!user) throw redirect({ to: '/auth/login' });
    return { user };
  },
  errorComponent: ({ error }) => (
    <div>Something went wrong: {error.message}</div>
  ),
  component: DashboardPage,
});

How to handle errors in background jobs

Throw from handle() to trigger the retry flow. Implement onFailure() for cleanup after all retries are exhausted.

import { Job, MaxRetries, Backoff } from '@roostjs/queue';

@MaxRetries(3)
@Backoff('exponential')
export class ProcessPayment extends Job<{ orderId: string }> {
  async handle(): Promise<void> {
    const result = await paymentProvider.charge(this.payload.orderId);

    if (result.status === 'declined') {
      // Throw to trigger retry
      throw new Error(`Payment declined: ${result.reason}`);
    }

    if (result.status === 'error') {
      // Non-retriable error — still throw but you can inspect this.attempt
      throw new Error(`Payment provider error: ${result.code}`);
    }
  }

  async onFailure(error: Error): Promise<void> {
    // Called only after all retries are exhausted
    await Order.where('id', this.payload.orderId).first().then((order) => {
      if (order) {
        order.attributes.status = 'payment_failed';
        return order.save();
      }
    });

    console.error(`Order ${this.payload.orderId} payment permanently failed:`, error.message);
  }
}

How to log errors

Use console.error in Cloudflare Workers — logs appear in the Workers dashboard and in wrangler tail output. For structured logging, add context as additional arguments.

// Basic error log
console.error('Database query failed', error);

// Structured log with context
console.error('Payment failed', {
  orderId: payload.orderId,
  attempt: this.attempt,
  error: error.message,
  stack: error.stack,
});

// In middleware — capture request context
async function errorHandlerMiddleware(request: Request, next: Handler): Promise<Response> {
  try {
    return await next(request);
  } catch (error) {
    console.error('Unhandled request error', {
      method: request.method,
      url: request.url,
      error: error instanceof Error ? error.message : String(error),
    });
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Stream real-time logs during development with:

wrangler tail

How to create custom error responses

Define a set of typed error classes that map to HTTP status codes, then handle them in a top-level error middleware.

export class HttpError extends Error {
  constructor(
    public readonly status: number,
    message: string,
    public readonly code?: string,
  ) {
    super(message);
    this.name = 'HttpError';
  }
}

export class NotFoundError extends HttpError {
  constructor(resource: string) {
    super(404, `${resource} not found`, 'NOT_FOUND');
  }
}

export class ValidationError extends HttpError {
  constructor(public readonly fields: Record<string, string>) {
    super(422, 'Validation failed', 'VALIDATION_ERROR');
  }
}

export class ForbiddenError extends HttpError {
  constructor(action?: string) {
    super(403, action ? `Forbidden: ${action}` : 'Forbidden', 'FORBIDDEN');
  }
}
import { HttpError, ValidationError } from '../lib/errors';
import type { Handler } from '@roostjs/core';

export async function errorHandlerMiddleware(
  request: Request,
  next: Handler,
): Promise<Response> {
  try {
    return await next(request);
  } catch (error) {
    if (error instanceof ValidationError) {
      return Response.json(
        { error: error.message, code: error.code, fields: error.fields },
        { status: error.status },
      );
    }

    if (error instanceof HttpError) {
      return Response.json(
        { error: error.message, code: error.code },
        { status: error.status },
      );
    }

    console.error('Unhandled error', error);
    return Response.json({ error: 'Internal server error' }, { status: 500 });
  }
}
import { errorHandlerMiddleware } from './middleware/error-handler';

app.useMiddleware(errorHandlerMiddleware); // Register first — runs outermost

Related: @roostjs/core reference for middleware pipeline details, @roostjs/queue reference for job retry configuration.