A solid API error strategy gives you:
- Consistent JSON error shapes
- Correct HTTP status codes
- Separation of concerns (domain vs transport)
- Observability without leaking internals
Below is a practical, production-ready approach that covers controller hooks, controllers, models/libs, background jobs, and more—illustrated with a real scenario from Session::CouponCode.
Core principles
- Keep transport (HTTP, JSON) in controllers; keep domain logic in models/libs.
- Map known, expected failures to specific HTTP statuses.
- Log unexpected failures; return a generic message to clients.
- Centralize API error rendering in a base controller.
1) A single error boundary for all API controllers
Create a base Error::ApiError and rescue it (plus a safe catch‑all) in your ApiController.
# lib/error/api_error.rb
module Error
class ApiError < StandardError
attr_reader :status, :details
def initialize(message, status = :unprocessable_entity, details: nil)
super(message)
@status = status
@details = details
end
end
end
# app/controllers/api_controller.rb
class ApiController < ActionController::Base
include LocaleConcern
skip_forgery_protection
impersonates :user,
......
# Specific handlers first
rescue_from Error::ApiError, with: :handle_api_error
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from ActiveRecord::RecordInvalid, with: :handle_unprocessable
rescue_from ActiveRecord::RecordNotUnique, with: :handle_conflict
# Catch‑all last
rescue_from StandardError, with: :handle_standard_error
private
def handle_api_error(e)
render json: { success: false, error: e.message, details: e.details }, status: e.status
end
def handle_bad_request(e)
render json: { success: false, error: e.message }, status: :bad_request
end
def handle_not_found(_e)
render json: { success: false, error: 'Not found' }, status: :not_found
end
def handle_unprocessable(e)
render json: { success: false, error: e.record.errors.full_messages }, status: :unprocessable_entity
end
def handle_conflict(_e)
render json: { success: false, error: 'Conflict' }, status: :conflict
end
def handle_standard_error(e)
Rollbar.error(e, path: request.fullpath, client_id: try(:current_client)&.id)
render json: { success: false, error: 'Something went wrong' }, status: :internal_server_error
end
end
- Order matters. Specific
rescue_frombeforeStandardError. - This pattern avoids duplicating
rescue_fromacross controllers and keeps HTML controllers unaffected.
2) Errors in before actions
Because before_action runs inside controllers, the same rescue_from handlers apply.
Two patterns:
- Render in the hook for simple guard clauses:
before_action :require_current_client
def require_current_client
return if current_client
render json: { success: false, error: 'require_login' }, status: :unauthorized
end
- Raise a domain/auth error and let
rescue_fromhandle JSON:
# lib/error/unauthorized_error.rb
module Error
class UnauthorizedError < Error::ApiError
def initialize(message = 'require_login') = super(message, :unauthorized)
end
end
before_action :require_current_client
def require_current_client
raise Error::UnauthorizedError unless current_client
end
Prefer raising if you want consistent global handling and logging.
3) Errors inside controllers
Use explicit renders for happy-path control flow; raise for domain failures:
def create
form = CreateThingForm.new(params.require(:thing).permit(:name))
result = CreateThing.new(form: form).call
if result.success?
render json: { success: true, thing: result.thing }, status: :created
else
# Known domain failure → raise an ApiError to map to 422
raise Error::ApiError.new(result.message, :unprocessable_entity, details: result.details)
end
end
Common controller exceptions (auto-mapped above):
ActionController::ParameterMissing→ 400ActiveRecord::RecordNotFound→ 404ActiveRecord::RecordInvalid→ 422ActiveRecord::RecordNotUnique→ 409
4) Errors in models, services, and libs
Do not call render here. Either:
- Return a result object (Success/Failure), or
- Raise a domain‑specific exception that the controller maps to an HTTP response.
Example from our scenario, Session::CouponCode:
# lib/error/session/coupon_code_error.rb
module Error
module Session
class CouponCodeError < Error::ApiError; end
end
end
# lib/session/coupon_code.rb
class Session::CouponCode
def discount_dollars
# ...
case
when coupon_code.gift_card?
# ...
when coupon_code.discount_code?
# ...
when coupon_code.multiorder_discount_code?
# ...
else
raise Error::Session::CouponCodeError, 'Unrecognized discount code'
end
end
end
Then, in ApiController, the specific handler (or the Error::ApiError handler) renders JSON with a 422.
This preserves separation: models/libs raise; controllers decide HTTP.
5) Other important surfaces
- ActiveJob / Sidekiq
- Prefer
retry_on,discard_on, and job‑level rescue with logging. - Return no HTTP here; jobs are async.
class MyJob < ApplicationJob
retry_on Net::OpenTimeout, wait: 10.seconds, attempts: 3
discard_on Error::ApiError
rescue_from(StandardError) { |e| Rollbar.error(e) }
end
- Mailers
- Use
rescue_fromto avoid bubble‑ups crashing deliveries:
class ApplicationMailer < ActionMailer::Base
rescue_from Postmark::InactiveRecipientError, Postmark::InvalidEmailRequestError do
# no-op / log
end
end
- Routing / 404
- For APIs, keep 404 mapping at the controller boundary with
rescue_from ActiveRecord::RecordNotFound. - For HTML,
config.exceptions_app = routes+ErrorsController. - Middleware / Rack
- For truly global concerns, use middleware. This is rarely necessary for controller-scoped API errors in Rails.
- Validation vs. Exceptions
- Use validations (
ActiveModel/ActiveRecord) for expected user errors. - Raise exceptions for exceptional conditions (invariants violated, external systems fail unexpectedly).
6) Observability
- Always log unexpected errors in the catch‑all (
StandardError). - Add minimal context:
client_id,request.fullpath, feature flags. - Avoid leaking stack traces or internal messages to clients. Send generic messages on 500s.
7) Testing
- Unit test domain services to ensure they raise
Error::ApiError(or return Failure). - Controller/request specs: assert status codes and JSON shapes for both happy path and error path.
- Ensure
before_actionguards either render or raise as intended.
Applying this to our scenario
/lib/session/coupon_code.rbraisesError::Session::CouponCodeErroron unknown/invalid discount values./app/controllers/api_controller.rbrescues that error and returns JSON:{ success: false, error: e.message }with a 422 (or viaError::ApiErrorbase).
This converts prior 500s into clean API responses and keeps error handling centralized.
When to generalize vs. specialize
- Keep a catch‑all
rescue_from StandardErrorinApiControllerto prevent 500s from leaking internals. - Still add specific handlers (or subclass
Error::ApiError) for known cases to control the correct status code and message. - Do not replace everything with only
StandardError—you’ll lose semantics and proper HTTP codes.
—
- Key takeaways
- Centralize API‐wide error handling in
ApiControllerusing specific handlers + a safe catch‑all. - Raise domain errors in models/libs; render JSON only in controllers.
- Map common Rails exceptions to correct HTTP statuses; log unexpected errors.
- Prefer
Error::ApiErroras a base for consistent message/status handling across the API.