---
name: forge-error-handling
description: Where to catch, where to throw, what to log, what to surface. Result types vs exceptions, sanitized client responses with request_id correlation, retries with backoff, idempotent recovery, panic on invariant violation. Contains ready-to-paste implementations. Use when writing or reviewing code paths that can fail.
license: MIT
---

# forge-error-handling

You are writing code that will fail in production. Default agent-written error handling wraps every line in a `try/catch`, swallows the error into a `console.log`, and returns `{ success: false }` to the client. Three different anti-patterns at once. This skill exists to fix all of them.

The mental model: errors are values. They have a shape, a lifecycle, a destination. They are not exceptions to escape from; they are information to route correctly.

## Quick reference (the things you must never ship)

1. `try { ... } catch (e) { console.log(e) }` swallowing the error.
2. `catch (e) { throw e }` re-throwing without adding context.
3. Sending `err.message` or `err.stack` in the client response body.
4. Wrapping every line of a function in its own try/catch.
5. Catching at the throw site AND logging at the catch site (double-log).
6. `res.status(500).json({ error: e.message })` - leaks internals.
7. Using exceptions for expected failures the caller will route on.
8. Retrying a non-idempotent operation without an idempotency key.
9. Silent retry-on-failure without an attempt count or upper bound.
10. Unhandled promise rejection that does not exit the process.

## Hard rules

### Where to catch

**1. Catch at process boundaries.** HTTP handler, message consumer, scheduled job entry point, CLI main. Inside business logic, let exceptions bubble.

```ts
// BAD: try/catch around every database call
async function getUser(id: string) {
  try {
    const user = await db.users.findById(id);
    try {
      await audit.record("user_viewed", id);
    } catch (e) {
      console.error(e);
    }
    return user;
  } catch (e) {
    console.error(e);
    return null;
  }
}

// GOOD: handle at the boundary
async function getUser(id: string) {
  const user = await db.users.findById(id);
  await audit.record("user_viewed", id);
  return user;
}

// boundary
app.get("/users/:id", async (req, res) => {
  try {
    const user = await getUser(req.params.id);
    return res.json({ data: user });
  } catch (err) {
    logger.error({ err, requestId: req.id }, "get user failed");
    return errorResponse(req, 500, "internal_error", "Something went wrong.");
  }
});
```

**2. One catch per boundary, not nested.** A handler with three nested try/catch is doing the work of one carefully written handler.

**3. Internal functions throw or return Result; they do not return null on error.** A function that returns `User | null` confuses "not found" with "failed to look up."

### Result types vs exceptions (decision logic)

**4. Use exceptions for unexpected failures. Use Result types for expected failures.**

```
Failure is expected (caller will route on it)?
  Yes → Result<T, E>  (validation, not found, permission denied, conflict)

Failure is unexpected (caller cannot recover at this layer)?
  Yes → throw         (network down, DB unreachable, invariant violation)

Failure is the program detecting impossible state?
  Yes → throw immediately, with the most specific message you can write.
        (forge-error-handling rule 19, "crash loud on invariant violation")
```

**5. The Result type:**

```ts
export type Ok<T>  = { ok: true;  value: T };
export type Err<E> = { ok: false; error: E };
export type Result<T, E> = Ok<T> | Err<E>;

export const ok  = <T>(value: T): Ok<T>  => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
```

**6. Caller switches exhaustively on the error.** Combine with `assertNever` from forge-typescript:

```ts
type CreateError =
  | { code: "validation_failed"; details: unknown }
  | { code: "customer_not_found" }
  | { code: "out_of_stock"; sku: string };

const result = await createOrder(input);
if (!result.ok) {
  switch (result.error.code) {
    case "validation_failed":    return badRequest(c, result.error.details);
    case "customer_not_found":   return notFound(c, "Customer");
    case "out_of_stock":         return unprocessable(c, "out_of_stock", { sku: result.error.sku });
    default:                     return assertNever(result.error);
  }
}
```

### Wrapping errors (preserving cause chain)

**7. `Error.cause` is the right way to wrap.** Native, supported, debuggers honor it.

```ts
// BAD: lose the original
try {
  await chargeCard(orderId);
} catch (e) {
  throw new Error("charge failed");  // stack of original lost
}

// GOOD: preserve cause
try {
  await chargeCard(orderId);
} catch (cause) {
  throw new Error(`Failed to charge for order ${orderId}`, { cause });
}
```

**8. Never catch and re-throw without adding context.** `catch (e) { throw e }` is theater.

### Logging

**9. Log at the catch site, not the throw site.** Otherwise duplicate entries when the catch site also logs.

**10. Log the full error object, not just the message.**

```ts
// BAD - loses stack, cause chain, custom fields
logger.error("charge failed: " + err.message);

// GOOD - structured, full
logger.error({ err, requestId, orderId }, "charge failed");
```

**11. Log levels mean specific things.** See forge-logging rule 8 for the level table. Never `info` for an error.

### Client-facing errors

**12. Production responses never include the raw error message or stack.** Map server errors to client-safe codes and a `request_id`. Log full detail server-side under the same `request_id`.

Use the canonical shape from forge-api-design rule 7:

```ts
// BAD
catch (e) {
  return res.status(500).json({ error: e.message, stack: e.stack });
}

// GOOD
catch (err) {
  logger.error({ err, requestId: req.id }, "charge failed");
  return errorResponse(c, 500, "internal_error", "Something went wrong.");
}
```

**13. Client-facing messages are written for humans, not engineers.** "We could not process your payment." Not "PSQL connection refused: pq: SSL is not enabled."

**14. 4xx errors do NOT log at `error` level.** They are caller's fault; pollutes the alerting signal. Use `info` or `debug` for 4xx, `error` only for actual server failures.

### Retries

**15. Retry only operations you have established are idempotent.** Charging a card without an idempotency key cannot be safely retried.

**16. Exponential backoff with jitter.**

```ts
// reference: retry helper
async function retry<T>(
  fn: () => Promise<T>,
  opts: { attempts: number; baseMs: number; maxMs: number } = { attempts: 3, baseMs: 200, maxMs: 5000 },
): Promise<T> {
  let lastErr: unknown;
  for (let i = 0; i < opts.attempts; i++) {
    try {
      return await fn();
    } catch (e) {
      lastErr = e;
      if (i === opts.attempts - 1) break;
      const backoff = Math.min(opts.baseMs * 2 ** i, opts.maxMs);
      const jitter  = backoff * Math.random() * 0.3;
      await new Promise((r) => setTimeout(r, backoff + jitter));
    }
  }
  throw lastErr;
}

// use
const order = await retry(() => stripe.orders.create(input), { attempts: 3, baseMs: 250, maxMs: 4000 });
```

**17. After max retries, do not silently drop. Surface the failure.** Enqueue to a dead-letter queue, write to an outbox, log at `error` with full context.

**18. Retry budget across a window prevents thundering herd.** A transient outage with retries amplifies load 10x without a budget.

### Invariant violations

**19. Crash early, crash loud on invariant violations.** A code path that detects "this should be impossible" throws immediately. Continuing on impossible state produces inscrutable bugs further down.

```ts
const row = result.rows[0];
if (!row) {
  // INSERT ... RETURNING returned no row - this should be impossible.
  // Crash loud.
  throw new Error("INSERT ... RETURNING returned no row");
}
```

**20. Top-level handlers catch otherwise-unhandled errors and EXIT.** Letting an unhandled rejection kill silently is the worst failure mode. Catch, log, then exit so the supervisor restarts.

```ts
// at process boundary - never recover, just log + exit
process.on("uncaughtException", (err) => {
  logger.fatal({ err }, "uncaught_exception");
  process.exit(1);
});
process.on("unhandledRejection", (reason) => {
  logger.fatal({ reason }, "unhandled_rejection");
  process.exit(1);
});
```

### Cleanup

**21. `finally` for resource cleanup, not for logging.** Close files, release locks, end transactions. Logging in `finally` runs on success too, which is usually wrong.

**22. Async cleanup needs explicit awaits.** A `finally { db.close() }` that fires off a promise without awaiting is racing.

```ts
async function withClient<T>(fn: (c: pg.Client) => Promise<T>): Promise<T> {
  const client = await pool.connect();
  try {
    return await fn(client);
  } finally {
    client.release();  // sync, OK in finally as-is
  }
}
```

### Sentry / observability

**23. Tag errors with a fingerprint for grouping.** Sentry, Datadog Errors, Honeycomb - all need a stable fingerprint. Random message inclusion (timestamps, IDs) breaks grouping; structure them as tags.

**24. Sample non-critical errors. Do not log every 4xx.** A spammy log channel becomes background noise. Alert on rates, not on individual events.

**25. Tier alerts.** `error` (look at it tomorrow) vs `critical` (page now). Without tiers, every error feels equally important until none do.

## Common AI-output patterns to reject

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `catch (e) { console.log(e) }` | Swallow + wrong logger | `logger.error({ err: e }, "context")` + rethrow or convert to Result |
| `catch (e) { throw e }` | No-op theater | Either handle, or add context with `{ cause: e }`, or let it bubble |
| `if (!user) return null` | Confuses "not found" with "failed lookup" | Result, or specific error type |
| `try { ... } catch { return false }` | Hides everything | Specific catch, structured log, return Result with error code |
| `res.json({ error: e.message })` | Leaks server internals | Canonical error shape + `request_id` |
| Throw new Error with rich message but no cause | Loses chain | `new Error(msg, { cause: original })` |
| Empty `catch {}` | Hides all errors | Explicit `// intentionally swallowed because X` with a comment, or remove |
| `unwrap()` / `result.value` without checking `result.ok` | Crashes on the unhappy path | Switch on `result.ok` first |
| Sentry init with no `beforeSend` filter | Tons of noise from health-check 404s | Filter at the SDK layer |
| Catch + log + re-throw | Double-log at every layer | Pick one: log here or let caller log |

## Language specifics

### TypeScript / Node

- `process.on("unhandledRejection")` and `process.on("uncaughtException")` log + exit.
- `AbortSignal` for cancellation. Pass through every async op that can be cancelled.
- `AggregateError` for `Promise.allSettled` failures (multiple errors at once).
- Custom error classes: `class OrderNotFoundError extends Error { constructor(public readonly id: string) { super(`Order ${id} not found`); this.name = "OrderNotFoundError"; } }`.

### Go

- Errors are values; wrap with `fmt.Errorf("%w", err)`. Compare with `errors.Is` and `errors.As`.
- Do not log AND return; double-logs at the caller. Pick one.
- `defer` for cleanup; check the error of `defer x.Close()` via a named return.

### Python

- `raise X from Y` for wrapping. Equivalent to `Error.cause`.
- `try/except Exception` at the top of a worker, not sprinkled.
- Bare `except:` is forbidden (catches `KeyboardInterrupt`).
- Use `logging.exception` inside `except` to get the traceback for free.

## Worked example: order creation, fully error-handled

```ts
type CreateError =
  | { code: "validation_failed"; details: z.ZodIssue[] }
  | { code: "customer_not_found" }
  | { code: "out_of_stock"; sku: string };

async function createOrder(rawInput: unknown): Promise<Result<Order, CreateError>> {
  const parsed = CreateOrderSchema.safeParse(rawInput);
  if (!parsed.success) {
    return err({ code: "validation_failed", details: parsed.error.issues });
  }
  const input = parsed.data;

  const customer = await db.customers.findById(input.customer_id);
  if (!customer) return err({ code: "customer_not_found" });

  for (const item of input.items) {
    const stock = await inventory.check(item.sku);
    if (stock < item.quantity) return err({ code: "out_of_stock", sku: item.sku });
  }

  // DB failure here is UNEXPECTED. Bubble as exception.
  // The boundary handler will log + return 500.
  const order = await db.orders.create(input);
  return ok(order);
}

// boundary
app.post("/v1/orders", async (c) => {
  try {
    const result = await createOrder(await c.req.json());
    if (!result.ok) {
      switch (result.error.code) {
        case "validation_failed":   return badRequest(c, result.error.details);
        case "customer_not_found":  return notFound(c, "Customer");
        case "out_of_stock":        return unprocessable(c, "out_of_stock", { sku: result.error.sku });
        default:                    return assertNever(result.error);
      }
    }
    return c.json({ data: result.value }, 201);
  } catch (err) {
    // unexpected only - log full, sanitize response
    logger.error({ err, requestId: c.get("requestId") }, "create order failed");
    return errorResponse(c, 500, "internal_error", "Something went wrong.");
  }
});
```

What this shows: Result for the three expected failure modes; DB layer throws for unexpected; one catch at the boundary; canonical error shape; exhaustive switch with `assertNever`; structured log on the unexpected path only.

## Workflow

1. **Identify the boundary.** That is the catch site.
2. **Decide expected vs unexpected for each failure.** Expected → Result. Unexpected → throw.
3. **Write the success path first.** Optimism reads faster.
4. **Add catches at the boundary, structured logging, sanitized response.**
5. **Decide retry policy and dead-letter target for each external call.**
6. **Test the failure paths.** They are the ones that break in production.

## Verification

```bash
bash skills/backend/forge-error-handling/verify/check_error_handling.sh path/to/file.ts
```

Flags: bare `catch (e) { throw e }`, `console.log` inside catch, `err.message`/`err.stack` in `.json()` or `.send()`.

## When to skip this skill

- Scripts that crash on any error and that is fine.
- Test code where failure is the point.
- Generated code from tooling you do not control.

## Related skills

- [`forge-api-design`](../forge-api-design/SKILL.md) - canonical error shape, status codes.
- [`forge-typescript`](../forge-typescript/SKILL.md) - Result type, assertNever.
- [`forge-logging`](../forge-logging/SKILL.md) - what to log, levels, correlation.
- [`forge-validation`](../forge-validation/SKILL.md) - where the validation errors come from.
