At AppSignal we provide error tracking for Ruby applications. To do so, we capture all exceptions applications throw at us and notify developers as they happen.
It can be difficult to get exception handling right. In this article we'll explain how it works, what problems bad handling can cause and how to rescue exceptions properly.
Rescuing exceptions
By rescuing exceptions in Ruby you can prevent your application from crashing the moment something goes wrong. With a begin .. rescue
block you can specify an alternative path for your application when an error occurs.
1begin
2 File.read "config.yml"
3rescue
4 puts "No config file found. Using defaults."
5end
It's also possible to specify which exceptions should be rescued. When specifying an exception class, all subclasses of this exception will also be captured.
1begin
2 File.read "config.yml"
3rescue SystemCallError => e
4 puts e.class # => Errno::ENOENT
5 puts e.class.superclass # => SystemCallError
6 puts e.class.superclass.superclass # => StandardError
7end
In the example above you can see the exception Errno::ENOENT
is caught when its parent SystemCallError
is being rescued.
Rescuing too high up in the exception chain
It's important not to rescue exceptions too high up the Exception chain. When you do, all subclassed exceptions will also be caught, making the rescue block's capture too generic.
Here's a program that reads a config file based on the argument passed to the program.
1# $ ruby example.rb config.yml
2def config_file
3 ARGV.firs # Note the typo here, we meant `ARGV.first`.
4end
5
6begin
7 File.read config_file
8rescue
9 puts "Couldn't read the config file"
10end
The error message says it couldn't read the config file, but the real problem was a typo in the code.
1begin
2 File.read config_file
3rescue => e
4 puts e.inspect
5end
6#<NoMethodError: undefined method `firs' for []:Array>
The default exception class caught by a begin .. rescue
block is StandardError. If we don't pass in a specific class, Ruby will rescue StandardError and all subclassed errors. NoMethodError is one of these errors.
Rescuing a specific exception class will help prevent unrelated errors from accidentally prompting a failure state. It also allows for more specific custom error messages that are more helpful for the end user.
1config_file = "config.yml"
2begin
3 File.read config_file
4rescue Errno::ENOENT => e
5 puts "File or directory #{config_file} doesn't exist."
6rescue Errno::EACCES => e
7 puts "Can't read from #{config_file}. No permission."
8end
Rescuing Exception
It might still be tempting to rescue high up in the exception chain. Rescuing all errors an application can raise will prevent it from crashing. (100% uptime here we come!) However, it can cause a lot of problems.
The Exception class is the main exception class in Ruby. All other exceptions are subclasses of this class; if Exception is rescued all errors will be caught.
Two exceptions that most applications won't want to rescue are are SignalException and SystemExit.
SignalException is used when an outside source is telling the application to stop. This can be the Operating System when it wants to shut down, or a system administrator that wants to stop the application. Example
SystemExit is used when
exit
is being called from the Ruby application. When this is raised the developer wants the application to stop. Example
If we rescue Exception and these exceptions are raised while an application is currently running the
begin ... rescue ... end
block it cannot exit.
It's generally a bad idea to rescue Exception in normal situations. When rescuing Exception, you'll prevent SignalException and SystemExit to function, but also LoadError, SyntaxError and NoMemoryError, to name a few. It's better to rescue more specific exceptions instead.
Failures in tests
When Exception is rescued, using rescue Exception => e
, other things beside your application could break. The test suite could actually be hiding some errors.
In minitest and RSpec assertions that fail will raise an exception to inform you about the failed assertion, failing the test. When they do, they raise their own custom exceptions, subclassed from Exception.
If Exception is rescued in a test or in the application code, it could be silencing an assertion failure.
1# RSpec example
2def foo(bar)
3 bar.baz
4rescue Exception => e
5 puts "This test should actually fail"
6 # Failure/Error: bar.baz
7 # <Double (anonymous)> received unexpected message :baz with (no args)
8end
9
10describe "#foo" do
11 it "hides an 'unexpected message' exception" do
12 bar = double(to_s: "")
13 foo(bar)
14 end
15end
Expecting exceptions
Some code is meant to raise exceptions. In a test suite it's possible to simply silence the exception in order to have the test not fail when they are raised.
1def foo
2 raise RuntimeError, "something went wrong"
3end
4
5foo rescue RuntimeError
However, this doesn't test if an exception was raised or not. When the exception is not raised, your test won't be able to tell if the behavior is still correct.
It's possible to assert if the exception is raised, and if not, which exception was.
1# expecting_exceptions_spec.rb
2# RSpec example
3def foo
4 raise NotImplementedError, "foo method not implemented"
5end
6
7describe "#foo" do
8 it "raises a RuntimeError" do
9 expect { foo }.to raise_error(RuntimeError)
10 end
11end
11) #foo raises a RuntimeError
2 Failure/Error: expect { foo }.to raise_error(RuntimeError)
3
4 expected RuntimeError, got #<NotImplementedError: foo method not implemented> with backtrace:
5 # ./expecting_exceptions_spec.rb:4:in `foo'
6 # ./expecting_exceptions_spec.rb:9:in `block (3 levels) in <top (required)>'
7 # ./expecting_exceptions_spec.rb:9:in `block (2 levels) in <top (required)>'
8 # ./expecting_exceptions_spec.rb:9:in `block (2 levels) in <top (required)>'
Re-raise Exception
An application should only capture exceptions as high up in the chain as the Exception class when there's a very good reason. For example, when there's some cleanup involved before exiting a block of code, like removing temporary files that really need to be removed.
One recommendation for when you absolutely have to rescue Exception, re-raise it after you're done handling the error. This way the Ruby exception handling can decide the fate of the process afterward.
1File.open("/tmp/my_app.status", "w") { |f| "running" }
2
3begin
4 foo
5rescue Exception => e
6 Appsignal.add_error e
7 File.open("/tmp/my_app.status", "w") { |f| "stopped" }
8 raise e
9end
Unsure what to rescue?
As mentioned earlier, it's good to be specific in what errors to rescue.
When you're unsure what exceptions an operation can raise, rescuing StandardError can be a good place to start. Run your code in different scenarios and see what exceptions it raises.
1begin
2 File.open('/tmp/appsignal.log', 'a') { |f| f.write "Starting AppSignal" }
3rescue => e
4 puts e.inspect
5end
6#<Errno::EACCES: Permission denied @ rb_sysopen - /tmp/appsignal.log>
Every time you come across a new exception, add specific rescue cases for those exceptions or its relevant parent class. It's better to be specific in what to rescue than to rescue too many exceptions.
1begin
2 file = '/tmp/appsignal.log'
3 File.open(file, 'a') { |f| f.write("AppSignal started!") }
4rescue Errno::ENOENT => e
5 puts "File or directory #{file} doesn't exist."
6rescue Errno::EACCES => e
7 puts "Cannot write to #{file}. No permissions."
8end
9
10# Or, using the parent error class
11begin
12 file = '/tmp/appsignal.log'
13 File.open(file, 'a')
14rescue SystemCallError => e
15 puts "Error while writing to file #{file}."
16 puts e
17end
This concludes our primer on exceptions handling in Ruby. Let us know at @AppSignal if you want to know more, or have a specific question. If you want to get a better insight in where and how often exceptions are raised in your app, give AppSignal a try.