ruby 61 lines · 3 tabs

Retry a Flaky HTTP Call with Exponential Backoff Using a Rails Service Object

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

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

Share this code

Here's the card — post it anywhere.

Retry a Flaky HTTP Call with Exponential Backoff Using a Rails Service Object — share card
Link copied