ruby 107 lines · 3 tabs

API Error Handling with Problem Details (RFC7807-ish)

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 tabs
ruby
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

rails activerecord patterns
by Alex Kumar 1 tab
ruby
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

jwt authentication api
by Kai Nakamura 2 tabs
typescript
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

typescript reliability retry
by codesnips 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
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

rails turbo hotwire
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

API Error Handling with Problem Details (RFC7807-ish) — share card
Link copied