class SyncContactJob < ApplicationJob
queue_as :external
BACKOFF = ->(executions) do
(2**executions) + rand(0.0..1.0) # exponential + jitter, in seconds
end
retry_on TransientError, wait: BACKOFF, attempts: 6
discard_on PermanentError do |_job, error|
Rails.logger.warn("Dropping contact sync: #{error.message}")
end
def perform(contact_id)
contact = Contact.find(contact_id)
client = ContactApiClient.new
result = client.upsert(contact)
contact.update!(
external_id: result.fetch("id"),
synced_at: Time.current
)
end
end
class TransientError < StandardError; end
class PermanentError < StandardError; end
class ContactApiClient
BASE_URL = ENV.fetch("CONTACT_API_URL")
def initialize(connection: default_connection)
@connection = connection
end
def upsert(contact)
response = @connection.post("/v1/contacts") do |req|
req.headers["Idempotency-Key"] = idempotency_key(contact)
req.body = contact.to_api_payload
end
raise_for_status(response)
response.body
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
raise TransientError, "network error: #{e.message}"
end
private
def idempotency_key(contact)
Digest::SHA256.hexdigest("#{contact.id}:#{contact.updated_at.to_i}")
end
def raise_for_status(response)
status = response.status
return if status.between?(200, 299)
if status == 429 || status >= 500
raise TransientError, "retryable status #{status}"
else
raise PermanentError, "non-retryable status #{status}"
end
end
def default_connection
Faraday.new(url: BASE_URL) do |f|
f.request :json
f.response :json
f.options.timeout = 5
f.options.open_timeout = 2
end
end
end
class ContactsController < ApplicationController
def update
contact = current_account.contacts.find(params[:id])
if contact.update(contact_params)
SyncContactJob.perform_later(contact.id)
render json: contact, status: :ok
else
render json: { errors: contact.errors }, status: :unprocessable_entity
end
end
private
def contact_params
params.require(:contact).permit(:name, :email, :phone)
end
end
This snippet shows how a flaky external HTTP call is made resilient inside a Rails ActiveJob, without hand-rolling loops or letting transient failures silently drop work. The core idea is that network calls fail for boring, temporary reasons — timeouts, 429 Too Many Requests, 5xx blips — and the correct response is not to fail permanently but to retry a bounded number of times with growing gaps between attempts.
In SyncContactJob, the job leans on ActiveJob's built-in retry_on rather than a manual begin/rescue/sleep loop, because retries handled by the queue adapter release the worker thread between attempts instead of blocking it. Only TransientError triggers retries; a PermanentError (like a 4xx that will never succeed) calls discard_on so the job is dropped instead of wasting the queue. The wait: option is passed a lambda so the delay is computed per-attempt: 2 ** executions gives exponential growth, and a random rand term adds jitter. Jitter matters because when a downstream service recovers, many jobs that failed at once would otherwise retry in lockstep and hammer it again — the classic thundering-herd problem.
The perform method delegates the actual call to ContactApiClient, keeping the job focused on orchestration and error policy. Because retries mean perform can run more than once, the request in ContactApiClient sends an Idempotency-Key derived from the contact so the remote side can dedupe repeated deliveries — essential when a request succeeds server-side but the response is lost to a timeout.
In ContactApiClient, HTTP status codes are translated into the two domain exceptions the job cares about: raise_for_status maps 5xx and 429 to TransientError and other non-2xx codes to PermanentError. Low-level Faraday::TimeoutError and connection failures are also rescued and reclassified as transient. This separation means the retry policy lives in one place and the client stays a thin, testable wrapper. The trade-off is that retries add latency and require the downstream endpoint to tolerate duplicates; the attempts cap and discard_on guard against retrying forever. A developer reaches for this pattern whenever a job crosses a network boundary to a service they do not control.
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
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
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.