module Retryable
module_function
def with_retries(tries: 3, base: 0.3, cap: 5.0, on: [StandardError])
attempt = 0
begin
attempt += 1
yield(attempt)
rescue *on => e
raise if attempt >= tries
delay = [base * (2 ** (attempt - 1)), cap].min
sleep(delay * (0.5 + rand))
Rails.logger.warn("retrying after #{e.class}: attempt #{attempt}/#{tries}")
retry
end
end
end
class WeatherClient
class ClientError < StandardError; end
class UpstreamError < StandardError; end
RETRYABLE = [Faraday::TimeoutError, Faraday::ConnectionFailed, UpstreamError].freeze
def initialize(base_url: ENV.fetch("WEATHER_API_URL"))
@conn = Faraday.new(url: base_url) do |f|
f.options.open_timeout = 2
f.options.timeout = 4
f.request :json
f.response :json
end
end
def call(city)
Retryable.with_retries(tries: 4, base: 0.4, on: RETRYABLE) do
response = @conn.get("/v1/current", { q: city })
handle(response)
end
end
private
def handle(response)
status = response.status
return response.body if status < 400
raise ClientError, "bad request (#{status})" if status < 500
raise UpstreamError, "upstream failed (#{status})"
end
end
class ForecastsController < ApplicationController
def show
forecast = WeatherClient.new.call(params.require(:city))
render json: { city: params[:city], current: forecast }
rescue WeatherClient::ClientError => e
render json: { error: e.message }, status: :bad_request
rescue WeatherClient::UpstreamError
response.set_header("Retry-After", "30")
render json: { error: "weather service unavailable" }, status: :service_unavailable
end
end
This snippet shows a small, focused way to make an outbound HTTP call resilient to transient failures without leaking retry logic into controllers or models. The technique is exponential backoff with jitter: on each failed attempt the wait time roughly doubles, and a random component is added so that many clients failing at once do not all retry in lockstep and hammer the upstream (the "thundering herd" problem).
In Retryable module, Retryable.with_retries implements the generic loop. It accepts the number of tries, a base delay, a cap on the maximum sleep, and the list of exception classes considered retryable. The block is called inside a begin/rescue; when it raises one of the on: exceptions and attempts remain, the code computes base * 2 ** (attempt - 1), clamps it to cap, then multiplies by a random factor via rand to add jitter. Non-retryable errors and the final failed attempt re-raise so callers still see genuine problems. Keeping this as a plain module means it is trivially unit-testable and reusable across services.
WeatherClient service is the service object that owns one external concern: fetching weather. It configures a Faraday connection with explicit open_timeout and timeout values — bounding how long a hung socket can block a worker is essential, otherwise retries never trigger because the request never returns. The call method wraps the request in Retryable.with_retries, retrying on timeouts and connection failures but deliberately raising UpstreamError for 5xx responses so those are retried too, while a 4xx becomes a non-retryable ClientError. This distinction matters: retrying a 400 just wastes attempts, whereas a 503 is often transient.
ForecastsController shows the payoff at the call site. It simply instantiates WeatherClient and renders the result; the controller stays ignorant of backoff, timeouts, and Faraday. When retries are exhausted it rescues WeatherClient::UpstreamError and returns a 503 with Retry-After, translating an internal failure into an honest HTTP contract for the caller. A caveat worth noting: blocking retries with sleep tie up a request thread, so for anything slow or high-volume this pattern is better moved into a background job, where the same Retryable module can be reused unchanged.
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
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.