Skip to content

Maxed-OSS/backoffer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

backoffer

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.

Install

gem install backoffer

Or in a Gemfile:

gem "backoffer"

Usage

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
end

Backoffer.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.

Options

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.

Jitter strategies

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.
  • :equal waits r/2 plus a random time in [0, r/2], so half the delay is guaranteed and half is randomized.
  • :none waits exactly r. Deterministic, no spread. Useful for tests and for cases where you want predictable timing.

The schedule is a pure object

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.

Test

rake test
# or, without bundler:
ruby -Ilib test/test_backoffer.rb

License

MIT. See LICENSE.

About

Retry with exponential backoff and jitter. Zero dependencies; the delay schedule is a pure, testable object, so the whole retry path is deterministic under test.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages