class CircuitBreaker
class CircuitOpenError < StandardError; end
INCREMENT = <<~LUA.freeze
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return n
LUA
def initialize(name, threshold: 5, cooldown: 30, redis: Redis.current)
@name = name
@threshold = threshold
@cooldown = cooldown
@redis = redis
end
def run
raise CircuitOpenError, @name if open?
result = yield
reset!
result
rescue StandardError
trip!
raise
end
private
def open?
failure_count >= @threshold
end
def failure_count
@redis.get(key).to_i
end
def trip!
@redis.eval(INCREMENT, keys: [key], argv: [@cooldown])
end
def reset!
@redis.del(key)
end
def key
"circuit:#{@name}"
end
end
module FeatureRescue
extend ActiveSupport::Concern
def with_fallback(feature, fallback:, threshold: 5)
CircuitBreaker.new(feature, threshold: threshold).run do
yield
end
rescue CircuitBreaker::CircuitOpenError => e
mark_degraded(feature)
Rails.logger.warn("[degraded] #{feature}: circuit open")
fallback
rescue StandardError => e
mark_degraded(feature)
Sentry.capture_exception(e, extra: { feature: feature })
fallback
end
def degraded?(feature)
degraded_features.include?(feature)
end
private
def mark_degraded(feature)
degraded_features << feature
response.headers["X-Degraded"] = degraded_features.to_a.join(",")
end
def degraded_features
@degraded_features ||= Set.new
end
end
class ProductsController < ApplicationController
include FeatureRescue
def show
# Essential: not wrapped. If this fails the page cannot render.
@product = Product.published.find(params[:id])
@recommendations = with_fallback(:recommendations, fallback: []) do
RecommendationService.for(@product, user: current_user)
end
@price = with_fallback(:live_pricing, fallback: @product.cached_price) do
PricingClient.new.quote(@product.sku, region: current_region)
end
fresh_when(@product) unless degraded?(:live_pricing)
end
end
This snippet shows how a Rails app keeps rendering useful pages even when a downstream dependency (a recommendation service, a pricing API) is slow or broken. Instead of letting one failing call take down a whole request, each risky feature is wrapped so it can fail independently and fall back to a degraded-but-acceptable result. The pattern combines a circuit breaker, a per-feature rescue helper, and a controller that composes both.
In CircuitBreaker, the breaker tracks consecutive failures for a named dependency in Redis. run short-circuits with CircuitOpenError once failure_count crosses @threshold, so a struggling service is left alone to recover rather than being hammered by every request. The INCREMENT Lua script increments and sets a TTL atomically, which avoids the race where two processes both read a stale count and neither renews the expiry. When the breaker is open the block is never invoked, turning a slow timeout into an instant, cheap failure.
FeatureRescue is the ergonomic layer. with_fallback names a feature, runs the breaker-wrapped block, and on any StandardError returns the supplied fallback instead of raising. It records what degraded in a per-request Set (degraded_features) and reports the exception to the error tracker, so a fallback never becomes an invisible silent failure. That distinction matters: graceful degradation should still be observable, otherwise a permanently broken feature looks healthy.
ProductsController#show demonstrates composition. The core product load is intentionally not wrapped — if the product itself cannot be fetched, the page genuinely cannot render, so that error should propagate to a normal 500 or 404. Only the optional sections (recommendations, live_pricing) go through with_fallback, each with its own breaker name and a sensible default such as [] or a cached price. The view can then check degraded?(:recommendations) to hide or annotate a section.
The key trade-off is deciding which failures are tolerable: wrapping something essential in a fallback hides real outages, while wrapping nothing makes the app brittle. The rule of thumb is to degrade features that are additive and keep hard failures for data the page cannot exist without.
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
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.