Error handling patterns and strategies including Rust Result/Option, API error responses, data pipeline errors, and security-aware handling.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add cosmix/loom --skill loom-error-handling --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Loom Error Handling?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/cosmix-loom-error-handling)More formats (shields.io, HTML) on the badges page.
---
name: loom-error-handling
description: Error handling patterns and strategies including Rust Result/Option, API error responses, data pipeline errors, and security-aware handling.
triggers:
- error
- exception
- try
- catch
- throw
- raise
- Result
- Option
- panic
- recover
- retry
- fallback
- graceful degradation
- circuit breaker
- error boundary
- "500"
- 4xx
- 5xx
- thiserror
- anyhow
- RFC 7807
- RFC 9457
- problem+json
- error propagation
- error context
- error messages
- stack trace
---
# Error Handling
## Overview
Cross-language error-handling reference. The recurring mistakes are not syntactic: swallowing errors, retrying non-idempotent operations, losing the cause chain when wrapping, leaking internals in API responses, and log-and-rethrow double logging. This skill leads with the decision rules an expert applies, then shows the minimum code to implement each.
## Agent Delegation
- **loom-senior-software-engineer** (Opus) — DEFAULT. Error architecture, strategy choice, secure handling (no info leak), infra resilience (retry/circuit breaker/fallback).
- **loom-software-engineer** (Sonnet) — ONLY boilerplate error types / unit tests following an established pattern.
## Error Taxonomy — Classify First
Every failure is exactly one of three kinds; the kind dictates handling:
| Kind | Meaning | Handle by |
| --- | --- | --- |
| **Expected / recoverable** | Invalid input, not-found, transient network/rate-limit | Return a typed error (`Result`/checked exception); retry only if transient AND idempotent |
| **Bug / programmer error** | Broken invariant, unreachable state, index OOB | Fail loud: `panic!`/assert/throw. Do NOT try to recover — it hides the bug |
| **Fatal / environmental** | OOM, disk full, config missing at startup | Fail fast at the boundary; crash cleanly, let the supervisor restart |
⚠ Do not model expected failures as panics/exceptions-for-control-flow, and do not swallow bugs into a recoverable path. Rust encodes this split directly: `Result` = recoverable, `panic!` = bug. In exception languages, keep the split by convention: validation → typed exception you catch; invariant break → let it propagate/crash.
## Rust: Result/Option & the anyhow-vs-thiserror Rule
**Decision rule:**
- **`thiserror`** → **libraries** and any code whose caller must *match on* and react to specific error variants. Gives typed, exhaustive enums with `#[from]` conversions.
- **`anyhow`** (or `eyre`) → **application/binary** top layers where you only need to *propagate, add context, and report*. One `anyhow::Error` type, cheap `.context()`, backtraces.
- Mixed is normal: libs export `thiserror` enums; `main`/handlers use `anyhow` and add context as errors bubble up.
```rust
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("validation failed: {0}")]
Validation(String),
#[error("resource not found: {resource_type} id={id}")]
NotFound { resource_type: String, id: String },
#[error("database error")]
Database(#[from] sqlx::Error), // free `?` conversion, preserves source
}
// anyhow at the app layer: `?` + context to build a cause chain.
use anyhow::{Context, Result};
fn process_order(id: &str) -> Result<Order> {
let order = fetch_order(id).with_context(|| format!("fetching order {id}"))?;
validate(&order).context("order validation")?;
Ok(order)
}
```
Combinators over match ladders:
- `?` propagates and auto-converts via `From`/`#[from]`. `map_err` adapts an error type; `.context()`/`.with_context()` (lazy, use for allocating messages) wrap while preserving `source()`.
- `Option`: `ok_or_else` → `Result`; `?` works on `Option` in `Option`-returning fns. Prefer `.ok_or_else(|| ...)` (lazy) over `.ok_or(expensive())`.
- ⚠ Never `.unwrap()`/`.expect()` on recoverable errors in library/production paths — that converts a handleable error into a crash. `expect("invariant: X")` is acceptable only for genuine bugs where the message documents the invariant.
## Cause Chains — Wrap, Don't Erase
Wrapping adds context; it must **preserve the original cause** so a reader can trace root cause. Every language has the idiom — use it:
| Language | Wrap-with-cause | Inspect chain |
| --- | --- | --- |
| Rust | `.with_context(...)` / `#[source]` / `#[from]` | `err.source()` / `{:?}` |
| Go | `fmt.Errorf("...: %w", err)` | `errors.Is` / `errors.As` / `Unwrap` |
| Python | `raise New(...) from err` | `__cause__` / traceback |
| JS/TS | `new Error(msg, { cause: err })` | `err.cause` |
```go
// Go: %w preserves the chain so errors.Is/As work up the stack.
if err != nil {
return fmt.Errorf("process order %s: %w", id, err)
}
// caller: if errors.Is(err, sql.ErrNoRows) { ... }
```
```python
# Python: `from err` keeps __cause__; bare `raise New(...)` LOSES the original.
try:
order = fetch_order(id)
except DatabaseError as e:
raise ServiceError("failed to process order", code="ORDER_FAILED",
details={"order_id": id}) from e # ← preserve cause
```
⚠ `%v` (Go) or f-string interpolation of an error into a *new* message string does NOT preserve the typed chain — `errors.Is`/`isinstance` checks then break. Use `%w` / `from` / `cause`.
## Anti-Patterns (the ones that actually bite)
- **Swallowing** — empty `catch {}`, bare `except:`, `if err != nil { }`, `let _ = op();`. At minimum log; usually handle or propagate. Bare `except:` also eats `KeyboardInterrupt`/`CancelledError` — always `except Exception` or narrower.
- **Log-and-rethrow** — logging an error then re-throwing it logs the same failure at every frame → noisy duplicate stacks. **Handle OR log-and-swallow OR propagate — pick one.** Rule: log where you *handle* (usually one boundary), propagate everywhere else.
- **Exceptions for control flow** — using try/except to implement normal branching is slow and hides logic; check the condition instead. (Python's EAFP is fine for genuinely exceptional cases, not for expected branches.)
- **Catch-all too early** — a broad catch near the leaf hides the specific error and its recovery opportunity. Catch narrow and near, or broad and at the boundary — not broad and deep.
- **Retrying non-idempotent ops** — see below.
- **Leaking internals** — stack traces / SQL / paths in API responses; see API section.
## Recovery Strategies
Choose by failure kind:
| Strategy | Use when | Caution |
| --- | --- | --- |
| **Retry + backoff + jitter** | Transient AND **idempotent** | Never retry a non-idempotent write without an idempotency key |
| **Fallback** | A degraded alternative exists (cache, default, secondary) | Fallback data must be safe to serve; log the degradation |
| **Circuit breaker** | Repeated failures to a dependency; prevent cascade | Tune threshold/timeout; expose state for observability |
| **Fail fast** | Bad config, missing dep at startup, unrecoverable | Crash cleanly at the boundary, don't limp on |
⚠ **Retry only idempotent operations.** GET/PUT/DELETE are typically idempotent; a raw POST "create" is not — a retried create makes duplicates. Make writes idempotent with a client-supplied idempotency key, or don't retry them. Also: retry only *transient* errors (timeout, 429, 503) — retrying a 400/permission error just wastes time and amplifies load.
**Backoff must have jitter** to avoid the thundering-herd where all clients retry in lockstep and re-synchronize the overload. `delay = min(base * 2^attempt, cap)`; then randomize (full jitter: `random(0, delay)`).
```rust
// Cap + exponential + jitter; retry only on retryable errors.
let mut attempt = 0u32;
loop {
match op().await {
Ok(v) => break Ok(v),
Err(e) if !e.is_retryable() || attempt >= max => break Err(e),
Err(_) => {
let backoff = (base_ms * 2u64.pow(attempt)).min(cap_ms);
let delay = rand::thread_rng().gen_range(0..=backoff); // full jitter
tokio::time::sleep(Duration::from_millis(delay)).await;
attempt += 1;
}
}
}
```
```python
# Circuit breaker: CLOSED → (failures ≥ threshold) → OPEN → (after timeout) → HALF_OPEN → CLOSED/OPEN
class CircuitBreaker:
def __init__(self, threshold=5, recovery_timeout=30.0):
self.threshold, self.recovery_timeout = threshold, recovery_timeout
self.state, self.failures, self.opened_at = "closed", 0, 0.0
def call(self, op):
if self.state == "open":
if time.time() - self.opened_at > self.recovery_timeout:
self.state = "half_open" # probe with a single call
else:
raise CircuitOpenError()
try:
r = op(); self.failures = 0; self.state = "closed"; return r
except Exception:
self.failures += 1
if self.failures >= self.threshold:
self.state, self.opened_at = "open", time.time()
raise
```
## API Error Responses (RFC 9457 / 7807 problem+json)
Design rules for HTTP APIs:
- **Stable machine-readable code** the client can branch on (`"code": "ORDER_NOT_FOUND"`), independent of the human `detail` message. Never make clients string-match on prose.
- **Never leak internals** — no stack traces, SQL, file paths, or exception class names in 5xx responses. Show `detail` for 4xx (client can act on it), hide it for 5xx.
- Content type `application/problem+json`; fields: `type`, `title`, `status`, `detail`, `instance` (+ your `code` and any safe extensions).
- Map error *kind* → status: validation→400, auth→401, forbidden→403, not-found→404, conflict→409, rate-limit→429, dependency down→503, bug→500.
```python
class ProblemDetails(BaseModel):
type: str = "about:blank"
title: str
status: int
code: str | None = None
detail: str | None = None
instance: str | None = None
@app.exception_handler(AppError)
async def handler(request, error: AppError):
status = {ValidationError: 400, NotFoundError: 404, ServiceError: 503}.get(type(error), 500)
problem = ProblemDetails(
title=type(error).__name__, status=status, code=error.code,
detail=str(error) if status < 500 else None, # hide internals on 5xx
instance=str(request.url.path),
)
return JSONResponse(status, problem.model_dump(exclude_none=True),
headers={"Content-Type": "application/problem+json"})
```
```rust
// Rust/axum: map variants to status; collapse internal errors to a generic 500.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, detail) = match self {
AppError::Validation(m) => (StatusCode::BAD_REQUEST, Some(m)),
AppError::NotFound { .. } => (StatusCode::NOT_FOUND, Some(self.to_string())),
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, None), // never expose
};
(status, Json(json!({ "title": status.canonical_reason(),
"status": status.as_u16(), "detail": detail }))).into_response()
}
}
```
## Security-Aware Handling
- **Production vs dev detail** — full error type + backtrace only when `ENVIRONMENT != "production"`; production returns a generic message. Gate on env, not on a debug flag a client can flip.
- **Sanitize before logging** — redact emails, SSNs, card numbers, tokens/passwords from messages *before* they hit logs. An error string built from user input can carry PII or secrets into your log store.
- **Rate-limit error logs** — a hot failure path can flood logs (cost + DoS); throttle per error-key (log first occurrence + a periodic count).
- **Uniform auth errors** — return the same 401/403 for "user not found" and "wrong password" to avoid user enumeration.
```python
SENSITIVE = [r'[\w.%+-]+@[\w.-]+\.\w{2,}', r'\b\d{3}-\d{2}-\d{4}\b',
r'\b(?:\d{4}[-\s]?){3}\d{4}\b', r'password["\']?\s*[:=]\s*\S+']
def sanitize(msg: str) -> str:
for p in SENSITIVE: msg = re.sub(p, '[REDACTED]', msg, flags=re.IGNORECASE)
return msg
```
## Data Pipeline / Batch Errors
Batch jobs need **partial-failure** handling: don't let one bad record kill 10k good ones. Collect failures with their index + payload, route to a dead-letter queue, and surface an error summary — but fail the whole job if the failure *rate* crosses a threshold (a 40% failure rate is a systemic problem, not bad records).
```python
@dataclass
class BatchResult(Generic[T]):
ok: list[T]; failed: list[dict]; summary: dict[str, int]
def process_batch(items, fn, *, continue_on_error=True) -> BatchResult:
ok, failed, counts = [], [], {}
for i, item in enumerate(items):
try:
ok.append(fn(item))
except Exception as e:
counts[type(e).__name__] = counts.get(type(e).__name__, 0) + 1
failed.append({"index": i, "item": item, "error": str(e)})
logging.error("item %d failed: %s", i, e)
if not continue_on_error: raise
return BatchResult(ok, failed, counts)
# route failed → dead_letter_queue; alert if len(failed)/len(items) > threshold
```
## Verification Checklist
- [ ] Every failure classified: recoverable (typed error) vs bug (panic/assert) vs fatal (fail-fast) — no expected failures modeled as panics, no bugs swallowed into recoverable paths
- [ ] Rust: `thiserror` for libs / matchable errors, `anyhow` for app layer; no `.unwrap()`/`.expect()` on recoverable errors
- [ ] Error wrapping preserves the cause chain (`%w` / `from` / `{ cause }` / `#[source]`) — `errors.Is`/`isinstance`/`source()` still work at the top
- [ ] No swallowed errors (empty catch, bare `except`, ignored `err`); no log-and-rethrow (log at exactly one boundary)
- [ ] Retries only on transient AND idempotent ops; non-idempotent writes use an idempotency key; backoff has jitter and a cap
- [ ] Circuit breaker / fallback where a dependency failure could cascade; breaker state observable
- [ ] API responses: stable machine `code`, correct status per kind, `application/problem+json`; NO stack traces/SQL/paths in 5xx
- [ ] Secrets/PII sanitized before logging; error logs rate-limited; auth errors uniform (no enumeration)
- [ ] Batch/pipeline: partial failures collected + dead-lettered; job fails if failure rate crosses threshold
- [ ] Exceptions are not used for normal control flow
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!