name: handling-errors description: Designs error handling for backend services — error taxonomy, HTTP problem+json responses (RFC 9457), retry semantics with backoff and jitter, circuit breakers, and fail-fast startup. Use when the user asks how to handle, structure, or standardize errors or exceptions in an API or service, design error responses, add retries or a circuit breaker, or fix swallowed/double-logged exceptions. Do not use for validating request input (validating-input) or for logging/metrics/tracing pipelines (instrumenting-observability).
Handling Errors
When to use / when NOT to use
- Use for: structuring errors and exceptions in backend code, API error responses, retry/circuit-breaker policy, startup failure behavior.
- Do NOT use for: request-validation rules and 400-level field errors (validating-input) or log/metric/trace plumbing (instrumenting-observability).
Core rules
- Expected failures are values, exceptional failures are exceptions. "User not found" is a normal outcome — return it. A broken DB socket is exceptional — raise it. Follow the language idiom (Result/Either in Rust/TS-fp, exceptions in Python/Java).
- Keep the taxonomy small. 4–6 error classes mapped to HTTP:
ValidationError→400,AuthError→401/403,NotFound→404,Conflict→409,RateLimited→429, everything else→500. New error types must argue their way in. - API errors are problem+json (RFC 9457).
- ✅
{"type":"https://api.example.com/errors/quota","title":"Quota exceeded","status":429,"detail":"Plan allows 100 reports/day.","instance":"/reports/123"} - ❌
{"error": "something went wrong"}or a raw stack trace.
- ✅
- Log or re-raise — never both. Handling an exception twice produces double logs and masks the real failure point. Catch only where you can act; add context and re-raise otherwise.
- ✅
except PaymentError as e: raise OrderError(order_id=id) from e - ❌
except Exception: logger.error(e); raiseat every layer.
- ✅
- User-facing messages say what to do next; internals stay in logs. ✅ "Payment declined — try another card." ❌ "psycopg2.OperationalError: connection refused at 10.0.3.7:5432".
- Retry only idempotent operations, with exponential backoff + full jitter (base 200 ms, factor 2, cap 30 s, max 5 attempts — bounded work, no thundering herd) and respect
Retry-Afterwhen present. - Wrap flaky dependencies in a circuit breaker (open after 5 consecutive failures, half-open probe after 30 s) so one dead downstream doesn't exhaust your threads.
- Fail fast at startup. Missing config, unreachable migrations, bad credentials → crash with a named cause before serving traffic. Never boot into a half-working state.
Workflow
- List the operation's failure modes; split expected vs exceptional.
- Map each to the taxonomy (rule 2) and its HTTP status; add a new class only if none fits.
- Implement handlers at the boundary layer only (HTTP middleware / job wrapper), converting taxonomy → problem+json.
- Add retry/breaker policy for each external dependency (rules 6–7).
- Validate: trigger each failure mode in a test — assert the status code, the problem+json shape, exactly one log entry per failure, and no stack trace in the response body.
Edge cases & failure modes
- Partial failure in a batch → return 207-style per-item results or a summary object; never fail the whole batch silently.
- Retryable error during a non-idempotent call → do not retry; surface it. Make the call idempotent first (idempotency keys) if retries are required.
- Error while handling an error (e.g., logger down) → last-resort handler writes to stderr and returns a static 500 body; never raise from the handler.
- Timeout vs failure ambiguity → treat timeouts as unknown-outcome: only retry with an idempotency key.
References
Deeper recipes and gotchas: see references/patterns.md