How OpenShip Implements Error Handling Across Its Monorepo Packages

OpenShip uses a centralized error hierarchy anchored in packages/core/src/errors.ts to convert every runtime failure into a predictable, machine-readable error object with consistent HTTP status codes.

The oblien/openship repository employs a layered error handling strategy that ensures every package—from database adapters to the dashboard frontend—communicates failures through a uniform interface. At the center of this architecture sits a small set of TypeScript error classes that define the shape of all operational failures, allowing the API layer to translate any thrown exception into a standardized JSON response.

The Core Error Hierarchy

All error handling logic in OpenShip stems from packages/core/src/errors.ts. This file defines the base contract that every other package either extends or consumes.

AppError Base Class

The AppError class serves as the root for all OpenShip-specific failures. It stores three critical properties:

  • message – A human-readable description of what went wrong
  • statusCode – An HTTP status code (defaults to 500)
  • code – An optional, machine-readable string identifier (e.g., NOT_FOUND)

Source: packages/core/src/errors.ts#L5-L14

Domain-Specific Subclasses

OpenShip provides semantic subclasses for common failure modes, each pre-configured with appropriate HTTP status codes and error codes:

  • NotFoundError – Returns 404 with code NOT_FOUND
  • UnauthorizedError – Returns 401 with code UNAUTHORIZED
  • ForbiddenError – Returns 403 with code FORBIDDEN
  • ValidationError – Returns 400 with code VALIDATION_ERROR
  • ConflictError – Returns 409 with code CONFLICT
  • DeployError – Returns 500 with code DEPLOY_ERROR

Source: packages/core/src/errors.ts#L16-L66

Safe Error Messaging

To prevent massive log payloads from overwhelming systems, the core package exports safeErrorMessage, a helper that trims any caught value to 2,000 characters while preserving the original message content.

Source: packages/core/src/errors.ts#L84-L89

Package-Level Error Patterns

Individual packages in the monorepo implement error handling through two primary strategies: extending AppError directly for HTTP-ready errors, or creating plain Error subclasses for domain-specific logic that gets wrapped later.

The following table illustrates how different packages implement custom errors:

Package File Custom Error Base Class
DB packages/db/src/dump.ts PkCollisionError – Signals primary-key collision during dumps Error
Adapters packages/adapters/src/system/remote-journal.ts OpInterruptedError – Aborts remote journal operations Error
Adapters packages/adapters/src/system/errors.ts SshDisconnectedError – SSH connection loss Error
Adapters packages/adapters/src/system/edge-preflight.ts EdgeConflictError – Conflicting edge configuration AppError
CLI apps/cli/src/lib/api-client.ts ApiError – Wraps HTTP status errors from API client Error
Dashboard apps/dashboard/src/lib/api/client.ts ApiError – Propagates server failures to UI Error
Email apps/email/server/src/lib/imap.ts ImapTimeoutError – IMAP server timeout Error

Sources: [packages/db/src/dump.ts](https://github.com/oblien/openship/blob/main/packages/db/src/dump.ts), [packages/adapters/src/system/remote-journal.ts](https://github.com/oblien/openship/blob/main/packages/adapters/src/system/remote-journal.ts), [packages/adapters/src/system/errors.ts](https://github.com/oblien/openship/blob/main/packages/adapters/src/system/errors.ts), [packages/adapters/src/system/edge-preflight.ts](https://github.com/oblien/openship/blob/main/packages/adapters/src/system/edge-preflight.ts)

API Boundary Translation

The apps/api package contains the critical translation layer that converts thrown errors into HTTP responses. Located in apps/api/src/lib/apiErrorHandler.ts, this middleware inspects every caught exception and applies the following logic:

import { AppError, safeErrorMessage } from "packages/core/src/errors";

export async function errorHandler(ctx: any, next: () => Promise<void>) {
  try {
    await next();
  } catch (err) {
    if (err instanceof AppError) {
      ctx.status = err.statusCode;
      ctx.body = { message: err.message, code: err.code };
    } else {
      ctx.status = 500;
      ctx.body = { message: safeErrorMessage(err) };
    }
    console.error(safeErrorMessage(err));
  }
}

Because every domain-specific error either inherits from AppError or gets wrapped into one before reaching this middleware, all HTTP responses share an identical shape:

{
  "message": "Human-readable description",
  "code": "MACHINE_READABLE_CODE"
}

This uniformity allows front-end applications—whether the Dashboard, CLI, or Email UI—to handle failures predictably by checking the HTTP status and the code field.

Practical Implementation Examples

Defining Custom Errors in Packages

When a package needs to signal a specific domain failure, it can define a plain Error subclass. In packages/db/src/dump.ts, the PkCollisionError captures table and key information:

// packages/db/src/dump.ts
export class PkCollisionError extends Error {
  constructor(public readonly table: string, public readonly key: string) {
    super(`Primary-key collision on ${table}.${key}`);
    this.name = "PkCollisionError";
  }
}

Converting Domain Errors to AppError

Services translate low-level domain errors into HTTP-ready AppError instances before propagating them upward:

import { ConflictError } from "packages/core/src/errors";
import { PkCollisionError } from "packages/db/src/dump";

async function createProject(name: string) {
  try {
    await db.insertProject(name);
  } catch (e) {
    if (e instanceof PkCollisionError) {
      throw new ConflictError(`Project name "${name}" already exists`);
    }
    throw e;
  }
}

Frontend Error Handling

Client applications import their own ApiError classes to handle server responses consistently. The Dashboard's API client demonstrates this pattern:

import { ApiError } from "apps/dashboard/src/lib/api/client";

try {
  await apiClient.createProject("my-project");
} catch (e) {
  if (e instanceof ApiError && e.code === "CONFLICT") {
    alert("A project with that name already exists.");
  } else {
    alert("Unexpected error: " + e.message);
  }
}

Summary

  • Centralized foundation: All error handling builds upon AppError in packages/core/src/errors.ts, ensuring consistent HTTP status codes and machine-readable error codes across the monorepo.
  • Flexible extension: Packages define domain-specific errors (like PkCollisionError or SshDisconnectedError) as either plain Error subclasses or AppError extensions depending on their needs.
  • Boundary translation: The API middleware in apps/api/src/lib/apiErrorHandler.ts converts any thrown error into a uniform JSON payload using safeErrorMessage for logging safety.
  • Client uniformity: Front-end applications receive predictable error shapes, enabling consistent user feedback and debugging workflows.

Frequently Asked Questions

What is the base error class in OpenShip?

The base class is AppError, defined in packages/core/src/errors.ts. It extends the native JavaScript Error class while adding statusCode and code properties to standardize HTTP responses across all packages.

How does OpenShip prevent sensitive error details from leaking in logs?

OpenShip uses the safeErrorMessage utility from packages/core/src/errors.ts, which truncates any error message to 2,000 characters. This prevents massive stack traces or accidental secret exposure while preserving diagnostic information.

Can packages define their own error types without extending AppError?

Yes. Packages often define plain Error subclasses for internal domain logic (such as PkCollisionError in packages/db/src/dump.ts). These get caught and wrapped in AppError subclasses (like ConflictError) at the service layer before reaching the API boundary.

Where does the HTTP response transformation happen?

The transformation occurs in apps/api/src/lib/apiErrorHandler.ts. This middleware checks if an error is an instance of AppError to extract the statusCode and code, otherwise falling back to a generic 500 response with a safely trimmed message.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →