Error Handling Patterns skill for Codex
Typed errors, one boundary, nothing swallowed. Codex separates expected failures, transient outages and bugs, then gives the codebase a few typed errors with stable codes instead of string matching. Errors are wrapped with their cause at layer boundaries, logged once where they leave the system, and turned into safe user messages with a request ID. Swallowed exceptions and log-and-rethrow chains are found and removed, and every error path gets a test.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- error-handling-patterns Installs to ~/.agents/skills/error-handling-patterns/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install error-handling-patterns The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project error-handling-patterns Installs to .agents/skills/error-handling-patterns/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/error-handling-patterns.zipunzip -o error-handling-patterns.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) error-handling-patterns PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $error-handling-patterns, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs how an application's errors are typed, propagated, wrapped, logged and shown - a small error taxonomy, expected failures versus bugs, wrapping with cause chains (Python raise from, JS Error cause, Go %w with errors.Is and errors.As), one boundary that maps errors to HTTP or CLI output, safe user-facing messages, and retries only where an error is transient. Use when asked to improve or review error handling, define custom error classes, stop swallowed exceptions, decide between throwing and returning results, map domain errors to status codes, or clean up logging of the same error at every layer.
Use it when
- Errors are swallowed, logged several times, or matched by message text
- You need custom error classes or sentinel errors for a domain
- A service needs one place that maps errors to HTTP or CLI output
- Users are seeing stack traces or internal details
Use something else when
- You need RFC 9457 problem details responses specifically: use problem-details-errors
- You are designing Rust error enums with thiserror and anyhow: use rust-error-handling
- You only need to rewrite the wording users see: use error-messages
How to check the result
Tests assert on error types or codes for each expected failure, and a forced dependency outage yields one log line with the full cause chain plus a safe response carrying a request ID.
The mistake it prevents
Logging and rethrowing at every layer, so one failure produces five log entries and nobody knows which layer actually handled it.
Error handling is right when every failure is either handled meaningfully or propagated with context, each error is logged once with enough detail to debug, users see a safe and actionable message, callers can tell expected failures apart by type rather than by parsing strings, and nothing is silently swallowed.
Before changing anything
- Read
AGENTS.mdand sample existing error code:rg -n "class \w+(Error|Exception)\b|errors\.New|fmt\.Errorf|new Error\(|catch \(|except " | head -50. - Find the boundaries where errors leave the system: HTTP handlers or middleware, CLI
main, job runners, message consumers. Most fixes belong there. - Note the project’s choice of exceptions vs result types and follow it; do not introduce a
Resultlibrary into an exception-based codebase on your own.
Classify failures first
| Class | Examples | Handling |
|---|---|---|
| Expected domain outcome | not found, validation failed, insufficient funds, conflict | Typed error or result value; mapped to a 4xx or a user message; logged at info or not at all |
| Transient infrastructure | timeout, connection reset, 503, deadlock, rate limit | Retry with backoff and jitter where the operation is idempotent; otherwise surface |
| Permanent infrastructure | auth to a dependency failed, missing config, schema mismatch | Fail fast and loudly; alert; do not retry |
| Programmer error (bug) | null dereference, broken invariant, unreachable branch | Let it crash the request or task; 500; log with stack; fix the code |
Do not catch bugs in order to “handle” them. Catching Exception or error around
business logic turns bugs into wrong results.
A small error taxonomy
Define a handful of domain error types in one module, each with a stable machine code. Callers branch on type or code, never on message text.
# errors.py
class AppError(Exception):
"""Base for expected, classified failures. Message is internal; public_message is safe."""
code = "internal"
status = 500
public_message = "Something went wrong."
class NotFound(AppError):
code, status, public_message = "not_found", 404, "The resource was not found."
class ValidationFailed(AppError):
code, status = "validation_failed", 422
def __init__(self, message: str, fields: dict[str, str]):
super().__init__(message)
self.fields = fields
self.public_message = "Some fields are invalid."
class Conflict(AppError):
code, status, public_message = "conflict", 409, "The resource was changed by someone else."
// errors.ts
export class AppError extends Error {
constructor(
message: string,
readonly code: string,
readonly status = 500,
options?: { cause?: unknown },
) {
super(message, options); // ES2022 Error cause
this.name = new.target.name;
}
}
export class NotFoundError extends AppError {
constructor(what: string, options?: { cause?: unknown }) {
super(`${what} not found`, "not_found", 404, options);
}
}
// errors.go
var ErrNotFound = errors.New("not found") // sentinel for errors.Is
type ValidationError struct{ Fields map[string]string }
func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed: %v", e.Fields) }
For Rust, use thiserror for library error enums and anyhow at application edges (see
rust-error-handling for detail).
Propagate with context, keep the cause
Wrap when crossing a layer, adding what the lower layer did not know (which order, which file). Never drop the original error.
try:
row = repo.load(order_id)
except psycopg.OperationalError as e:
raise StorageUnavailable(f"loading order {order_id}") from e # keeps __cause__
try {
await repo.load(orderId);
} catch (err) {
throw new AppError(`loading order ${orderId}`, "storage_unavailable", 503, { cause: err });
}
row, err := repo.Load(ctx, orderID)
if err != nil {
return fmt.Errorf("loading order %s: %w", orderID, err) // %w keeps the chain
}
// callers:
if errors.Is(err, ErrNotFound) { ... }
var ve *ValidationError
if errors.As(err, &ve) { ... }
Rules:
- Use
%w(Go),from e(Python),{ cause }(JS) every time you wrap.%vor string concatenation breakserrors.Isand loses the stack. - Do not wrap at every function; wrap at meaningful boundaries (repository, service, client).
- Combine independent failures with
errors.Join(Go 1.20+),ExceptionGroup(Python 3.11+) orAggregateError(JS) instead of keeping only the first.
Handle once, at the boundary
Log and translate errors in one place per entry point. Intermediate layers either handle the error completely or propagate it; they do not log and rethrow.
@app.exception_handler(AppError) # FastAPI example
async def app_error_handler(request, exc: AppError):
if exc.status >= 500:
log.error("request failed", exc_info=exc, extra={"code": exc.code, "path": request.url.path})
return JSONResponse(
status_code=exc.status,
content={"type": exc.code, "title": exc.public_message,
"request_id": request.state.request_id},
)
Unknown exceptions reach a catch-all that logs with stack trace and returns a generic 500 with a request ID. For RFC 9457 problem details response bodies, see problem-details-errors.
User-facing vs internal
| Internal message (logs) | Public message (response, UI) |
|---|---|
loading order 8812: connection refused to db-primary:5432 | “We could not load your order. Try again in a minute. (ref 7f3a)” |
| Stack trace, SQL, file paths, upstream response bodies | Never |
| Validation detail with field names | Field-level messages the user can fix |
Never put secrets, tokens, personal data or raw SQL into error messages; they end up in logs, alerts and sometimes responses. Wording of user-facing messages is covered by error-messages.
Retries
Retry only transient errors, only idempotent operations (or with idempotency keys), with exponential backoff, jitter, a cap on attempts and an overall deadline. Retrying at several layers multiplies attempts (3 x 3 x 3 = 27 calls). Pick one layer. Circuit breakers and bulkheads are in resilience-patterns.
Anti-patterns to remove
rg -n "except\s*:|except Exception\s*:\s*$" --type py -A2 | rg -n "pass|continue" # swallowed
rg -n "catch\s*\(\w*\)\s*\{\s*\}" --type ts # empty catch
rg -n "_ = err|_ = \w+\.Close\(\)" --type go # ignored errors
- Empty catch blocks or
except: pass. catch (e) { console.log(e) }and continue as if nothing happened.- Returning
null,-1or""for failures callers cannot distinguish from data. - Log-and-rethrow at every layer (the same error appears five times in logs).
- Branching on
err.message.includes("not found"). finallyblocks that throw and mask the original error.- Converting every error to 500, or every error to 400.
Workflow
- [ ] 1. Inventory boundaries and current error types
- [ ] 2. Classify failures the code can produce (table above)
- [ ] 3. Introduce or consolidate the taxonomy (few types, stable codes)
- [ ] 4. Add wrapping with cause at layer boundaries
- [ ] 5. Centralise logging and mapping at each boundary
- [ ] 6. Remove swallowed errors and duplicate logging
- [ ] 7. Test each error path (below)
Verification
- Tests assert on error type or code, not message text, for each expected failure:
pytest.raises(NotFound),await expect(p).rejects.toBeInstanceOf(NotFoundError),errors.Is(err, ErrNotFound). - A forced dependency failure (stopped database, mocked timeout) produces one log line with the full cause chain and a safe response with a request ID.
- The anti-pattern searches above return nothing new in changed files.
Output
**Taxonomy:** AppError -> NotFound(404), ValidationFailed(422), Conflict(409), StorageUnavailable(503)
**Boundary:** api/middleware/errors.py maps AppError; catch-all logs stack, returns 500 + request_id
**Removed:** 7 swallowed exceptions, 12 log-and-rethrow sites
**Wrapping added:** repos/*.py (raise ... from e), clients/payments.py
**Tests:** 9 new error-path tests asserting on type/code What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Error Handling Patterns"
short_description: "Typed errors, one boundary, nothing swallowed"
default_prompt: "$error-handling-patterns Our API returns 500 for everything - design proper error types and map them to status codes" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.