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