class Problem
DEFAULT_TYPE = "https://api.example.com/problems/about:blank".freeze
attr_reader :type, :title, :status, :detail, :instance, :extensions
def initialize(status:, title:, detail: nil, type: nil, instance: nil, extensions: {})
@status = status
@title = title
@detail = detail
@type = type || DEFAULT_TYPE
@instance = instance
@extensions = extensions
end
def self.for_status(status, detail: nil, type: nil, extensions: {})
title = Rack::Utils::HTTP_STATUS_CODES.fetch(status, "Unknown Error")
new(status: status, title: title, detail: detail, type: type, extensions: extensions)
end
def to_h
{
type: type,
title: title,
status: status,
detail: detail,
instance: instance
}.merge(extensions).compact
end
end
module ProblemDetails
extend ActiveSupport::Concern
CONTENT_TYPE = "application/problem+json".freeze
included do
rescue_from StandardError, with: :render_unexpected
rescue_from ActionController::ParameterMissing, with: :render_bad_request
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable
end
private
def render_bad_request(error)
render_problem Problem.for_status(400, detail: error.message)
end
def render_not_found(error)
render_problem Problem.for_status(404, detail: error.message)
end
def render_unprocessable(error)
problem = Problem.for_status(
422,
detail: "Validation failed",
extensions: { errors: error.record.errors.group_by_attribute.transform_values { |errs| errs.map(&:message) } }
)
render_problem problem
end
def render_unexpected(error)
Rails.logger.error("[#{error.class}] #{error.message}\n#{error.backtrace&.first(10)&.join("\n")}")
render_problem Problem.for_status(500, detail: "An unexpected error occurred")
end
def render_problem(problem)
problem_with_instance = Problem.new(
status: problem.status,
title: problem.title,
detail: problem.detail,
type: problem.type,
instance: request.path,
extensions: problem.extensions
)
response.set_header("Content-Type", CONTENT_TYPE)
render json: problem_with_instance.to_h, status: problem.status
end
end
class ArticlesController < ApplicationController
include ProblemDetails
def show
render json: find_article!
end
def create
article = Article.new(article_params)
article.save!
render json: article, status: :created
end
def update
article = find_article!
article.update!(article_params)
render json: article
end
private
def find_article!
Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
This snippet shows how a Rails JSON API can return consistent, machine-readable error bodies using the Problem Details format described in RFC 7807. Instead of every controller inventing its own error shape, the API standardizes on an application/problem+json document with type, title, status, detail, and instance members, plus room for extension fields like errors.
In Problem value object, the error is modeled as an immutable struct-like class built from Keyword init. It holds the core RFC members and an extensions hash for domain-specific data. The type defaults to a stable URI so clients can dereference documentation, and to_h uses compact so absent members are omitted rather than serialized as null. Modeling the problem as a plain object keeps it decoupled from HTTP: the same Problem can be logged, tested, or rendered anywhere. A small for_status factory pulls a human-readable title from Rack::Utils::HTTP_STATUS_CODES, guaranteeing the title and status stay in agreement.
ProblemDetails concern is the glue mounted into the controller stack. It registers rescue_from handlers in a deliberate order — most specific first, StandardError last — because Rails matches handlers bottom-to-top against the ancestor chain. ActiveRecord::RecordInvalid is mapped to a 422 carrying the model's field errors under the errors extension, which is exactly the kind of structured validation feedback a form client needs. ActiveRecord::RecordNotFound becomes a clean 404, and ActionController::ParameterMissing a 400. The catch-all render_unexpected logs the backtrace server-side but returns a generic 500 body, avoiding leaking internal messages to callers.
The private render_problem method is where the response is actually written: it sets content_type to application/problem+json so intermediaries and clients can distinguish problem bodies from normal payloads, and echoes the request path into instance. A key trade-off is centralization versus flexibility — handling everything in one concern keeps controllers thin but means unusual cases still need explicit raises of typed exceptions.
ArticlesController demonstrates the payoff: its actions contain no rescue blocks at all. find_article! simply calls find, and create calls save!; any failure bubbles up to the concern and emerges as a correct Problem Details response. This pattern is worth reaching for whenever an API has more than a handful of endpoints and clients that need to parse errors programmatically.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
Share this code
Here's the card — post it anywhere.