require "faraday"
require "faraday/retry"
module Http
class RetryableError < StandardError; end
class CircuitOpenError < StandardError; end
class HttpClientFactory
RETRIABLE_EXCEPTIONS = [
Faraday::TimeoutError,
Faraday::ConnectionFailed,
Faraday::RetriableResponse
].freeze
def self.build(base_url:, open_timeout: 2, timeout: 5, max_retries: 3)
Faraday.new(url: base_url) do |conn|
conn.options.open_timeout = open_timeout
conn.options.timeout = timeout
conn.request :retry,
max: max_retries,
interval: 0.3,
interval_randomness: 0.5,
backoff_factor: 2,
retry_statuses: [429, 502, 503, 504],
methods: %i[get head options],
exceptions: RETRIABLE_EXCEPTIONS,
retry_if: ->(env, exc) { retriable?(env, exc) }
conn.request :json
conn.response :json, content_type: /\bjson$/
conn.adapter Faraday.default_adapter
end
end
def self.retriable?(env, exception)
return true if RETRIABLE_EXCEPTIONS.any? { |k| exception.is_a?(k) }
env && env.response && [429, 502, 503, 504].include?(env.response.status)
end
end
end
module Http
class PaymentGatewayClient
FAILURE_THRESHOLD = 5
COOLDOWN = 30 # seconds
def initialize(base_url: ENV.fetch("GATEWAY_URL"))
@conn = HttpClientFactory.build(base_url: base_url, timeout: 8, open_timeout: 2)
@failures = 0
@opened_at = nil
end
def charge(amount_cents:, currency:, source:, idempotency_key:)
with_circuit do
response = @conn.post("/v1/charges") do |req|
req.headers["Idempotency-Key"] = idempotency_key
req.body = { amount: amount_cents, currency: currency, source: source }
end
raise RetryableError, "gateway #{response.status}" unless response.success?
response.body
end
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
record_failure!
raise RetryableError, e.message
end
private
def with_circuit
raise CircuitOpenError, "gateway circuit open" if open?
result = yield
@failures = 0
result
rescue RetryableError
record_failure!
raise
end
def open?
return false if @opened_at.nil?
if Time.now - @opened_at > COOLDOWN
@opened_at = nil
@failures = 0
return false
end
true
end
def record_failure!
@failures += 1
@opened_at = Time.now if @failures >= FAILURE_THRESHOLD
end
end
end
class ChargeCustomerJob
include Sidekiq::Job
sidekiq_options queue: :payments, retry: 4
def perform(payment_id)
payment = Payment.find(payment_id)
return if payment.captured?
client = Http::PaymentGatewayClient.new
result = client.charge(
amount_cents: payment.amount_cents,
currency: payment.currency,
source: payment.token,
idempotency_key: payment.idempotency_key
)
payment.update!(captured: true, gateway_id: result["id"])
rescue Http::CircuitOpenError => e
# Breaker is open; requeue later instead of piling on the downstream.
self.class.perform_in(60, payment_id)
Rails.logger.warn("charge deferred: #{e.message}")
rescue Http::RetryableError => e
Rails.logger.error("charge failed, sidekiq will retry: #{e.message}")
raise
end
end
This snippet shows how a resilient HTTP client is assembled around Faraday so that transient network failures don't cascade into user-facing errors. The core idea is that every outbound call must be bounded in time and retried only when it is safe to do so, with backoff and jitter to avoid retry storms.
In HttpClientFactory, a Faraday connection is built with an explicit options.open_timeout and options.timeout. The open timeout guards TCP connection establishment while the read timeout bounds how long the client waits for a response body; both are essential because a default Faraday connection can hang indefinitely on a slow peer. The faraday-retry middleware is configured with max attempts, exponential interval growth via backoff_factor, and interval_randomness to spread retries out. Critically, retry_statuses and methods restrict automatic retries to idempotent verbs and specific status codes, and retry_if inspects the exception so only timeouts and connection failures trigger another attempt — a POST that may have side effects is not blindly replayed.
The RetryableError and CircuitOpenError classes give callers a stable exception taxonomy independent of Faraday internals. PaymentGatewayClient wraps the raw connection and adds a lightweight circuit breaker: with_circuit tracks consecutive failures, trips to an open state once FAILURE_THRESHOLD is reached, and short-circuits calls for a cooldown window rather than hammering a downstream that is already struggling. This is the trade-off at the heart of resilience work — failing fast protects both services once retries alone stop helping.
The charge method demonstrates idempotency in practice: an Idempotency-Key header lets the gateway de-duplicate a retried request server-side, which is what makes retrying a POST acceptable at all. Faraday exceptions are translated into the local error types so the rest of the application never couples to the HTTP library.
Finally, ChargeCustomerJob shows the client used from a background job, where Sidekiq's own retry mechanism complements the in-process retries. A pitfall worth noting: in-library retries multiply with job-level retries, so timeouts and max must be tuned together to keep total latency bounded. This layered approach — timeouts, selective retries, backoff, and a breaker — is the standard pattern for any Rails service calling third-party APIs.
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.