playbook/outfitter-agents/plugins/outfitter-stack/skills/stack-patterns/references/errors.md

5.7 KiB

Error Taxonomy

Ten error categories that map to exit codes (CLI) and HTTP status codes (API).

Categories

Category Exit HTTP Class When to Use
validation 1 400 ValidationError Invalid input, schema failures, constraint violations
not_found 2 404 NotFoundError Resource doesn't exist
conflict 3 409 ConflictError Already exists, version mismatch, optimistic lock failure
permission 4 403 PermissionError Forbidden action, insufficient privileges
timeout 5 504 TimeoutError Operation took too long
rate_limit 6 429 RateLimitError Too many requests, quota exceeded
network 7 503 NetworkError Connection failures, DNS errors, unreachable hosts
internal 8 500 InternalError Unexpected errors, bugs, unhandled cases
auth 9 401 AuthError Authentication required, invalid credentials
cancelled 130 499 CancelledError User interrupted (Ctrl+C), operation aborted

Error Classes

All errors extend OutfitterError and have:

interface OutfitterError {
  readonly _tag: string;           // Discriminator for pattern matching
  readonly category: ErrorCategory; // One of the 10 categories
  readonly message: string;         // Human-readable message
  readonly details?: unknown;       // Additional context
}

ValidationError

import { ValidationError } from "@outfitter/contracts";

// Basic
new ValidationError("Invalid email format");

// With details
new ValidationError("Validation failed", {
  field: "email",
  value: "not-an-email",
  constraint: "email",
});

// From Zod
const result = schema.safeParse(input);
if (!result.success) {
  return Result.err(new ValidationError("Invalid input", {
    issues: result.error.issues,
  }));
}

NotFoundError

import { NotFoundError } from "@outfitter/contracts";

// Resource type and ID
new NotFoundError("user", "user-123");

// Access properties
error.resourceType;  // "user"
error.resourceId;    // "user-123"
error.message;       // "user not found: user-123"

ConflictError

import { ConflictError } from "@outfitter/contracts";

// Already exists
new ConflictError("User already exists", { email: "user@example.com" });

// Version mismatch
new ConflictError("Version mismatch", {
  expected: 5,
  actual: 7,
});

PermissionError

import { PermissionError } from "@outfitter/contracts";

new PermissionError("Cannot delete admin users", {
  action: "delete",
  resource: "user",
  resourceId: "admin-1",
});

TimeoutError

import { TimeoutError } from "@outfitter/contracts";

new TimeoutError("Database query timed out", {
  operation: "findUsers",
  timeoutMs: 5000,
});

RateLimitError

import { RateLimitError } from "@outfitter/contracts";

new RateLimitError("API rate limit exceeded", {
  limit: 100,
  window: "1m",
  retryAfter: 30,
});

NetworkError

import { NetworkError } from "@outfitter/contracts";

new NetworkError("Failed to connect to API", {
  host: "api.example.com",
  code: "ECONNREFUSED",
});

InternalError

import { InternalError } from "@outfitter/contracts";

// Wrap unexpected errors
try {
  await riskyOperation();
} catch (error) {
  return Result.err(new InternalError("Unexpected error", { cause: error }));
}

AuthError

import { AuthError } from "@outfitter/contracts";

new AuthError("Invalid API key");
new AuthError("Token expired", { expiredAt: "2024-01-01T00:00:00Z" });

CancelledError

import { CancelledError } from "@outfitter/contracts";

if (ctx.signal.aborted) {
  return Result.err(new CancelledError("Operation cancelled by user"));
}

Pattern Matching

Use _tag for type-safe error handling:

if (result.isErr()) {
  switch (result.error._tag) {
    case "ValidationError":
      console.log("Invalid input:", result.error.details);
      break;
    case "NotFoundError":
      console.log(`${result.error.resourceType} not found`);
      break;
    case "ConflictError":
      console.log("Conflict:", result.error.message);
      break;
    default:
      console.log("Error:", result.error.message);
  }
}

Exit Code Mapping

import { getExitCode } from "@outfitter/contracts";

const exitCode = getExitCode(error.category);
process.exit(exitCode);

HTTP Status Mapping

import { getStatusCode } from "@outfitter/contracts";

const status = getStatusCode(error.category);
res.status(status).json({ error: error.message });

ERROR_CODES Constant

Use ERROR_CODES for type-safe category validation and iteration:

import { ERROR_CODES, type ErrorCategory } from "@outfitter/contracts";

// ERROR_CODES is a readonly object mapping category names to exit codes
ERROR_CODES.validation;  // 1
ERROR_CODES.not_found;   // 2
ERROR_CODES.conflict;    // 3
// ... etc

// Validate a category exists
const isValidCategory = (cat: string): cat is ErrorCategory => {
  return cat in ERROR_CODES;
};

// Iterate over all categories
for (const [category, exitCode] of Object.entries(ERROR_CODES)) {
  console.log(`${category}: exit ${exitCode}`);
}

Creating Custom Errors

Extend the base classes for domain-specific errors:

import { ValidationError } from "@outfitter/contracts";

export class EmailValidationError extends ValidationError {
  constructor(email: string) {
    super("Invalid email format", { email, field: "email" });
  }
}

The category is inherited, so exit codes and HTTP status work automatically.