Feb 2021
Exceptions, rescues, and error handling
Let’s say we’re running this simple little Ruby program:
i = 0
loop do
exit if i == 5
puts 'Doing something'
i += 1
sleep 10
end
This will print Doing something every 10 seconds, five times, and then exit.
A different, more relatable example of this might be if we we want to run something continuously until a given time.
In that case, it might look like:
y = 2030; mo = 2; d = 28; h = 17; mi = 0;
cutoff = Time.new(y, mo, d, h, mi)
loop do
exit if Time.now > cutoff
puts 'Doing something'
sleep 10
end
In this case, we’ll print Doing something every 10 seconds until February 28, 2030 at 5PM. Here’s a link to a working program if you’d like to run this until 2030.
Manual Exit
In these examples, the programs would automatically exit at some point via an exit
statement.
Let’s say we want to exit earlier; we can manually force an exit by running CTRL C
in our Terminal.
Exit Cleanup
When our program exits, either automatically or manually, we might want to do some clean up.
Let’s say our program is a stock trading bot. When we exit, we want to make sure all the positions are closed.
Luckily, we can use Ruby’s at_exit
block to handle this.
at_exit do
# close all positions
end
This will run, as we’d expect, when the program ends. We just need to make sure this is defined before exit
is called, or it won’t be found.
Exceptions
When we exit the program early via the CTRL C
method, everything seems to operate normally, but we also notice that we receive a messy error message in our Terminal.
This is because CTRL C
triggers a SignalException
and the program terminates. When exceptions are unhandled, our Terminal spews out some stuff that’s supposed to help us out.
If we want to exit the program more gracefully during a CTRL C
, we can rescue ourselves from this SignalException
using a begin
- rescue
block.
Begin / Rescue Detour
The boilerplate begin
- rescue
block looks something like this:
begin
# do something bad
1/0
rescue
'something bad happened'
end
If we want to know what the bad thing was, we can pass the exception object into the rescue.
begin
# do something naughty
1/0
rescue => e
puts e.message
end
This will print:
divided by 0
In addition to message
, there are a variety of instance methods available to call on our exception object (full_message
, backtrace
, etc) to give us whatever info we might want.
One other thing worth mentioning — method declarations are implicit begin
blocks, so we can do something like this:
def my_method
1 / 0
rescue
puts 'Something bad'
end
(And so are Class
and Module
declarations.)
Why do we keep using 1/0
as an example? It’s a quick way to trigger an Exception, as programs (just like calculators) cannot divide numbers by zero.
One Large Caveat
If we don’t specify the Exception type on our rescue
it will default to a StandardError
.
So
begin
# do something bad
1/0
rescue
'something bad happened'
end
is identical to
begin
# do something bad
1/0
rescue StandardError
'something bad happened'
end
StandardError
is an umbrella Error class, that contains many of the standard exceptions, and as we’ve seen above, ZeroDivisionError
is one of these. There are a dozen or so of these children - link here.
However, the exception created by a CTRL C
, SignalException
, is NOT a child of StandardError
, so it will not be handled by the default rescue
. We have to specifically handle this scenario.
begin
# Code here
rescue SignalException
puts "CTRL C"
rescue
puts "StandardError"
end
Rescuing SignalException
Now that we’re able to rescue SignalException
, our terminal won’t freak out when we CTRL C
.
However, we need to be careful about how we exit at this point.
Since we’re rescuing from this exception, the program will not automatically exit anymore. We need to explicitly tell it to exit.
at_exit do
# clean up program
end
begin
# Code here
rescue SignalException
puts "Program terminated via CTRL C"
exit
rescue
puts "StandardError"
end
You might be wondering if adding that explicit exit
statement was necessary. The answer is — sort of.
The Third Exit
So far, we’ve seen exits from CTRL C
and exit
statements in our code. Another way a program will exit is when it simply reaches the end of the program.
Back to the question above - we might get away with not calling exit
in our SignalException
if the program will exit on its own immediately after.
When the program exits on its own, at_exit
is still called, and everything goes as planned.
But it pays to be explicit in this case, because there’s a chance there could be something that happens after the begin
- rescue
block, but before the program naturally concludes and exits.
For example:
at_exit do
puts "in at_exit"
end
begin
sleep 10
rescue SignalException
puts "Program terminated via CTRL C"
# no exit call here
end
sleep 100
This program will initially sleep for 10 seconds. If we call CTRL C
during this 10 seconds, we will be rescued, but we have removed the exit
call.
Now the program won’t exit right away. at_exit
won’t run for 100 seconds.
If we add back the exit
call in our SignalException
, we can guarantee that we immediately exit when CTRL C
is called.
at_exit do
puts "in at_exit"
end
begin
sleep 10
rescue SignalException
puts "Program terminated via CTRL C"
exit
end
One Last Rescue
Everything should be working nicely now. For clarity, we’ll add one last rescue statement.
at_exit do
puts "in at_exit"
end
begin
sleep 10
exit
rescue SignalException
puts "Program terminated via CTRL C"
exit
rescue SystemExit
puts "Program terminated via exit command"
exit
rescue => e
puts "StandardError"
puts e.message
end
This step isn’t completely necessary, but I think it helps understand what’s happening. We’ve added a SystemExit
rescue, which captures any exit
statement in our begin
block. As you may have expected, SystemExit
is not a child of StandardError
, so we have to explicitly declare it.
This is helpful because we receive information about how the program exited. Every time the program exits, we’ll know whether it came from an exit
call or from a CTRL C
.
And again, since we’re capturing this in a rescue block, we have to explicitly exit
for the same reasons as mentioned above.
The end
Our program can exit via (1) an exit
statement in the begin
block, (2) a CTRL C
user input, or (3) by reaching the end of the program.
(1) is captured by a SystemExit
and explicitly exited.
(2) is captured by a SignalException
and explicitly exited.
(1),(2), and (3) find their way to the at_exit
block, allowing us to clean things up.