• Home
  • About
  • Portfolio
  • Contact
CodeCurious
  • Home
  • About
  • Portfolio
  • Contact
Go Back

How To Handle Errors In Rails APIs

Learn how to handle errors in Rails APIs with consistent JSON responses, HTTP status codes, and production best practices.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet • Ruby on Rails Developer

Last updated : Aug 14, 2026 • 37 min read

How to Handle Errors in Rails APIs

Last updated : Aug 14, 2026 • 37 min read

Share with friends

When you are building a Rails API, the happy path is easy. A request comes in, your controller does its job, and you return a clean JSON response. The real test of an API is what happens when things go wrong. A missing record, a failed validation, an expired token, a downed third party service. How your API responds in those moments determines whether frontend developers, mobile developers, and API consumers trust your API or dread working with it.

Inconsistent error handling is one of the most common problems in Rails APIs. One endpoint returns a plain string. Another returns a nested hash with a different structure. A third quietly returns HTTP 200 with an error message buried inside the body. Every one of these inconsistencies pushes work onto the client, forcing frontend and mobile developers to write defensive code that guesses at what your API might return instead of relying on a predictable contract.

A well designed API treats errors as first class citizens, not afterthoughts. Every error response should use the correct HTTP status code, follow the same JSON structure, and give the client enough information to act on it without exposing anything sensitive. That is the standard this article will help you reach.

By the end of this guide, you will understand the different categories of API errors, how to choose the correct HTTP status code for each one, and how to build a centralized, reusable error handling system in Rails 8 using rescue_from. You will see how to handle validation errors, missing records, authentication and authorization failures, custom application errors, and unexpected exceptions, all while keeping your controllers clean. Along the way we will write RSpec request specs to make sure our error handling actually works the way we expect, and we will close with a full, production ready example you can adapt directly into your own Rails API.


What Are API Errors?

Before writing any code, it helps to have a shared vocabulary for the kinds of errors your API will encounter. Not all errors are the same, and treating them differently is what makes an API predictable.

Client errors happen when the request itself is the problem. Maybe the JSON body is malformed, a required parameter is missing, or the request format is not something your API understands. These map to the 400 range of HTTP status codes.

Authentication errors occur when the API cannot confirm who is making the request. This includes a missing token, an invalid token, or an expired session. Authentication answers the question "do we know who you are."

Authorization errors occur after authentication succeeds. The API knows who the user is, but that user does not have permission to perform the requested action. Authorization answers the question "are you allowed to do this."

Validation errors happen when a request is understood but the data submitted does not meet the rules defined on your models. A blank title, an invalid email format, or a negative price are all validation errors.

Resource not found errors occur when a client requests something that does not exist, like fetching an article with an id that was never created or was deleted.

Server errors are failures on your side. A bug in your code, a database that is unreachable, or a third party service that times out unexpectedly.

Unexpected exceptions are the errors you did not anticipate. Nobody writes a rescue_from for every possible exception class, and that is fine. What matters is having a safety net that catches anything unhandled and turns it into a safe, generic response instead of leaking a stack trace.

The most important distinction to internalize is the difference between expected errors and unexpected errors. Expected errors are things you know can happen and can handle gracefully, like a validation failure or a missing record. Unexpected errors are bugs or unforeseen failures that should be logged, monitored, and hidden from the client behind a generic message. Good API error handling treats these two categories very differently, and we will build that distinction directly into our code.


Why Consistent API Error Responses Matter

Imagine you are a frontend developer consuming a Rails API. One endpoint returns errors as a plain array of strings. Another wraps them in an object with a message key. A third uses error while a fourth uses errors. Every time you integrate a new endpoint, you have to open the network tab, inspect the actual response, and write custom parsing logic just for that one case.

This is not a hypothetical problem. It is one of the most common sources of friction between backend and frontend teams. Consistent error responses matter for several groups of people:

Frontend applications need a predictable shape they can parse once and reuse everywhere, whether they are rendering a toast notification or highlighting an invalid form field.

Mobile applications often have stricter release cycles than web apps. If your API changes its error format without warning, a mobile app might not be able to patch around it quickly, leading to a poor experience for users stuck on an older app version.

Third party integrations rely on documented, stable contracts. A partner integrating with your API should not have to guess whether an error will come back as a string or an object.

Debugging becomes far easier for everyone, internal and external, when every error follows the same shape and every log entry includes the same fields.

API documentation is much simpler to write and maintain when there is one canonical error format to document instead of dozens of one off shapes.

Client-side error handling logic gets dramatically simpler. A single error parsing function can work across your entire API surface instead of needing special cases per endpoint.

Here is what inconsistency looks like in practice:

// Endpoint A
{ "error": "Title can't be blank" }

// Endpoint B
{ "errors": ["Title can't be blank", "Price must be greater than 0"] }

// Endpoint C
{ "message": "Validation failed", "fields": { "title": "is required" } }

Three different shapes for the same underlying concept: a validation failure. Compare that to a consistent structure:

{
"error": {
"code": "validation_failed",
"message": "The article could not be created.",
"details": {
"title": ["can't be blank"],
"price": ["must be greater than 0"]
}
}
}

Now every validation error across the entire API, regardless of which resource or controller generated it, has the exact same shape. That predictability is the foundation everything else in this article builds on.


Understanding HTTP Status Codes

HTTP status codes are the first signal a client uses to understand what happened, often before it even looks at the response body. Using the correct status code is not a cosmetic detail. It changes how browsers, HTTP libraries, caching layers, and monitoring tools behave.

Here are the status codes that matter most for a Rails API.

200 OK means the request succeeded and the response contains the requested data. This is your default for successful GET, PATCH, and PUT requests that return a body.

201 Created should be returned when a POST request successfully creates a new resource. Rails conventionally pairs this with a Location header pointing to the new resource.

204 No Content is used when a request succeeds but there is nothing to return, most commonly after a successful DELETE.

400 Bad Request signals that the request itself is malformed, independent of your application's business rules. Think invalid JSON syntax or a completely missing required parameter.

401 Unauthorized means the client has not proven who they are. Despite the name, this is really about authentication, not authorization. Missing or invalid credentials belong here.

403 Forbidden means the client is known, authentication succeeded, but they are not allowed to perform this action. A regular user trying to hit an admin only endpoint should get a 403, not a 401.

404 Not Found means the requested resource does not exist, either because it was never created or because it was deleted.

409 Conflict is used when a request cannot be completed because it conflicts with the current state of the resource, such as trying to create a record that violates a uniqueness constraint.

422 Unprocessable Content (still commonly referred to by its earlier name, Unprocessable Entity) is the standard choice for validation errors. The request was well formed and understood, but the data did not pass your application's validation rules.

429 Too Many Requests tells the client they have been rate limited and should slow down, often paired with a Retry-After header.

500 Internal Server Error is your catch all for unexpected failures on the server side, bugs, unhandled exceptions, anything your code did not anticipate.

503 Service Unavailable signals a temporary condition, like scheduled maintenance or a dependency being down, rather than a bug in your code.

A few distinctions trip people up constantly, so let's be explicit about them.

400 vs 422: Use 400 when the request itself cannot be parsed or understood, like malformed JSON. Use 422 when the request is understood perfectly well, but the data inside it fails your validation rules. A blank title field is a 422, not a 400, because Rails understood the request just fine.

401 vs 403: Use 401 when you do not know who the client is. Use 403 when you do know who they are, but they are not allowed to do what they are asking. A logged out user trying to view their orders gets a 401. A logged in regular user trying to delete another user's account gets a 403.

404 vs 422: Use 404 when the resource referenced in the URL does not exist, like GET /articles/999. Use 422 when the resource exists but the data submitted to create or update it is invalid.

500 vs 503: Use 500 for bugs and unexpected exceptions in your own code. Use 503 when the failure is due to a temporary, known condition like a downstream service outage or planned maintenance, especially when you can tell the client to retry later.


Designing a Standard API Error Format

With the status code conventions in place, the next step is designing the actual JSON shape every error response in your API will follow. This is the single most valuable decision you can make for the long term maintainability of your API.

Here is a format that works well for most Rails APIs:

{
"error": {
"code": "validation_failed",
"message": "The request could not be processed.",
"details": {
"title": ["can't be blank"]
}
}
}

Each field serves a specific purpose.

The code is a short, machine-readable, stable string like validation_failed or record_not_found. Clients can use this to branch logic without parsing human language, which matters a lot if you ever translate your message field or reword it later.

The message is a human-readable summary meant primarily for logs, developer tools, or generic UI fallback text. It should never be the only thing a client relies on to determine what happened, because message text can and will change over time.

The details field carries structured, field-specific information, most commonly validation errors keyed by attribute name. This is what a frontend form uses to highlight the exact field that failed.

You may also want to include metadata, such as a request ID for tracing (which we will cover later), or a documentation URL pointing to more information about that specific error code.

The core principle behind this design is simple: clients should never have to parse arbitrary human-readable strings to figure out what went wrong. If your only signal for "this failed because of a duplicate email" is a sentence buried in a message field, every client integration becomes fragile. A stable code field gives clients something they can safely depend on release after release.

If you have already built a Rails 8 JSON API from scratch, this error format fits naturally alongside the resource-building patterns covered in how to build a JSON API using Ruby on Rails, where the same emphasis on predictable, well-structured responses applies to successful responses too.


Handling Validation Errors

Validation errors are the most common type of expected error your API will encounter, so it is worth understanding exactly how Rails surfaces them before wrapping them in JSON.

When an ActiveRecord model fails validation, Rails populates an errors object on that model instance. You have a few ways to read it.

article = Article.new(title: "", price: -5)
article.valid? # => false

article.errors.full_messages
# => ["Title can't be blank", "Price must be greater than or equal to 0"]

article.errors.messages
# => { title: ["can't be blank"], price: ["must be greater than or equal to 0"] }

full_messages gives you friendly, human-readable sentences that combine the attribute name with the failure reason. errors.messages gives you a hash keyed by attribute, which is exactly the shape we want for our details field.

Here is how that looks inside a controller action:

class Api::V1::ArticlesController < ApplicationController
def create
article = Article.new(article_params)

if article.save
render json: article, status: :created
else
render json: {
error: {
code: "validation_failed",
message: "The article could not be created.",
details: article.errors.messages
}
}, status: :unprocessable_entity
end
end

private

def article_params
params.require(:article).permit(:title, :price, :body)
end
end

Notice that we return status: :unprocessable_entity, which Rails maps to HTTP 422. Validation errors should almost always return 422 rather than 400, because the request was perfectly well formed. The problem is with the data, not the request structure. This is the exact distinction we walked through in the HTTP status codes section, and it is worth internalizing because it is one of the most common mistakes in Rails APIs.


Handling Record Not Found Errors

When you look up a record by an id that does not exist, Rails raises ActiveRecord::RecordNotFound. Left unhandled, this exception bubbles all the way up and Rails renders its default error page, which in a JSON API context is not what you want your clients to receive.

class Api::V1::ArticlesController < ApplicationController
def show
article = Article.find(params[:id])
render json: article
end
end

If params[:id] does not correspond to an existing article, Article.find raises ActiveRecord::RecordNotFound. We want to catch that and turn it into a clean, predictable JSON response with a 404 status:

class Api::V1::ArticlesController < ApplicationController
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found

private

def handle_not_found
render json: {
error: {
code: "record_not_found",
message: "The requested resource could not be found."
}
}, status: :not_found
end
end

A request to GET /api/v1/articles/999999 now returns:

{
"error": {
"code": "record_not_found",
"message": "The requested resource could not be found."
}
}

Notice what is deliberately absent here. We are not telling the client which table we queried, what the primary key column is named, or anything about our database schema. Rails' default error page in development mode is verbose and helpful for debugging, but that same detail becomes a liability in a production API response. Keep not found messages generic and let your logs carry the specifics.


Handling Authentication Errors

Authentication errors happen when your API cannot verify who is making a request. In a typical token based Rails API, this usually means one of the following situations: a missing Authorization header, an invalid token signature, or an expired JWT.

All of these should return HTTP 401, and the response body should avoid being overly specific about which condition failed, since that specificity can help an attacker probe your authentication system.

class ApplicationController < ActionController::API
rescue_from JWT::ExpiredSignature, with: :handle_expired_token
rescue_from JWT::DecodeError, with: :handle_invalid_token

private

def authenticate_user!
token = request.headers["Authorization"]&.split(" ")&.last

if token.blank?
render_unauthorized("Missing authentication token.")
return
end

decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded[:user_id])
end

def handle_expired_token
render_unauthorized("Your session has expired. Please log in again.")
end

def handle_invalid_token
render_unauthorized("Invalid authentication token.")
end

def render_unauthorized(message)
render json: {
error: {
code: "unauthorized",
message: message
}
}, status: :unauthorized
end
end

If you followed the complete walkthrough on Rails API authentication with JWT, this pattern will feel familiar. That article covers issuing and decoding JSON Web Tokens in detail, and this is exactly where those tokens intersect with error handling. An expired or malformed JWT is not a bug in your application, it is an expected condition your authentication layer needs to handle gracefully rather than letting the raw JWT::DecodeError exception crash the request.


Handling Authorization Errors

Authentication and authorization sound similar but answer completely different questions. Authentication confirms identity. Authorization confirms permission. A user can be fully authenticated, with a perfectly valid token, and still be forbidden from performing a specific action.

Consider an API where only admins can delete articles:

class Api::V1::ArticlesController < ApplicationController
before_action :authenticate_user!
before_action :authorize_admin!, only: [:destroy]

def destroy
article = Article.find(params[:id])
article.destroy
head :no_content
end

private

def authorize_admin!
return if current_user.admin?

render json: {
error: {
code: "forbidden",
message: "You do not have permission to perform this action."
}
}, status: :forbidden
end
end

The same pattern applies when a regular user tries to update or delete a resource that belongs to someone else, for example a user attempting to edit another user's profile or delete a comment they did not author:

def update
article = Article.find(params[:id])

unless article.user_id == current_user.id
return render json: {
error: {
code: "forbidden",
message: "You can only update your own articles."
}
}, status: :forbidden
end

if article.update(article_params)
render json: article
else
render json: {
error: {
code: "validation_failed",
message: "The article could not be updated.",
details: article.errors.messages
}
}, status: :unprocessable_entity
end
end

In both examples, the user is authenticated, current_user exists and is valid. The failure is purely about permission, which is why 403 is correct here instead of 401. Mixing these two up is one of the most common mistakes in Rails APIs, and clients genuinely rely on the distinction to decide whether to redirect a user to a login screen (401) or show a "you don't have access" message (403).


Using rescue_from in Rails API Controllers

You have already seen rescue_from used a few times above, but it is worth explaining properly because it is the backbone of clean Rails API error handling.

rescue_from lets you register a handler for a specific exception class at the controller level. When that exception is raised anywhere inside an action in that controller (or any controller that inherits from it), Rails calls your handler instead of letting the exception propagate.

class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
end

Because ApplicationController is the parent of every other controller in your API, registering handlers there means every controller automatically inherits this behavior without repeating the same rescue logic over and over.

You can register handlers for multiple exception types, and Rails will match the most specific one:

class ApplicationController < ActionController::API
rescue_from StandardError, with: :internal_server_error
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from ActiveRecord::RecordInvalid, with: :validation_error
rescue_from ActionController::ParameterMissing, with: :bad_request
end

This is the real value of rescue_from. Instead of wrapping every single controller action in a begin/rescue block, which clutters your code and is easy to forget, you declare your exception handling once, in one place, and every action benefits automatically. Your controllers stay focused on business logic instead of defensive error handling boilerplate.


Creating a Centralized API Error Handler

Now let's pull everything together into a reusable, centralized error handling module. This is the pattern I recommend for any production Rails API, because it keeps every error response consistent without duplicating rendering logic across controllers.

module ApiErrorHandler
extend ActiveSupport::Concern

included do
rescue_from StandardError, with: :internal_server_error
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from ActiveRecord::RecordInvalid, with: :validation_error
rescue_from ActionController::ParameterMissing, with: :bad_request
end

private

def record_not_found(exception)
render_error(code: "record_not_found",
message: "The requested resource could not be found.",
status: :not_found)
end

def validation_error(exception)
render_error(code: "validation_failed",
message: "The request could not be processed.",
details: exception.record.errors.messages,
status: :unprocessable_entity)
end

def bad_request(exception)
render_error(code: "bad_request",
message: exception.message,
status: :bad_request)
end

def unauthorized(message = "Authentication required.")
render_error(code: "unauthorized", message: message, status: :unauthorized)
end

def forbidden(message = "You do not have permission to perform this action.")
render_error(code: "forbidden", message: message, status: :forbidden)
end

def internal_server_error(exception)
Rails.logger.error("[#{exception.class}] #{exception.message}")
Rails.logger.error(exception.backtrace.join("\n")) if exception.backtrace

render_error(code: "internal_server_error",
message: "Something went wrong. Please try again later.",
status: :internal_server_error)
end

def render_error(code:, message:, status:, details: nil)
body = { error: { code: code, message: message } }
body[:error][:details] = details if details.present?
render json: body, status: status
end
end

Then include it in your ApplicationController:

class ApplicationController < ActionController::API
include ApiErrorHandler
end

Every controller in your API now has consistent, centralized error handling with almost no additional code. Adding a new exception type is a matter of adding one rescue_from line and one small private method, not touching every controller in your codebase.


Handling Custom Application Errors

Not every error maps cleanly to a built in Rails or Ruby exception. Sometimes your business logic has its own failure conditions that deserve their own exception class. A common example is a payment requirement:

class PaymentRequiredError < StandardError; end

You can raise it from anywhere in your application, such as a service object:

class SubscriptionService
def self.activate!(user)
raise PaymentRequiredError, "An active payment method is required." unless user.payment_method?

user.subscription.activate!
end
end

And handle it centrally, just like any other exception:

module ApiErrorHandler
included do
rescue_from PaymentRequiredError, with: :payment_required
end

private

def payment_required(exception)
render_error(code: "payment_required", message: exception.message, status: :payment_required)
end
end

Custom exceptions are useful when a failure condition is meaningful to your business domain and you want a distinct, stable code for clients to branch on. They are not a good fit for every minor edge case. If you find yourself creating a new exception class for every possible failure state, you are probably better off returning a structured failure result from a service object instead of raising, which we will cover shortly. Reach for a custom exception when the failure represents a genuine, named business rule violation that multiple parts of your application need to recognize and react to consistently.


Handling Unexpected Exceptions

No matter how carefully you write rescue_from handlers, some exceptions will slip through unanticipated. A third party library throws something unexpected, a nil value shows up where you did not expect one, or a genuine bug makes it to production. This is exactly what our StandardError catch all handler is for.

def internal_server_error(exception)
Rails.logger.error("[#{exception.class}] #{exception.message}")
Rails.logger.error(exception.backtrace.join("\n")) if exception.backtrace

render_error(code: "internal_server_error",
message: "Something went wrong. Please try again later.",
status: :internal_server_error)
end

Two things matter here. First, we log the full exception class, message, and backtrace so your team can actually diagnose what happened. In a real production setup you would send this to an error monitoring service like Sentry, Honeybadger, or Rollbar rather than relying on log files alone, but the principle is the same: capture everything internally.

Second, and just as important, the client receives none of that detail. They get a generic message and a 500 status code. This is a deliberate tradeoff. Detailed error information is a debugging tool for your team, not something to expose to the outside world, where it can reveal database structure, gem versions, file paths, or other information that helps an attacker understand your system.


Development vs Production Error Responses

Rails already treats development and production differently out of the box, and it is worth understanding what that means for API error responses specifically.

In development, config.consider_all_requests_local defaults to true, which means unhandled exceptions render Rails' detailed debug page (or a JSON equivalent if you have configured config.action_dispatch.show_exceptions accordingly). This is genuinely useful while you are building, since you can see the exact line of code and full backtrace immediately.

In production, that same setting should be false, ensuring that any exception that escapes your rescue_from handlers falls back to a generic response rather than leaking internals. Your config/environments/production.rb should already have this configured by default:

config.consider_all_requests_local = false

Because our centralized ApiErrorHandler catches StandardError explicitly, we are not actually relying on Rails' default exception page behavior in either environment. Our handler runs consistently, logs the full detail, and returns the same safe JSON shape everywhere. That consistency is valuable: you want your error handling logic to behave the same way in your test suite, staging environment, and production, not something that only gets exercised for the first time once real traffic hits production.


Logging API Errors

Good logging is what turns a generic "something went wrong" response into an actionable bug report for your team. When an unexpected error occurs, your logs should capture enough context to reproduce and fix the issue without needing to ask the reporting user follow up questions.

At minimum, log:

  • The exception class and message
  • The request path and HTTP method
  • The request ID (covered in the next section)
  • The current user's id, when available and appropriate
  • Any other context relevant to diagnosing the failure, like the resource id being acted on

Just as important is what you should never log:

  • Passwords, even hashed ones
  • JWT tokens or session tokens
  • API keys and secrets
  • Credit card numbers or other payment details
  • Any other data that would be considered sensitive if the logs were ever exposed

A reasonable logging call for our internal server error handler might look like this:

def internal_server_error(exception)
Rails.logger.error(
"[#{exception.class}] #{exception.message} " \
"path=#{request.path} method=#{request.method} " \
"request_id=#{request.request_id} user_id=#{current_user&.id}"
)
Rails.logger.error(exception.backtrace.join("\n")) if exception.backtrace

render_error(code: "internal_server_error",
message: "Something went wrong. Please try again later.",
status: :internal_server_error)
end

Be deliberate about what you interpolate into log lines, especially around request parameters, since raw parameter dumps are a common way sensitive data accidentally ends up in logs.


Request IDs and Debugging

Every Rails request already has a unique request ID generated automatically by the ActionDispatch::RequestId middleware, accessible via request.request_id. This id is also included in Rails' default log output, which means you can trace a single request across your entire log file just by searching for that id.

This becomes especially valuable when you have multiple services or background jobs involved in handling a single user action, since a shared or propagated request id lets you follow the entire journey of that request.

Including the request id in your error responses gives support teams and API consumers a concrete reference to hand back to you:

def render_error(code:, message:, status:, details: nil)
body = {
error: {
code: code,
message: message,
request_id: request.request_id
}
}
body[:error][:details] = details if details.present?
render json: body, status: status
end

Now if a mobile developer reports "users are getting a 500 on checkout," they can hand you the request_id from the response, and you can find the exact log entry, exception, and backtrace immediately instead of trying to reconstruct what happened from a vague description.


Handling Errors in API Services

As your API grows, business logic tends to move out of controllers and into service objects, background jobs, and other supporting classes. Error handling needs to follow that logic wherever it lives.

There are two broad strategies for handling failures inside a service object: raising an exception, or returning a failure result.

Raising an exception makes sense when the failure is truly exceptional and callers should not be expected to handle it inline every time:

class ArticlePublisher
def self.publish!(article)
raise PaymentRequiredError unless article.user.subscribed?

article.update!(published_at: Time.current)
end
end

Returning a failure result is often a better fit when a failure is a normal, expected outcome that the caller needs to branch on explicitly, rather than something exceptional:

class ArticlePublisher
Result = Struct.new(:success?, :article, :error, keyword_init: true)

def self.call(article)
unless article.user.subscribed?
return Result.new(success?: false, error: "An active subscription is required.")
end

article.update(published_at: Time.current)
Result.new(success?: article.persisted?, article: article, error: article.errors.full_messages.to_sentence)
end
end
def publish
article = Article.find(params[:id])
result = ArticlePublisher.call(article)

if result.success?
render json: result.article
else
render json: { error: { code: "publish_failed", message: result.error } }, status: :unprocessable_entity
end
end

A useful rule of thumb: raise exceptions for conditions that are genuinely unexpected or that you want to bubble up to your centralized handler automatically. Return failure results for conditions that are a normal, expected part of your business logic and that the calling code is meant to check explicitly every time. Background jobs generally follow the same principle, raising exceptions when a job should retry or be flagged for investigation, and logging plus returning quietly when a failure is expected and does not need to interrupt the job queue.


Handling External API Errors

Any time your Rails API depends on a third party service, whether that is a payment processor, a mapping service, or another internal API, you introduce a new category of failure that is entirely outside your control: timeouts, connection failures, rate limits, invalid responses, and outright outages.

The key principle is to never let a raw exception from an external HTTP client leak directly to your API consumers. Wrap external calls, catch their specific failure modes, and translate them into your own consistent error format.

class WeatherApiClient
class ServiceUnavailableError < StandardError; end

def self.fetch(city)
response = Faraday.get("https://api.example.com/weather", city: city) do |req|
req.options.timeout = 5
end

raise ServiceUnavailableError unless response.success?

JSON.parse(response.body)
rescue Faraday::TimeoutError, Faraday::ConnectionFailed
raise ServiceUnavailableError
end
end
module ApiErrorHandler
included do
rescue_from WeatherApiClient::ServiceUnavailableError, with: :service_unavailable
end

private

def service_unavailable(exception)
render_error(code: "service_unavailable",
message: "This feature is temporarily unavailable. Please try again shortly.",
status: :service_unavailable)
end
end

Notice that the client receives a clean 503 with a generic, actionable message. They never see the underlying Faraday::TimeoutError or find out which specific third party service you depend on internally. That detail belongs in your logs, not your API response.


Database and Transaction Errors

A few ActiveRecord specific exceptions come up often enough in API work that they deserve individual attention.

ActiveRecord::RecordInvalid is raised by bang methods like save! or update! when validation fails, as opposed to the boolean-returning save and update, which simply return false. If you use bang methods anywhere in your controllers or services, make sure ActiveRecord::RecordInvalid is registered in your centralized handler, as shown earlier.

ActiveRecord::RecordNotUnique is raised when a database level uniqueness constraint is violated, distinct from a model level uniqueness validation. This typically happens under race conditions, when two requests pass Rails' validation check nearly simultaneously before either has committed.

rescue_from ActiveRecord::RecordNotUnique, with: :conflict

def conflict(exception)
render_error(code: "conflict",
message: "This resource already exists.",
status: :conflict)
end

Transaction rollbacks happen when code inside an ActiveRecord::Base.transaction block raises an exception, causing all changes in that block to be undone. This is expected, intentional behavior, and typically the exception that caused the rollback should propagate up to be handled by your normal exception handling, not swallowed silently.

Database connection errors, such as ActiveRecord::ConnectionNotEstablished, represent genuine infrastructure problems. These should generally be treated as unexpected errors, falling through to your generic 500 handler, while being logged and, ideally, monitored so your team is alerted quickly rather than discovering the outage from user reports.

The general principle across all of these: expected database conditions, like a uniqueness conflict, deserve their own specific handler and status code. Unexpected database failures, like a connection dropping entirely, should become safe generic 500 responses on the client side while being logged with full detail internally so your team can respond quickly.


Rate Limiting Errors

If your API enforces rate limits, whether through a gem like rack-attack or custom middleware, exceeding that limit should return HTTP 429 Too Many Requests, along with a Retry-After header telling the client how long to wait before trying again.

def rate_limited
response.headers["Retry-After"] = "60"
render_error(code: "rate_limited",
message: "Too many requests. Please try again later.",
status: :too_many_requests)
end

Rate limiting is especially important on authentication endpoints, like login and password reset, where it helps protect against brute force attacks. It is equally relevant for public APIs with generous free tiers, where predictable 429 responses let well behaved clients back off automatically instead of hammering your servers or getting blocked entirely. Clients that respect the Retry-After header can implement automatic backoff and retry logic, which is far better for both sides than a client that keeps retrying immediately and getting rejected repeatedly.


API Error Handling and Security

Error handling and security are more connected than they might first appear. Poorly designed error responses are a common, and often overlooked, source of information disclosure vulnerabilities.

Stack traces reveal file paths, gem versions, and internal application structure, all useful reconnaissance for an attacker probing your system.

Raw database errors can expose table names, column names, and even fragments of SQL queries, which can help an attacker understand your schema or attempt SQL injection more effectively.

User enumeration is a subtle but serious issue. If your login endpoint returns "no account found with that email" for invalid emails but "incorrect password" for valid ones, an attacker can use that difference to build a list of valid email addresses in your system. The fix is to return the same generic message, something like "invalid email or password," regardless of which condition actually failed.

Authentication details should never reveal whether a token failed because it was expired versus tampered with versus simply missing, since that level of detail can help an attacker understand how your authentication system is implemented.

Sensitive tokens should never appear anywhere in an error response body, not even partially or truncated, since that is a place they are extremely easy to accidentally leave behind.

Internal service information, like the name of a third party vendor you use internally, should stay out of client-facing responses, as we saw in the external API error handling section.

The overarching principle: your error responses should give clients exactly enough information to act correctly, and nothing more. Every extra detail you include is a potential piece of information for someone trying to understand or attack your system.


Testing Rails API Errors

Error handling that is not tested is error handling you cannot trust. RSpec request specs are the natural place to verify that your API returns the correct status codes and JSON structure for every failure scenario.

At minimum, you want coverage for:

  • Validation errors (422)
  • Record not found errors (404)
  • Authentication errors (401)
  • Authorization errors (403)
  • Custom application errors
  • Unexpected exceptions (500)

For each case, verify the HTTP status, the overall JSON structure, the code value, the message, and, where applicable, the details hash.


Testing Error Handling with Request Specs

Here is a realistic request spec covering several of our error scenarios:

require "rails_helper"

RSpec.describe "Api::V1::Articles", type: :request do
describe "GET /api/v1/articles/:id" do
context "when the article does not exist" do
it "returns a 404 with a consistent error format" do
get "/api/v1/articles/999999"

expect(response).to have_http_status(:not_found)

json = JSON.parse(response.body)
expect(json["error"]["code"]).to eq("record_not_found")
expect(json["error"]["message"]).to be_present
end
end
end

describe "POST /api/v1/articles" do
context "with invalid parameters" do
it "returns a 422 with validation details" do
post "/api/v1/articles", params: { article: { title: "" } }

expect(response).to have_http_status(:unprocessable_entity)

json = JSON.parse(response.body)
expect(json["error"]["code"]).to eq("validation_failed")
expect(json["error"]["details"]).to include("title")
end
end

context "without an authentication token" do
it "returns a 401" do
post "/api/v1/articles", params: { article: { title: "New Post" } }

expect(response).to have_http_status(:unauthorized)

json = JSON.parse(response.body)
expect(json["error"]["code"]).to eq("unauthorized")
end
end
end

describe "DELETE /api/v1/articles/:id" do
context "when the current user is not an admin" do
it "returns a 403" do
article = create(:article)
user = create(:user, admin: false)

delete "/api/v1/articles/#{article.id}", headers: auth_headers(user)

expect(response).to have_http_status(:forbidden)

json = JSON.parse(response.body)
expect(json["error"]["code"]).to eq("forbidden")
end
end
end
end

Request specs are particularly well suited to error handling because they exercise the full stack, from routing through middleware, controllers, and your rescue_from handlers, exactly the same way a real client request would. Unit tests on individual methods cannot verify that a rescue_from handler is wired up correctly or that the right status code actually reaches the response, but a request spec catches exactly that kind of integration gap.


API Error Handling Best Practices

A practical checklist you can hold your API against:

  • Use the correct HTTP status code for every response, not just 200 and 500.
  • Keep every error response in the same consistent JSON shape.
  • Include a stable, machine-readable error code alongside any human-readable message.
  • Keep messages clear and free of internal implementation detail.
  • Validate all incoming request data before acting on it.
  • Centralize exception handling with rescue_from instead of scattering rescue blocks everywhere.
  • Log unexpected errors with full detail, including exception class, message, and backtrace.
  • Never expose stack traces to clients in production.
  • Never expose secrets, tokens, or credentials in an error response.
  • Write tests that specifically target your error responses, not just your happy paths.
  • Document expected errors alongside successful responses in your API documentation.
  • Keep authentication (401) and authorization (403) errors clearly distinct.

Common Rails API Error Handling Mistakes

Even experienced Rails developers fall into a few recurring traps.

Returning HTTP 200 for every error buries failure information inside the body and breaks every HTTP-aware tool, from browser dev tools to monitoring dashboards, that relies on status codes to detect problems. Fix it by returning the correct status code for every response, always.

Returning HTML errors from an API happens when Rails' default exception handling kicks in for an unhandled exception instead of a JSON specific handler. Fix it with a centralized rescue_from setup like the one in this article, applied consistently across every controller.

Exposing Rails stack traces in production is usually a configuration oversight. Fix it by confirming config.consider_all_requests_local is false in production and that your catch all handler is registered.

Returning raw exception messages to clients can leak implementation details. Fix it by mapping exceptions to safe, predefined messages rather than passing exception.message straight through to the client for anything beyond validation errors.

Using inconsistent JSON structures across endpoints forces every client to write custom parsing per endpoint. Fix it by adopting one standard error format and applying it everywhere, as we did earlier in this article.

Confusing 401 and 403 sends the wrong signal to clients about whether a user needs to log in again or simply lacks permission. Fix it by clearly separating your authentication and authorization logic, as shown in their respective sections above.

Returning 500 for validation failures treats an expected condition as if it were a bug. Fix it by making sure validation failures are caught explicitly and returned as 422, never allowed to bubble up to your generic error handler.

Ignoring errors from external services lets a downed third party dependency crash your own API with an unhandled exception. Fix it by wrapping every external call and translating failures into your own error format, as shown in the external API errors section.

Logging sensitive information like tokens or passwords creates a security liability inside your own logging infrastructure. Fix it by auditing exactly what gets interpolated into your log statements.

Not testing failure cases means your error handling is unverified and can silently break as your codebase evolves. Fix it by writing request specs for every category of error your API can produce, as shown above.


Real-World Example

Let's put everything together into a complete, working error handling system for a Rails 8 JSON API.

# app/controllers/concerns/api_error_handler.rb
module ApiErrorHandler
extend ActiveSupport::Concern

included do
rescue_from StandardError, with: :internal_server_error
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from ActiveRecord::RecordInvalid, with: :validation_error
rescue_from ActiveRecord::RecordNotUnique, with: :conflict
rescue_from ActionController::ParameterMissing, with: :bad_request
rescue_from JWT::ExpiredSignature, with: :token_expired
rescue_from JWT::DecodeError, with: :token_invalid
end

private

def record_not_found(_exception)
render_error(code: "record_not_found", message: "The requested resource could not be found.", status: :not_found)
end

def validation_error(exception)
render_error(code: "validation_failed", message: "The request could not be processed.",
details: exception.record.errors.messages, status: :unprocessable_entity)
end

def conflict(_exception)
render_error(code: "conflict", message: "This resource already exists.", status: :conflict)
end

def bad_request(exception)
render_error(code: "bad_request", message: exception.message, status: :bad_request)
end

def token_expired(_exception)
render_error(code: "unauthorized", message: "Your session has expired. Please log in again.", status: :unauthorized)
end

def token_invalid(_exception)
render_error(code: "unauthorized", message: "Invalid authentication token.", status: :unauthorized)
end

def unauthorized(message = "Authentication required.")
render_error(code: "unauthorized", message: message, status: :unauthorized)
end

def forbidden(message = "You do not have permission to perform this action.")
render_error(code: "forbidden", message: message, status: :forbidden)
end

def internal_server_error(exception)
Rails.logger.error(
"[#{exception.class}] #{exception.message} path=#{request.path} " \
"method=#{request.method} request_id=#{request.request_id}"
)
Rails.logger.error(exception.backtrace.join("\n")) if exception.backtrace

render_error(code: "internal_server_error", message: "Something went wrong. Please try again later.",
status: :internal_server_error)
end

def render_error(code:, message:, status:, details: nil)
body = { error: { code: code, message: message, request_id: request.request_id } }
body[:error][:details] = details if details.present?
render json: body, status: status
end
end
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ApiErrorHandler

before_action :authenticate_user!

private

def authenticate_user!
token = request.headers["Authorization"]&.split(" ")&.last
return unauthorized("Missing authentication token.") if token.blank?

decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded[:user_id])
end

def current_user
@current_user
end
end
# app/controllers/api/v1/articles_controller.rb
class Api::V1::ArticlesController < ApplicationController
before_action :authorize_admin!, only: [:destroy]

def show
render json: Article.find(params[:id])
end

def create
article = current_user.articles.new(article_params)
article.save!
render json: article, status: :created
end

def update
article = current_user.articles.find(params[:id])
article.update!(article_params)
render json: article
end

def destroy
Article.find(params[:id]).destroy
head :no_content
end

private

def authorize_admin!
forbidden unless current_user.admin?
end

def article_params
params.require(:article).permit(:title, :price, :body)
end
end

Walking through the flow: a client sends POST /api/v1/articles with a blank title. Rails routes it to Api::V1::ArticlesController#create. article.save! raises ActiveRecord::RecordInvalid because the bang method fails validation. That exception propagates up, and because ApiErrorHandler registered a rescue_from for it in ApplicationController, Rails intercepts it before it ever reaches the client as a raw exception. The validation_error handler runs, builds a response using render_error, and the client receives a clean 422 with the exact field level details they need, all without a single rescue block written inside the controller action itself.


API Documentation for Errors

An API is only as useful as its documentation, and error responses deserve just as much documentation attention as successful ones. A developer integrating with your API needs to know not only what a successful 200 response looks like, but every realistic failure mode they should plan for.

For each endpoint, your documentation should ideally cover:

  • The successful response shape and status code
  • Possible validation failures and what triggers them
  • Authentication failure conditions
  • Authorization failure conditions
  • Whether the resource can return a not found error
  • Any endpoint-specific server error conditions worth calling out

If you are versioning your API, this documentation also needs to stay consistent across versions. The approach covered in how to version a Rails API applies directly here: error response formats should remain stable within a given API version, and any breaking changes to your error structure should be treated the same way as breaking changes to your success responses, introduced in a new version rather than silently changed underneath existing clients.

For formal documentation, tools like OpenAPI (formerly Swagger) let you define your error schemas alongside your success schemas in a single spec file, which can then generate interactive documentation and even client SDKs automatically. Whether or not you adopt a formal spec format, the underlying discipline is the same: document every error a client can realistically encounter, not just the happy path.


Frequently Asked Questions

How do I handle errors in a Rails API? Use a centralized ApiErrorHandler concern with rescue_from registered in your ApplicationController, mapping each expected exception type to the correct HTTP status code and a consistent JSON error structure, as shown throughout this article.

What HTTP status should Rails APIs return for validation errors? 422 Unprocessable Content. The request was understood, but the data submitted did not pass your model's validations.

How do I use rescue_from in Rails? Call rescue_from inside a controller, passing the exception class you want to handle and either a with: symbol pointing to a method, or a block. Registering it in ApplicationController makes it apply to every controller that inherits from it.

What is the difference between 401 and 403? 401 means the API does not know who is making the request, an authentication failure. 403 means the API knows who the user is but they lack permission for this specific action, an authorization failure.

How should Rails APIs handle 404 errors? Rescue ActiveRecord::RecordNotFound centrally and return a generic JSON response with a 404 status, without exposing internal details like table or column names.

Should Rails APIs return error messages as JSON? Yes. A JSON API should never fall back to Rails' default HTML error pages. Every error response, expected or unexpected, should be JSON with a consistent structure.

How should unexpected exceptions be handled in production? Catch them with a StandardError rescue_from handler, log the full exception detail internally, and return a generic 500 response to the client without exposing stack traces or implementation details.

Should API errors include exception details? No, not in production. Clients should receive a stable error code and a safe, generic message. Full exception details, including backtraces, belong in your logs and error monitoring tools, not in the response body.

How do I test Rails API error responses? Write RSpec request specs that hit your endpoints with conditions designed to trigger each error type, then assert on the HTTP status code, the error code, and the response structure, as demonstrated in the testing section above.


Conclusion

Consistent error handling is not a minor implementation detail, it is part of your API's contract with every client that depends on it. Choosing the correct HTTP status code for every situation, wrapping every error in the same predictable JSON structure, and clearly separating authentication failures from authorization failures all add up to an API that frontend developers, mobile developers, and third party integrators can build against with confidence.

The centralized approach covered in this article, using rescue_from and a shared ApiErrorHandler concern, keeps that consistency easy to maintain as your API grows, without cluttering individual controller actions with repetitive rescue logic. Pair that with careful logging that captures what your team needs while protecting sensitive data, and a solid suite of request specs that verify every error path actually behaves the way you expect, and you have an error handling system that scales with your application rather than becoming a source of technical debt.

Build your Rails APIs so that failure is just as predictable as success. Your future self, and everyone building on top of your API, will thank you for it.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.

    Enter valid email address

    Services Tailored to Your Needs


    coding

    Web & Mobile Development

    Custom websites and mobile apps built to be fast, modern, and user-friendly. From sleek landing pages to full-scale applications, I deliver solutions that engage your audience and grow your business.

    API development

    Seamlessly connect your systems with secure, scalable APIs. I design and integrate APIs that improve efficiency, reliability, and flexibility for your business processes.

    Database design and management

    Reliable database solutions tailored to your needs. I design, optimize, and maintain databases that ensure performance, security, and scalability for your applications.

    You might also like…

    How to Version a Rails API: A Complete Guide
    Ruby On Rails

    How To Version A Rails API: A Complete Guide

    By Jean Emmanuel Cadet
    Published on: Aug 12, 2026
    Rails API Authentication with JWT: Complete Guide
    Ruby On Rails

    Rails API Authentication With JWT: Complete Guide

    By Jean Emmanuel Cadet
    Published on: Aug 10, 2026
    How to Build a JSON API using Ruby on Rails
    Ruby On Rails

    How To Build A JSON API Using Ruby On Rails

    By Jean Emmanuel Cadet
    Published on: Aug 07, 2026
    CodeCurious

    Designed for those who view software as architecture and code as literature.

    Legal

    Terms & Conditions Privacy Policy Disclaimer

    CodeCurious © 2025 - 2026. All rights reserved. | Made with ♥ by @jecode93