Return Clean Consistent Json Errors From A Rails Api Controller
An API that returns a validation error one shape, a 404 another shape, and an HTML error page for anything unhandled forces every client to write three parsers. One rescue block in the base controller fixes it permanently.
Description
Define a single error envelope and render it from rescue_from handlers in ApplicationController. Every failure β validation, not found, parameter missing, unhandled β comes back with the same keys and an appropriate status code.
Order matters: rescue_from handlers are matched bottom-up, so declare the most generic one first and the specific ones after it.
Include a reference id on 500s so a user can quote it to support, and log the full exception rather than sending it to the client.
Sample input:
POST /api/orders { "account_id": null }
Sample Output:
{
"error": {
"type": "validation_failed",
"message": "Validation failed",
"details": { "account": ["must exist"] }
}
}
Answer
class ApplicationController < ActionController::API
rescue_from StandardError, with: :internal_error
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActiveRecord::RecordInvalid, with: :validation_failed
rescue_from ActionController::ParameterMissing, with: :bad_request
rescue_from ActiveRecord::RecordNotUnique, with: :conflict
private
def render_error(type:, message:, status:, details: nil, reference: nil)
payload = { type:, message: }
payload[:details] = details if details
payload[:reference] = reference if reference
render json: { error: payload }, status:
end
def not_found(exception)
render_error(type: "not_found", message: exception.message, status: :not_found)
end
def validation_failed(exception)
render_error(
type: "validation_failed",
message: "Validation failed",
details: exception.record.errors.to_hash,
status: :unprocessable_entity
)
end
def bad_request(exception)
render_error(type: "bad_request", message: exception.message, status: :bad_request)
end
def conflict(_exception)
render_error(type: "conflict", message: "Record already exists", status: :conflict)
end
def internal_error(exception)
raise exception if Rails.env.local?
reference = SecureRandom.hex(4)
Rails.logger.error("[#{reference}] #{exception.class}: #{exception.message}")
Rails.logger.error(exception.backtrace&.first(15)&.join("\n"))
render_error(
type: "internal_error",
message: "Something went wrong",
reference:,
status: :internal_server_error
)
end
end
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! π