Skip to content
Joey Wang
Menu

Search

Full Stack Development

Retry Strategies in Ruby: Exceptions vs. Conditional Checks

Comparing Ruby retry patterns, exception-based retry, conditional checks, and exponential backoff, and when each keeps performance acceptable.

· 2 min read

engineering #ruby#performance#debugging

Audio summary

Transient failures, a flaky external service, a database timeout, a dropped network call, need a retry strategy. Ruby’s retry keyword makes exception-based retry easy to reach for, but exceptions carry a real performance cost, and reaching for them by default isn’t always the right call.

Exception-based retry

def retry_method
  attempts = 0
  begin
    attempts += 1
    puts "Attempt #{attempts}"
    raise "An error occurred" if attempts < 5 # Simulating failure
    puts "Success!"
  rescue => e
    puts "Rescued: #{e.message}"
    sleep 1
    retry if attempts < 5 # Automatically retries
  end
end

retry_method

This is simple and keeps the retry logic inside one begin...rescue block. The costs: raising and rescuing an exception is slower than a conditional check, since it builds a stack trace every time, and an unguarded retry can loop forever if the attempt count isn’t checked.

Conditional checks for expected failures

When failure is a normal, expected outcome rather than an exceptional one, a loop with a condition avoids the exception overhead entirely:

def retry_method
  5.times do |attempt|
    result = risky_operation
    if result
      puts "Success!"
      return result
    else
      puts "Attempt #{attempt + 1} failed, retrying..."
      sleep 1
    end
  end
  raise "Operation failed after 5 attempts"
end

def risky_operation
  rand > 0.8 # Simulates a success/failure scenario
end

No exception overhead, at the cost of writing the failure handling explicitly instead of leaning on rescue.

Exponential backoff

Retrying immediately just hammers whatever already failed. Backing off the delay reduces load on the thing you’re retrying against:

def retry_method
  attempts = 0
  begin
    attempts += 1
    puts "Attempt #{attempts}"
    risky_operation
    puts "Success!"
  rescue => e
    puts "Error: #{e.message}"
    sleep (2**attempts) # Exponential backoff
    retry if attempts < 5
  end
end

The tradeoff is resolution time: if the failure is persistent, backoff makes you wait longer to find that out.

The retryable gem

require 'retryable'

Retryable.retryable(tries: 5, sleep: 2) do
  puts "Trying operation..."
  raise "Temporary failure" if rand < 0.8
  puts "Success!"
end

Cleaner and more configurable than hand-rolled retry logic, at the cost of one more dependency.

Performance notes

Raising an exception builds a stack trace every time, which adds CPU cost and GC pressure under frequent failures. Avoid logging e.backtrace inside a retry loop unless you’re actively debugging, it’s expensive and rarely needed for every attempt. And always cap the number of retries, with a circuit breaker if the failure is likely to be persistent rather than transient.

When to use which

Exceptions fit unexpected failures, network timeouts, database errors, the kind of failure that should genuinely interrupt control flow. Conditional checks fit expected failures, rate limits, status codes you already anticipate. Most systems end up using both: exceptions for the genuinely exceptional, checks for the routine, and backoff wrapped around either one once retries start hitting a service under load.