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 wrongstatusCode– 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 codeNOT_FOUNDUnauthorizedError– Returns 401 with codeUNAUTHORIZEDForbiddenError– Returns 403 with codeFORBIDDENValidationError– Returns 400 with codeVALIDATION_ERRORConflictError– Returns 409 with codeCONFLICTDeployError– Returns 500 with codeDEPLOY_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 |
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
AppErrorinpackages/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
PkCollisionErrororSshDisconnectedError) as either plainErrorsubclasses orAppErrorextensions depending on their needs. - Boundary translation: The API middleware in
apps/api/src/lib/apiErrorHandler.tsconverts any thrown error into a uniform JSON payload usingsafeErrorMessagefor 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →