Retry with exponential backoff and jitter, in Ruby. Zero dependencies.
Transient failures (a flaky network call, a 503, a locked row) usually clear if you wait and try again. But retrying immediately, or every retry waiting the exact same amount, makes things worse: a fleet of clients that all fail at once will all retry at once, hammering a struggling service in lockstep. The fix is well known. Back off exponentially (wait longer after each failure) and add jitter (randomize the wait) so retries spread out instead of synchronizing.
backoffer is a small implementation of that pattern with the knobs you actually need, and a schedule that is a pure, testable object.
gem install backofferOr in a Gemfile:
gem "backoffer"require "backoffer"
# Retry up to 5 times with the default exponential-backoff-with-jitter policy.
Backoffer.retry(max_attempts: 5) do
do_something_flaky
end
# Only retry specific errors, and observe each retry.
Backoffer.retry(
max_attempts: 4,
base: 0.2,
retry_if: ->(e) { e.is_a?(Net::ReadTimeout) },
on_retry: ->(info) { logger.warn("retry #{info[:attempt]} in #{info[:delay]}s: #{info[:error]}") }
) do
call_api
endBackoffer.retry returns the block's value on the first success. When every
attempt fails it raises Backoffer::RetriesExhausted (whose cause and
last_error are the final underlying error). An error rejected by retry_if is
re-raised immediately, untouched.
| Option | Default | Meaning |
|---|---|---|
max_attempts |
3 |
Total tries, including the first. |
base |
0.5 |
Seconds before the first retry. |
factor |
2.0 |
Growth multiplier per attempt. |
max_delay |
30.0 |
Cap on any single delay (seconds); nil for no cap. |
jitter |
:full |
:full, :equal, or :none (see below). |
retry_if |
retry any StandardError |
Predicate ->(error) { ... }; truthy retries, falsey re-raises now. |
on_retry |
none | Callback ->(info) { ... } with { attempt:, delay:, error: }, called before each sleep. |
sleeper |
Kernel#sleep |
Receives the seconds to wait. Inject a no-op in tests to run instantly. |
policy |
built from the above | Pass a prebuilt Backoffer::Policy instead of the individual knobs. |
For a raw delay r:
:full(recommended) waits a random time in[0, r]. Maximum spread; the AWS "Exponential Backoff and Jitter" guidance favors this.:equalwaitsr/2plus a random time in[0, r/2], so half the delay is guaranteed and half is randomized.:nonewaits exactlyr. Deterministic, no spread. Useful for tests and for cases where you want predictable timing.
Backoffer::Policy computes delays and never sleeps, so you can inspect, test,
or reuse a schedule without side effects:
policy = Backoffer::Policy.new(base: 0.5, factor: 2.0, max_delay: 10, jitter: :none)
policy.delays(5) # => [0.5, 1.0, 2.0, 4.0, 8.0]
policy.raw_delay(6) # => 10.0 (capped by max_delay)
# Pass a seeded RNG for deterministic jitter in tests.
seeded = Backoffer::Policy.new(base: 1.0, jitter: :full, rng: Random.new(42))
seeded.delay(3)Because both the sleeper and the RNG are injectable, the whole retry path is
deterministic and instant under test, with no real waiting.
rake test
# or, without bundler:
ruby -Ilib test/test_backoffer.rbMIT. See LICENSE.