Error Handling Standards
Rails Backend
- Never rescue
Exception — rescue StandardError or more specific errors
- Use custom error classes inheriting from
StandardError for domain errors
- Service objects return Result objects, never raise for business logic failures
- Controllers rescue specific errors and render consistent JSON:
rescue ActiveRecord::RecordNotFound
render json: { error: "Resource not found", code: 404 }, status: :not_found
rescue ActiveRecord::RecordInvalid => e
render json: { error: e.message, code: 422, details: e.record.errors }, status: :unprocessable_entity
- Log all rescued exceptions with context (user_id, request_id, params)
- Use Sentry/Rollbar for error tracking — tag with environment and user
React Native
- TanStack Query error handling: use
onError callbacks and error state
- Global error boundary for unhandled React errors
- Network errors: show user-friendly messages, retry with exponential backoff
- Form validation: display inline errors from zod schemas
- Never show raw error messages/stack traces to users
- Log errors to crash reporting (Sentry, Crashlytics)
API Error Response Format
Owned by std-api-design — see the "Related, owned elsewhere" pointer at the end of this file.
This skill auto-loads on every .rb/.ts/.tsx file, so a second copy of the envelope here is
a second source of truth on nearly every task. It had already drifted from the owner on every
axis that matters — code as an integer rather than the string error code, the HTTP status in
code rather than status, details as an object rather than an array, and request_id in an
API whose keys are camelCase.
Background Jobs (Sidekiq)
- Sidekiq already retries: 25 times over ~20 days, by default. Not configuring retries is not
"no retries" — it is three weeks of them. Set
sidekiq_options retry: deliberately on every job.
retry_on does not replace Sidekiq's retries — it stacks on top of them. ActiveJob retries
first, then hands the failure back to Sidekiq for its 25. retry_on ..., attempts: 5 runs 30
attempts over ~20 days, not 5. Set the policy with sidekiq_options retry:, in one place.
retry: 0 sends the job to the Dead set (kept, visible, retryable for 6 months);
retry: false discards it (gone, no record). One character, opposite outcomes.
- Use
discard_on for permanent errors (record not found, invalid data) — 25 retries of a
guaranteed failure is noise that hides real failures.
- Never rescue-and-swallow inside a job. The exception is the retry signal: swallowing it
makes Sidekiq report success while the work never happened.
- A retried job must be idempotent — delivery is at-least-once. Derive the idempotency key
from the work, never per attempt.
- Timeouts on every outbound call. Without them there is no failure to retry — just a worker
thread held forever and a queue that stops with a green dashboard.
- Dead-set jobs must be alerted on (
sidekiq_retries_exhausted per job, death_handlers
globally). A job dying into the Dead set is work silently not happening.
- Deep guide →
references/background-jobs.md
Deep guides (read on demand, do not preload)
- Sidekiq's real defaults (25 retries /
20 days; Dead set 10k jobs / 6 months), retry: 0 vs
retry: false, the retry_on-stacks-on-Sidekiq trap, ActiveJob vs include Sidekiq::Job
(30% overhead), alerting on the Dead set, why swallowing an exception disables the retry, and
idempotency keys → references/background-jobs.md
Related, owned elsewhere — do not duplicate: the API error envelope →
../std-api-design/references/errors-rails.md; row/advisory locks behind idempotency →
../std-database/references/locking-and-timeouts.md; alert design →
../std-monitoring.
1---2name: std-error-handling3description: Error handling standards across Rails, React Native, Sidekiq, and API responses — Result objects, custom errors, retry/discard, consistent error JSON. Use when handling errors.4paths:5 - "**/*.rb"6 - "**/*.py"7 - "**/*.ts"8 - "**/*.tsx"9 - "**/*.js"10 - "**/*.jsx"11---1213# Error Handling Standards1415## Rails Backend16- Never rescue `Exception` — rescue `StandardError` or more specific errors17- Use custom error classes inheriting from `StandardError` for domain errors18- Service objects return Result objects, never raise for business logic failures19- Controllers rescue specific errors and render consistent JSON:20 ```ruby21 rescue ActiveRecord::RecordNotFound22 render json: { error: "Resource not found", code: 404 }, status: :not_found23 rescue ActiveRecord::RecordInvalid => e24 render json: { error: e.message, code: 422, details: e.record.errors }, status: :unprocessable_entity25 ```26- Log all rescued exceptions with context (user_id, request_id, params)27- Use Sentry/Rollbar for error tracking — tag with environment and user2829## React Native30- TanStack Query error handling: use `onError` callbacks and `error` state31- Global error boundary for unhandled React errors32- Network errors: show user-friendly messages, retry with exponential backoff33- Form validation: display inline errors from zod schemas34- Never show raw error messages/stack traces to users35- Log errors to crash reporting (Sentry, Crashlytics)3637## API Error Response Format3839Owned by `std-api-design` — see the "Related, owned elsewhere" pointer at the end of this file.40This skill auto-loads on every `.rb`/`.ts`/`.tsx` file, so a second copy of the envelope here is41a second source of truth on nearly every task. It had already drifted from the owner on every42axis that matters — `code` as an integer rather than the string error code, the HTTP status in43`code` rather than `status`, `details` as an object rather than an array, and `request_id` in an44API whose keys are camelCase.4546## Background Jobs (Sidekiq)47- **Sidekiq already retries: 25 times over ~20 days, by default.** Not configuring retries is not48 "no retries" — it is three weeks of them. Set `sidekiq_options retry:` deliberately on every job.49- **`retry_on` does not replace Sidekiq's retries — it stacks on top of them.** ActiveJob retries50 first, then hands the failure back to Sidekiq for its 25. `retry_on ..., attempts: 5` runs 3051 attempts over ~20 days, not 5. Set the policy with `sidekiq_options retry:`, in one place.52- `retry: 0` sends the job to the **Dead set** (kept, visible, retryable for 6 months);53 `retry: false` **discards it** (gone, no record). One character, opposite outcomes.54- Use `discard_on` for permanent errors (record not found, invalid data) — 25 retries of a55 guaranteed failure is noise that hides real failures.56- **Never rescue-and-swallow inside a job.** The exception *is* the retry signal: swallowing it57 makes Sidekiq report success while the work never happened.58- **A retried job must be idempotent** — delivery is at-least-once. Derive the idempotency key59 from the work, never per attempt.60- **Timeouts on every outbound call.** Without them there is no failure to retry — just a worker61 thread held forever and a queue that stops with a green dashboard.62- Dead-set jobs must be alerted on (`sidekiq_retries_exhausted` per job, `death_handlers`63 globally). A job dying into the Dead set is work silently not happening.64- Deep guide → `references/background-jobs.md`6566## Deep guides (read on demand, do not preload)6768- Sidekiq's real defaults (25 retries / ~20 days; Dead set 10k jobs / 6 months), `retry: 0` vs69 `retry: false`, the `retry_on`-stacks-on-Sidekiq trap, ActiveJob vs `include Sidekiq::Job`70 (~30% overhead), alerting on the Dead set, why swallowing an exception disables the retry, and71 idempotency keys → `references/background-jobs.md`7273Related, owned elsewhere — do not duplicate: the API error envelope →74`../std-api-design/references/errors-rails.md`; row/advisory locks behind idempotency →75`../std-database/references/locking-and-timeouts.md`; alert design →76`../std-monitoring`.