/ Tags: RAILS-CODE / Categories: SOLUTIONS

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

Learn More

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! πŸ’» 🏑 Grad. Student, MCS. πŸŽ“ Class of '23. GitKraken Ambassador πŸ‡³πŸ‡΅ 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More