
Ruby’s retry keyword, called inside a rescue clause, re-runs the whole begin block after an exception; ensure runs its code whether the block raises or not; a bare raise inside rescue re-raises the current exception with its original backtrace. Together they build retry-with-backoff logic that gives up cleanly after a few attempts.
Raised exceptions can be rescued to run an alternative code path when things go wrong, but rescuing is only the start. This article covers the ensure and retry keywords and reraising rescued exceptions — the tools for cleaning up after an exception, trying again, and giving up.
Our running example is a library that talks to an unreliable web API. Aside from being down every once in a while, the API is so slow that requests to it can take seconds. We depend on it, so our client needs to be as resilient as we can make it. Every example runs on Ruby 3.4.
The ensure Keyword
The ensure keyword guarantees a block of code runs, even when an exception is raised in the code before it.
In our library, we’d like to make sure the TCP connection opened by Net::HTTP.start is closed, even when the request fails — because it times out, for example. To do that, we wrap the request in a begin/ensure/end block.
In the ensure block, we close the TCP connection by calling Net::HTTP#finish, unless the http variable is nil. That happens when opening the connection fails and raises an exception before the variable is assigned.
require "net/http"
uri = URI("http://localhost:4567/")
begin
puts "Opening TCP connection..."
http = Net::HTTP.start(uri.host, uri.port)
puts "Sending HTTP request..."
puts http.request_get(uri.path).body
ensure
if http
puts "Closing the TCP connection..."
http.finish
end
endWe manage the connection by hand so we can reuse it when retrying later. Net::HTTP.start also accepts a block and closes the connection for you when the block ends, so this sample could drop the ensure — internally, that block form is implemented with an ensure of its own.
The retry Keyword
The retry keyword re-runs a begin block from the top. Combined with a rescue clause, we can use it to try again when we fail to open the connection, or when the API takes too long to respond.
To catch slow responses, we pass read_timeout: 10 to Net::HTTP.start, which sets the read timeout to 10 seconds. If no response arrives in time, the request fails with a Net::ReadTimeout. (Net::HTTP transparently retries an idempotent request like GET once before the exception reaches your code, so the rescue clause sees the Net::ReadTimeout after about 20 seconds.)
We also match on Errno::ECONNREFUSED to handle the API being down completely, which keeps the TCP connection from being opened at all. When nothing is listening on the port, Net::HTTP.start raises Failed to open TCP connection to localhost:4567 (Connection refused - connect(2) for "localhost" port 4567), and the http variable stays nil.
When one of these exceptions is rescued, calling retry starts the begin block again, so the code repeats the same request until no exception is raised. The http object is reused if it already holds a connection.
require "net/http"
http = nil
uri = URI("http://localhost:4567/")
begin
unless http
puts "Opening TCP connection..."
http = Net::HTTP.start(uri.host, uri.port, read_timeout: 10)
end
puts "Executing HTTP request..."
puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
puts "Failed (#{e.class}), retrying in 1 second..."
sleep(1)
retry
ensure
if http
puts "Closing the TCP connection..."
http.finish
end
endNow, the request is retried every second until no Net::ReadTimeout is raised.
$ ruby retry.rb
Opening TCP connection...
Executing HTTP request...
Failed (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Failed (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
...
While that makes sure no request ever fails with a timeout, retry-hammering an unresponsive API won’t help it come back up — and if it stays down, this loop runs forever. Instead, we should spread out our retries and give up after a few attempts.
Reraising Exceptions with raise
When an exception is rescued, the raised exception object is passed to the rescue clause. We can use it to extract data from the exception, like printing the message to the log, but we can also use it to reraise the exact same exception, with the same backtrace.
begin
raise "Exception!"
rescue RuntimeError => e
puts "Exception happened: #{e}"
raise e
endSince we have access to the exception object in the rescue clause, we can send the error to a log or an error monitor before reraising it. Rescuing and reraising is how AppSignal’s Ruby integrations track errors in Rack apps: the middleware rescues the exception, records it, and reraises it for the rest of the stack to handle.
Ruby stores the last raised exception in a global variable named $!, and raise uses it by default: called without arguments inside a rescue clause, it reraises the current exception — the same object, with the same backtrace, not a copy.
Reraising doesn’t have to mean reraising the same class, either. When you raise a new exception inside a rescue clause, Ruby links the exception being handled to the new one through Exception#cause, so wrapping a low-level timeout in an error class from your library’s domain loses nothing. Defining a class like ApiError is covered in our guide to custom exceptions in Ruby.
require "net/http"
class ApiError < StandardError; end
begin
begin
raise Net::ReadTimeout
rescue Net::ReadTimeout
raise ApiError, "the API did not respond"
end
rescue ApiError => e
puts e.message
puts e.cause.class
end$ ruby cause.rb
the API did not respond
Net::ReadTimeout
In our library, we can use reraising to take the pressure off the API after a couple of failed attempts. We track the number of attempts in a retries variable. Whenever a request fails, we increment it and check that it’s no higher than three, because we want to retry three times at most. If we can still retry, we retry. If not, we call raise without arguments to reraise the current exception.
require "net/http"
http = nil
uri = URI("http://localhost:4567/")
retries = 0
begin
unless http
puts "Opening TCP connection..."
http = Net::HTTP.start(uri.host, uri.port, read_timeout: 1)
end
puts "Executing HTTP request..."
puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
if (retries += 1) <= 3
puts "Failed (#{e.class}), retrying in #{retries} second(s)..."
sleep(retries)
retry
else
raise
end
ensure
if http
puts "Closing the TCP connection..."
http.finish
end
endBy passing the retries variable to sleep, we wait a little longer before every new attempt.
$ ruby reraise.rb
Opening TCP connection...
Executing HTTP request...
Failed (Net::ReadTimeout), retrying in 1 second(s)...
Executing HTTP request...
Failed (Net::ReadTimeout), retrying in 2 second(s)...
Executing HTTP request...
Failed (Net::ReadTimeout), retrying in 3 second(s)...
Executing HTTP request...
Closing the TCP connection...
/usr/local/lib/ruby/3.4.0/net/protocol.rb:229:in 'Net::BufferedIO#rbuf_fill': Net::ReadTimeout with #<TCPSocket:(closed)> (Net::ReadTimeout)
from /usr/local/lib/ruby/3.4.0/net/protocol.rb:199:in 'Net::BufferedIO#readuntil'
...
from reraise.rb:13:in '<main>'
The request is retried three times before the code gives up and reraises the last error. The ensure block still runs on the way out, closing the connection before the process exits, and Ruby 3.4 prints the backtrace with straight-quoted frame names like 'Net::BufferedIO#rbuf_fill'. From here, we can handle the error one level up, or let it crash the app if it can’t do its job without the API’s response.
Before giving up, log everything we know about the failure. Exception#full_message renders an exception the way Ruby’s default handler would print it — message, class, and backtrace in one string:
begin
raise "Exception!"
rescue RuntimeError => e
puts e.full_message(highlight: false)
end$ ruby full_message.rb
full_message.rb:2:in '<main>': Exception! (RuntimeError)
With highlight: true (the default when printing to a terminal), the same output arrives with ANSI colors.
When the client finally gives up and re-raises, that exception is the one production needs to surface. AppSignal’s Ruby error tracking captures the re-raised exception with its full backtrace and groups it by class, so a flaky third-party API and a real outage never blur into one incident.
Common Errors with retry and raise
Both keywords need a current exception to work with. Used where there is none, each fails with a message that shows up in searches often enough to deserve its own explanation.
unhandled exception (RuntimeError)
$ ruby -e 'raise'
-e:1:in '<main>': unhandled exception
A bare raise outside any rescue clause has no current exception to reraise, so Ruby raises a fresh RuntimeError with an empty message. The unhandled exception text is the default handler’s placeholder for that missing message: rescue the exception, and e.message returns an empty string while e.class is RuntimeError.
The fix: call a bare raise only inside a rescue clause, where it reraises the current exception. Anywhere else, pass an exception class or a message, like raise ArgumentError, "no URL given".
Invalid retry without rescue (SyntaxError)
$ ruby -e 'retry'
-e: -e:1: syntax error found (SyntaxError)
> 1 | retry
| ^~~~~ Invalid retry without rescue
This one never gets to run: retry outside a rescue clause is rejected at parse time. The keyword only has meaning inside rescue, where it re-runs the begin block. To repeat or skip an iteration of a loop, reach for redo or next instead — our article on redo, retry, and next compares all three.
A Resilient Web API Client
By combining these keywords, we’ve built a resilient web API client in about 20 lines of code: ensure guarantees the TCP connection is closed, retry repeats the request when the API times out or refuses the connection, the attempt counter backs off a little longer after every failure, and raise hands the exception up the stack once the client has done all it can.
That final raise is the hand-off to the rest of your app — and, in production, to your monitoring. The AppSignal documentation on exception handling shows how to report Ruby errors, whether they crash the process or are rescued along the way.
P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
Frequently asked questions
- Does ensure always run in Ruby, even when an exception is raised?
- Yes. Code in an ensure clause runs whether the block completes, raises, or returns early. Ruby runs the ensure body after the begin block and any rescue clause, which makes it the place to close connections and files.
- How does retry work in Ruby?
- Calling retry inside a rescue clause jumps back to the start of the begin block and runs it again. Without a counter or backoff it loops forever on a persistent failure, so track attempts and re-raise after a limit.
- How do you re-raise an exception in Ruby?
- Call raise with no arguments inside a rescue clause. Ruby re-raises the current exception, the same object with its original backtrace, stored in the global variable $!. Outside a rescue clause, a bare raise produces a RuntimeError reported as unhandled exception.
- Can you use retry outside a rescue block in Ruby?
- No. Modern Ruby rejects it at parse time with a SyntaxError, Invalid retry without rescue. The keyword only has meaning inside a rescue clause; for repeating loop iterations, Ruby offers redo and next instead.
Published , Updated
Wondering what you can do next?

Jeff Kreeftmeijer
Become our next author!
AppSignal monitors your apps
AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!