# How OpenShip Implements Error Handling Across Its Monorepo Packages

> Discover how OpenShip implements error handling across its monorepo using a centralized error hierarchy for machine-readable errors and consistent HTTP status codes.

- Repository: [oblien/openship](https://github.com/oblien/openship)
- Tags: internals
- Published: 2026-07-23

---

**OpenShip uses a centralized error hierarchy anchored in [`packages/core/src/errors.ts`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/packages/db/src/dump.ts) | `PkCollisionError` – Signals primary-key collision during dumps | `Error` |
| **Adapters** | [`packages/adapters/src/system/remote-journal.ts`](https://github.com/oblien/openship/blob/main/packages/adapters/src/system/remote-journal.ts) | `OpInterruptedError` – Aborts remote journal operations | `Error` |
| **Adapters** | [`packages/adapters/src/system/errors.ts`](https://github.com/oblien/openship/blob/main/packages/adapters/src/system/errors.ts) | `SshDisconnectedError` – SSH connection loss | `Error` |
| **Adapters** | [`packages/adapters/src/system/edge-preflight.ts`](https://github.com/oblien/openship/blob/main/packages/adapters/src/system/edge-preflight.ts) | `EdgeConflictError` – Conflicting edge configuration | `AppError` |
| **CLI** | [`apps/cli/src/lib/api-client.ts`](https://github.com/oblien/openship/blob/main/apps/cli/src/lib/api-client.ts) | `ApiError` – Wraps HTTP status errors from API client | `Error` |
| **Dashboard** | [`apps/dashboard/src/lib/api/client.ts`](https://github.com/oblien/openship/blob/main/apps/dashboard/src/lib/api/client.ts) | `ApiError` – Propagates server failures to UI | `Error` |
| **Email** | [`apps/email/server/src/lib/imap.ts`](https://github.com/oblien/openship/blob/main/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)](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)](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)](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)](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`](https://github.com/oblien/openship/blob/main/apps/api/src/lib/apiErrorHandler.ts), this middleware inspects every caught exception and applies the following logic:

```typescript
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**:

```json
{
  "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`](https://github.com/oblien/openship/blob/main/packages/db/src/dump.ts), the `PkCollisionError` captures table and key information:

```typescript
// 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:

```typescript
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:

```typescript
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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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`](https://github.com/oblien/openship/blob/main/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.