So far the topics we've covered have been interesting and perhaps obscure. But they haven't been dangerous.
Ruby is a supremely flexible language. It lets you modify the behavior of core systems, and exceptions are no different. We can use techniques like monkey-patching to open up whole new realms of possibility when it comes to exception handling.
Extending Ruby's exception system is an interesting exercise. It can even be useful. But most of the time it's a bad idea. Still, we're all adults here so I'll leave it to you to decide what's best for your particular use-case.
» Retrying Failed Exceptions
We covered the retry
keyword in Chapter 2. If you use retry
in your rescue block it causes the section of code that was rescued to be run again. Let's look at an example.
begin
retries ||= 0
puts "try ##{ retries }"
raise "the roof"
rescue
retry if (retries += 1) < 3
end
# ... outputs the following:
# try #0
# try #1
# try #2
» The Problem With retry
While retry
is great it does have some limitations. The main one being that the entire begin block is re-run.
For example, imagine that you're using a gem that lets you post status updates to Twitter, Facebook, and lots of other sites by using a single method call. It might look something like this:
SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")
# ...posts to Twitter API
# ...posts to Facebook API
# ...etc
If one of the APIs fails to respond, the gem raises a SocialMedia::TimeoutError
and aborts. If we were to catch this exception and retry, we'd wind up with duplicate posts because the retry would start over from the beginning.
begin
SocialMedia.post_to_all("Zomg! I just ate the biggest hamburger")
rescue SocialMedia::TimeoutError
retry
end
# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# facebook error
# ...posts to Twitter API
# and so on
Wouldn't it be nice if we were able to make the gem only retry the failed requests? Fortunately for us, Ruby allows us to do exactly that.
» Continuations to the Rescue
Continuations tend to scare people. They're not used very frequently and they look a little odd. But once you understand the basics they're really quite simple.
A continuation lets you jump to a location in your code. It's kind of like a goto
statement in BASIC.
Let's use continuations to implement a simple loop:
require "continuation"
# Initialize our counter
counter = 0
# Define the location to jump to
continuation = callcc { |c| c }
# Increment the counter and print it
puts(counter += 1)
# Jump to the location above
continuation.call(continuation) if counter < 3
When you run this, it produces the following output:
1
2
3
You may have noticed a few things:
- Continuations require a lot of ugly boilerplate.
- We use the callcc method to create a Continuation object. There's no clean OO syntax for this.
- The first time the
continuation
variable is assigned, it is set to the return value ofcallcc
's block. That's why the block has to be there. - Each time we jump back to the saved location, the
continuation
variable is assigned whatever argument we pass thecall
method. We don't want it to change, so we docontinuation.call(continuation)
.
» Reimagining retry
We're going to use continuations to add an skip
method to to all exceptions. The example below shows how it should work. Whenever I rescue an exception I should be able to call skip
, which will cause the code that raised the exception to act like it never happened.
begin
raise "the roof"
puts "The exception was ignored"
rescue => e
e.skip
end
# ...outputs "The exception was ignored"
To do this I'm going to have to commit a few sins. Exception
is just a class. That means I can monkey-patch it to add a skip
method.
class Exception
attr_accessor :continuation
def skip
continuation.call
end
end
Now we need to set the continuation
attribute for every exception.
The code below is taken almost verbatim from Advi Grimm's excellent slide deck Things You Didn't know about Exceptions. I just couldn't think of a better way to implement it than this:
require 'continuation'
module StoreContinuationOnRaise
def raise(*args)
callcc do |continuation|
begin
super
rescue Exception => e
e.continuation = continuation
super(e)
end
end
end
end
class Object
include StoreContinuationOnRaise
end
Now I can call the skip
method for any exception and it will be like the exception never happened.
» Logging Local Variables on Raise
If you've ever wished that your exceptions contained a more complete representation of program state, you might be interested in this technique for logging local variables at the time an exception was raised.
This technique is not suitable for production. It imposes a large performance penalty on your entire app, even when no exceptions occur. Moreover, it relies on the binding_of_caller
gem which is not actively maintained.
» Introducing binding_of_caller
At any given moment, your program has a certain "stack." This is simply the list of currently "in-progress" methods.
In Ruby, you can examine the current stack via the Kernel#caller
method. Here's an example:
def a
b()
end
def b
c()
end
def c
puts caller.inspect
end
a()
# Outputs:
# ["caller.rb:11:in `b'", "caller.rb:4:in `a'", "caller.rb:20:in `<main>'"]
A binding is a snapshot of the current execution context. In the example below, I capture the binding of a method, then use it to access the method's local variables.
def get_binding
a = "marco"
b = "polo"
return binding
end
my_binding = get_binding
puts my_binding.local_variable_get(:a) # "marco"
puts my_binding.local_variable_get(:b) # "polo"
The binding_of_caller gem combines these two concepts. It gives you access to the binding for any level of the current execution stack. Once you have the binding, it's possible to access local variables, instance variables and more.
In the following example, we use the binding_of_caller gem to access local variables in one method while inside another method.
require "rubygems"
require "binding_of_caller"
def a
fruit = "orange"
b()
end
def b
fruit = "apple"
c()
end
def c
fruit = "pear"
# Get the binding "two levels up" and ask it for its local variable "fruit"
puts binding.of_caller(2).local_variable_get(:fruit)
end
a() # prints "orange"
This is really cool. But it's also disturbing. It violates everything we've learned about separation of concerns. It's going to get worse before it gets better. Let's suppress that feeling for a bit and press on.
» Replacing raise
One often-overlooked fact about raise
is that it's simply a method. That means that we can replace it.
In the example below we create a new raise
method that uses binding_of_caller
to print out the local variables of whatever method called raise
.
require "rubygems"
require "binding_of_caller"
module LogLocalsOnRaise
def raise(*args)
b = binding.of_caller(1)
b.eval("local_variables").each do |k|
puts "Local variable #{ k }: #{ b.local_variable_get(k) }"
end
super
end
end
class Object
include LogLocalsOnRaise
end
def buggy
s = "hello world"
raise RuntimeError
end
buggy()
Here's what it looks like in action:
» Logging All Exceptions With TracePoint
TracePoint is a powerful introspection tool that has been part of Ruby since version 2.0.
It allows you to define callbacks for a wide variety of runtime events. For example, you can be notified whenever a class is defined, whenever a method is called or whenever an exception is raised. Check out the TracePoint documentation for even more events.
Let's start by adding a TracePoint that is called whenever an exception is raised and writes a summary of it to the log.
tracepoint = TracePoint.new(:raise) do |tp|
# tp.raised_exeption contains the actual exception
# object that was raised!
logger.debug tp.raised_exception.object_id.to_s +
": " + tp.raised_exception.class +
" " + tp.raised_exception.message
end
tracepoint.enable do
# Your code goes here.
end
When I run this, every exception that occurs within the enable
block is logged. It looks like this: