The project is in a healthy, maintained state
A lightweight retry library with exponential/linear/constant backoff, configurable jitter strategies, and an optional circuit breaker for resilient Ruby applications.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

philiprehberger-retry_kit

Tests Gem Version Last updated

Retry with exponential backoff, jitter, and circuit breaker

Requirements

  • Ruby >= 3.1

Installation

Add to your Gemfile:

gem "philiprehberger-retry_kit"

Or install directly:

gem install philiprehberger-retry_kit

Usage

require "philiprehberger/retry_kit"

# Simple retry with defaults (3 attempts, exponential backoff, full jitter)
result = Philiprehberger::RetryKit.run do
  api.call
end

Custom Options

Philiprehberger::RetryKit.run(
  max_attempts: 5,
  backoff: :exponential,
  base_delay: 1,
  max_delay: 60,
  jitter: :equal,
  on: [Net::ReadTimeout, Errno::ECONNRESET]
) do
  http_request
end

Retry Callback

Philiprehberger::RetryKit.run(
  max_attempts: 4,
  on_retry: ->(error, attempt, delay) {
    puts "Attempt #{attempt} failed: #{error.message}. Retrying in #{delay}s..."
  }
) do
  flaky_operation
end

Total Timeout

Limit the total elapsed time across all retries:

Philiprehberger::RetryKit.run(
  max_attempts: 10,
  total_timeout: 30  # seconds — raises TotalTimeoutError if exceeded
) do
  slow_operation
end

Absolute Deadline

Stop retrying once a specific wall-clock Time has passed. Useful when the caller has a hard SLA (e.g. "respond before the request times out") rather than a relative budget:

Philiprehberger::RetryKit.run(
  max_attempts: 50,
  deadline: Time.now + 10  # raises DeadlineExceededError once Time.now >= deadline
) do
  api_call
end

deadline: composes with total_timeout: — whichever is hit first wins. DeadlineExceededError is never caught by the on: retryable-errors filter, so it always propagates.

Execution Stats

Use Executor directly to access stats after execution:

executor = Philiprehberger::RetryKit::Executor.new(max_attempts: 5)
executor.call { api.request }

executor.last_attempts     # => 3 (number of attempts made)
executor.last_total_delay  # => 3.5 (total seconds spent in backoff sleeps)

Decorrelated Jitter

AWS-style decorrelated jitter provides better delay distribution than full or equal jitter:

Philiprehberger::RetryKit.run(
  backoff: :exponential,
  jitter: :decorrelated,
  base_delay: 0.5,
  max_delay: 30
) do
  api_call
end

Each delay is randomized between base_delay and 3 * last_sleep, capped at max_delay.

Fallback Handler

Execute alternative code when all retries are exhausted instead of raising:

result = Philiprehberger::RetryKit.run(
  max_attempts: 3,
  fallback: ->(error) { default_value }
) do
  unreliable_call
end

The fallback proc receives the last error and its return value becomes the result of run.

Retry Predicate

Custom predicate for fine-grained retry decisions beyond exception class filtering:

Philiprehberger::RetryKit.run(
  retry_if: ->(error, attempt) { error.message.include?("timeout") && attempt < 3 }
) do
  api_call
end

If retry_if returns false, retrying stops immediately even if max_attempts has not been reached. Works in addition to the on: exception class filter (both must pass).

Per-Attempt Callback

Hook called after every attempt (not just retries) for metrics and logging:

Philiprehberger::RetryKit.run(
  on_attempt: ->(attempt, duration, error) {
    puts "Attempt #{attempt} took #{duration}s#{error ? " (failed: #{error.message})" : ""}"
  }
) do
  api_call
end

Called after each attempt with: attempt number (1-based), duration in seconds, and error (nil on success).

Give-up Callback

Fired exactly once when the executor stops retrying (attempts exhausted, retry_if returned false, or budget ran out). Runs before the fallback is invoked or the error is re-raised:

Philiprehberger::RetryKit.run(
  max_attempts: 3,
  on_giveup: ->(error, attempts) {
    Metrics.increment("retry.giveup", tags: { reason: error.class.name, attempts: attempts })
  }
) do
  unreliable_call
end

Useful for metrics, alerting, and structured logging at the point of failure.

Retry Budget

Global retry budget shared across executors to prevent retry storms:

budget = Philiprehberger::RetryKit::Budget.new(max_retries: 100, window: 60)

# Multiple executors share the budget
Philiprehberger::RetryKit.run(budget: budget) { call_a }
Philiprehberger::RetryKit.run(budget: budget) { call_b }

budget.remaining   # => remaining retry count
budget.exhausted?  # => true/false
budget.reset       # clear all recorded retries

Thread-safe sliding window counter. When the budget is exhausted, retries are skipped and the error is raised immediately (or the fallback is invoked if provided).

Backoff Strategies

# Exponential: 0.5s, 1s, 2s, 4s, ...
Philiprehberger::RetryKit.run(backoff: :exponential)

# Linear: 0.5s, 1s, 1.5s, 2s, ...
Philiprehberger::RetryKit.run(backoff: :linear)

# Constant: 1s, 1s, 1s, ...
Philiprehberger::RetryKit.run(backoff: :constant, base_delay: 1)

Circuit Breaker

breaker = Philiprehberger::RetryKit::CircuitBreaker.new(
  failure_threshold: 5,
  cooldown: 30,
  on_state_change: ->(from, to) { puts "Circuit: #{from} -> #{to}" }
)

# Use with retry
Philiprehberger::RetryKit.run(circuit_breaker: breaker) do
  external_service.call
end

# Use standalone
breaker.call { risky_operation }

# Check state
breaker.state        # => :closed, :open, or :half_open
breaker.failure_count
breaker.reset        # reset to closed
breaker.trip!        # force open immediately (operational kill-switch)

Backoff Utilities

Philiprehberger::RetryKit::Backoff.exponential(3, base_delay: 0.5, max_delay: 30)
# => 4.0

Philiprehberger::RetryKit::Backoff.jitter(4.0, mode: :full)
# => 0.0..4.0 (random)

API

Method / Class Description
RetryKit.run(**options, &block) Execute a block with retry logic
Executor.new(**options) Create a reusable retry executor
Executor#call(&block) Execute the block with retries
Executor#last_attempts Number of attempts in the last execution
Executor#last_total_delay Total backoff sleep time (seconds) in the last execution
CircuitBreaker.new(failure_threshold:, cooldown:) Create a circuit breaker
CircuitBreaker#call(&block) Execute through the circuit breaker
CircuitBreaker#state Current state (:closed, :open, :half_open)
CircuitBreaker#reset Reset to closed state
CircuitBreaker#trip! Force the circuit open (operational kill-switch)
Backoff.exponential(attempt, base_delay:, max_delay:) Calculate exponential delay
Backoff.linear(attempt, base_delay:, max_delay:) Calculate linear delay
Backoff.constant(attempt, delay:) Calculate constant delay
Backoff.jitter(delay, mode:) Apply jitter to a delay (:full, :equal, :none)
Backoff.decorrelated(last_delay, base_delay:, max_delay:) Calculate decorrelated jitter delay
Budget.new(max_retries:, window:) Create a shared retry budget
Budget#acquire Consume one retry from the budget
Budget#remaining Remaining retries in the current window
Budget#exhausted? Whether the budget is exhausted
Budget#reset Clear all recorded retries
TotalTimeoutError Raised when total_timeout is exceeded
DeadlineExceededError Raised when the absolute deadline: has passed

Development

bundle install
bundle exec rspec
bundle exec rubocop

Support

If you find this project useful:

Star the repo

🐛 Report issues

💡 Suggest features

❤️ Sponsor development

🌐 All Open Source Projects

💻 GitHub Profile

🔗 LinkedIn Profile

License

MIT