Happy Birthday Josh!
About this Talk
By the end of this talk you should:
- Understand the mechanics of Ruby failure handling
- Be able to architect a robust failure handling strategy
For apps or libraries.
- Be exceptionally bored
Definitions
- This talk is about failure
- Exceptions are how we signal failures
- Failures are often caused by errors
- So what is a failure?
Every method has a contract
- Bertrand Meyer, Design by Contract
- Ruby's exception system influenced by Eiffel
- Preconditions and postconditions
- Either implicit or explicit
The Definition of Failure
A failure occurs when code is unable to fulfill its contract.
- A mistake in usage
User.find(nil)
- A mistake in the code
h = {:count => 42}; h["count"] += 1
The Definition of Failure cont'd
- An unexpected case
case message_type when "success" then # ... when "failure" then # .... else # uh oh... end
- Failure of an external element
HTTP.get_response(url).code # => 500
Lifecycle of an Exception
In some cases it may not be acceptable to know that a failure will lead to termination and a message. You may want to take control. – Object Oriented Software Construction
If code in one routine encounters an unexpected condition that it doesn't know how to handle, it throws an exception, essentially throwing up its hands and yelling "I don't know what to do about this–I sure hope somebody else knows how to handle it!" – Code Complete
Raising Exceptions
raise
(or fail
)
- Every exception starts with a
raise
(orfail
)
- Which to use?
I almost always use the "fail" keyword… the only time I use "raise" is when I am catching an exception and re-reaising it, because here I'm not failing, but explicitly and purposefully raising an exception. – Jim Weirich
begin fail "Oops"; rescue => error raise if error.message != "Oops" end
raise
with no arguments
raise
… is equivalent to:
raise RuntimeError
raise
with a string
Raises a RuntimeError
with the string as its message.
raise "Doh!"
… is equivalent to:
raise RuntimeError, "Doh!"
raise
with an error class
Raises an object of the specified class.
raise ArgumentError, "Doh!"
raise
with a custom backtrace
def assert(value) raise(RuntimeError, "Doh!", caller) unless value end assert(4 == 5)
…results in:
set_backtrace.rb:4: Failed (RuntimeError)
raise
is a method on Kernel
A method, not a keyword.
This means we can override it!
Overriding raise
Make exceptions instantly fatal:
module RaiseExit def raise(msg_or_exc, msg=msg_or_exc, trace=caller) warn msg.to_s exit! 1 end end module Kernel include RaiseExit end
- Note: No override in C extensions
Hammertime
An error console for ruby: http://github.com/avdi/hammertime
require 'hammertime' raise "Oh no!"
=== Stop! Hammertime. === An error has occurred at example.rb:2:in `raise_runtime_error' The error is: #<RuntimeError: Oh no!> 1. Continue (process the exception normally) 2. Ignore (proceed without raising an exception) 3. Permit by type (don't ask about future errors of this type) 4. Permit by line (don't ask about future errors raised from this point) 5. Backtrace (show the call stack leading up to the error) 6. Debug (start a debugger) 7. Console (start an IRB session) What now?
raise
internals
raise SomeError, "Bad stuff"
-
Call
#exception()
to get the exception. -
Call
#set_backtrace
-
Set
$!
- Throw exception up the call stack
Step 1: #exception()
You might think raise
does this:
def raise(klass, message, backtrace) error = klass.new(message) # Nope! # ... end
But in fact it does this:
def raise(error_class_or_obj, message, backtrace) error = error_class_or_obj.exception(message) # ... end
.exception()
Exception.exception
Equivalent to callingException.new()
.
Exception#exception
- With no arguments, returns self.
- With a message, returns a new exception.
- With no arguments, returns self.
Implement your own #exception()
class Net::HTTPResponse def exception(message="HTTP Error") RuntimeError.new("#{message}: #{code}") end end # ... response = Net::HTTP.get_response(url) raise response
exception_method.rb:11: HTTP Error: 404 (RuntimeError)
Step 2: #set_backtrace
Unless being re-raised with no extra arguments
Step 3: Set $!
require 'English' puts $!.inspect begin raise "Oops" rescue puts $!.inspect puts $ERROR_INFO.inspect end puts $!.inspect
nil #<RuntimeError: Oops> #<RuntimeError: Oops> nil
Step 4: Throw it up the call stack
Until it hits a rescue
, ensure
, or exits the program.
rescue
In practice, the rescue clause should be a short sequence of simple instructions designed to bring the object back to a stable state and to either retry the operation or terminate with failure. – Object Oriented Software Construction
rescue
with no arguments Incremental
rescue # ... end
- Equivalent to…
rescue StandardError # ... end
- Won't catch:
NoMemoryError
LoadError
NotImplementedError
SignalException
Interrupt
- And others…
rescue
with just a name
rescue => error # ... end
…is equivalent to…
rescue StandardError => error # ... end
rescue
with a class
Only catches errors of that class.
rescue SomeError => error # ... end
Also accepts a list of classes:
rescue SomeError, SomeOtherError => error # ... end
ensure
begin raise "Uh oh" ensure # will always get here end
- Always executed
- Good place for required cleanup
ensure
with explicit return
An unexpected edge case documented by Les Hill:
begin raise Exception, "Very bad!" ensure return "A-OK" end
A-OK
Ensure with an explicit return will eat any exception as if it had been caught and thrown away!
retry
tries = 0 begin tries += 1 puts "Trying #{tries}..." raise "Didn't work" rescue retry if tries < 3 puts "I give up" end
Trying 1... Trying 2... Trying 3... I give up
raise
during exception handling
raise
a new error
begin raise "Error A" rescue raise "Error B" end
- No way to discover original failure
- Rails does this a lot
- Please don't do this
Nested Exceptions
class MyError < StandardError attr_reader :original def initialize(msg, original=nil); super(msg); @original = original; end end # ... rescue => error raise MyError.new("Error B", error) end
Re-raise with the caught error
Re-raises the same object again.
begin begin raise "Error A" rescue => error puts error.object_id.to_s(16) raise error end rescue => error puts error.object_id.to_s(16) end
-244072b8 -244072b8
Re-raise with new message
Calls #exception()
to create a new message.
begin begin raise "Error A" rescue => error puts error.inspect + ": " + error.object_id.to_s(16) raise error, "Error B" end rescue => error puts error.inspect + ": " + error.object_id.to_s(16) end
#<RuntimeError: Error A>: -243a6b7a #<RuntimeError: Error B>: -243a6bde
Re-raise with message and backtrace
Calls #exception()
and #set_backtrace
begin begin raise "Error A" rescue => error puts error.backtrace.first raise error, "Error B", ["FAKE:42"] end rescue => error puts error.backtrace.first end
-:3 FAKE:42
Re-raise with no arguments
begin raise "Error A" rescue => error raise end
…is equivalent to…
begin raise "Error A" rescue => error raise $! end
Disallowing double-raise
module NoDoubleRaise def error_handled! $! = nil end def raise(*args) if $! warn "Double raise at #{caller.first}, aborting" exit! else super end end end
Using NoDoubleRaise
module Kernel include NoDoubleRaise end begin raise "First error" rescue error_handled! raise "Second error" end
else
def foo yield rescue puts "Only on error" else puts "Only on success" ensure puts "Always executed" end foo{ raise "Error" } puts "---" foo{ "No error" }
Only on error Always executed --- Only on success Always executed
Uncaught exceptions
trap "EXIT" { puts "trap" } at_exit { puts "at_exit" } END { puts "END" } raise "Not handled"
trap END at_exit unhandled.rb:5: Not handled (RuntimeError)
A simple crash logger
at_exit do if $! open('crash.log', 'a') do |log| error = { :timestamp => Time.now, :message => $!.message, :backtrace => $!.backtrace, :gems => Gem.loaded_specs.inject({}){ |m, (n,s)| m.merge(n => s.version) } } YAML.dump(error, log) end end end
What about in threads?
t = Thread.new { raise "oops" } begin t.join rescue => error puts "Child raised error: #{error.inspect}" end
Child raised error: #<RuntimeError: oops>
Are exceptions slow?
- Yes.
With Ruby 1.8.7, the algorithm… is about 37 times slower when Exceptions are involved. – Simon Carletti
- Ruby 1.9.1 has similar performance
Responding to Failures
Return an error value
Typically nil
def save # ... rescue nil end
Return a benign value
The system might replace the erroneous value with a phony value that it knows to have a benign effect on the rest of the system. – Code Complete
begin response = HTTP.get_response(url) JSON.parse(response.body) rescue Net::HTTPError {"stock_quote" => "<Unavailable>"} end
The humble puts
puts "Uh oh, something bad happened" $stderr.puts "Waiting..." sleep 1 puts "Done"
…produces:
Waiting... Uh oh, something bad happened Done
- Not auto-flushed by default
$stderr.puts
$stderr.puts "Uh oh, something bad happened"
- But there's a better way…
warn
warn "Uh oh, something bad happened"
- Auto-flushed
- Shorter
Warnings as errors
- We all know warnings can hide real problems…
module Kernel def warn(message) raise message end end warn "Uh oh"
-:3:in `warn': Uh oh (RuntimeError) from -:7
Remote Failure Reporting
- Log a message
- Send an email
- Third-party Services
- Hoptoad
http://hoptoadapp.com/
- New Relic RPM
http://newrelic.com
- Hoptoad
- Here be dragons
Avoid the failure cascade
- Failures have a way of multiplying
- Circuit Breaker
Described by Michael Nygard in "Release It"
- States
- Closed
- Open
- Half-Open
- Closed
- Ruby implementation
https://github.com/wsargent/circuit_breaker
exit(1)
Raises SystemExit
warn "Uh oh"
exit 1
…is quivalent to:
warn "Uh oh" raise SystemExit.new(1)
There's a better way…
abort
abort "Uh oh" puts "Will never get here."
- Prints a message
- Raises SystemExit with a failure status
exit!(1)
warn "Alas, cruel runtime!"
exit! 1
- Immediately exits
- Does not pass go. Does not collect $200.
- No cleanup.
Your Failure Handling Strategy
Error processing is turning out to be one of the thorniest problems of modern computer science, and you can't afford to deal with it haphazardly. – Code Complete
Exceptions shouldn't be expected
Use exceptions only for exceptional situations. […] Exceptions are often overused. Because they distort the flow of control, they can lead to convoluted constructions that are prone to bugs. It is hardly exceptional to fail to open a file; generating an exception in this case strikes us as over-engineering. – The Practice of Programming
- Invalid user input isn't unusual
- ActiveRecord's
#save
A rule of thumb for raising
We believe that exceptions should rarely be used as part of a program's normal flow; exceptions should be used for unexpected events. …ask yourself, 'Will this code still run if I remove all the exception handlers?" If the answer is "no", then maybe exceptions are being used in nonexceptional circumstances. – The Pragmatic Programmer
Use throw
for expected cases
Sinatra example:
get '/foo' do last_modified some_timestamp # ...expensive GET logic... end
Implementation (simplified):
def last_modified(time) response['Last-Modified'] = time request.env['HTTP_IF_MODIFIED_SINCE'] > time throw :halt, response end end
What constitutes an exception?
Detect errors at a low level, handle them at a high level. […] In most cases, the caller should determine how to handle an error, not the callee. – The Practice of Programming
- Is an EOF a failurer? Is a missing hash key an failure?
- How about and HTTP 404?
- Answer: it depends!
- By raising an exception you force the issue
- When in doubt, punt!
Caller-defined failure strategy
h.fetch(:optional_key){ DEFAULT_VALUE } h.fetch(:required_key) { raise "Required key not found!" } arr.detect(lambda{"None found"}) {|x| ... }
def my_awesome_method # ... if value # ... else yield end end
Questions before raising
- When should I raise?
- When shouldn't I raise?
Question #1
Is the situation truly unexpected?
puts "Confirm (y/n)" answer = gets.chomp raise "Huh?" unless [?y, ?n].include?(answer)
Question #2
Am I prepared to end the program?
@ug = UserGreeting.find_by_name!("winter_holidays")
Vs.
@ug = UserGreeting.find_by_name("winter_holidays") unless @ug logger.error "Someone forgot to run db:populate" @ua = OpenStruct.new(:welcome => "Hello") end
Question #3
Can I punt the decision?
def fetch_quotes(options => {}) on_error = options.fetch(:on_error) { lambda{|r| raise "Failure fetching quotes: #{r.code}"}} response = do_fetch(...) if !response.success? on_error.call(response) end # ... end
Question #4
Am I throwing away valuable diagnostics?
result = some_fifteen_minute_operation() if result.has_raisins_in_it? raise "I HATE RAISINS" end
Vs.
result = some_fifteen_minute_operation() if result.has_raisins_in_it? problem_flags << :icky_raisins end # ...
Question #5
Would continuing result in a less informative exception?
response_code = might_return_nil() message = codes_to_messages[response_code] response = "System Status: " + message # What do you mean "Can't convert nil into string"?!
Vs.
response_code = might_return_nil() or fail "No response code" # ...
Isolate exception handling code
as exception represents an immediate, nonlocal transfer of control - it's a kind of cascading
goto
. Programs that use exceptions as part of their normal processing suffer from all the readability and maintainability problems of classic spaghetti code. – The Pragmatic Programmer
begin
is a code smell
begin try_something rescue begin try_something_else rescue # handle failure end end end
Method-level exception clauses
def foo # mainline logic goes here rescue # error handling goes here end
Guard methods
begin something_that_might_fail rescue IOError # handle IOError end
…becomes…
def handle_io_errors yield rescue # handle IOError end handle_io_errors { something_that_might_fail }
Exception Safety
Should a library attempt a recovery when something goes wrong? Not usually, but it might do a service by making sure it leaves information in as clean and harmless a state as possible. – The Practice of Programming
A method's exception safety describes how it will behave in the presence of exceptions.
Critical methods need known exception semantics.
The Three Guarantees
- The weak guarantee
The object will be left in a consistent state.
- The strong guarantee
The object will be rolled back to its beginning state.
- The nothrow guarantee
No exceptions will be raised.
When you least expect it Incremental ShowFirst
- What parts of this code can raise exceptions?
size = File.size('/home/avdi/.emacs') kbytes = size / 1024 puts "File size is #{size}k"
- Any of it!
- NoMemoryError
- SignalException
- Etc.
- NoMemoryError
Exception testing
- A test script
- An assertion or assertions
- Record
Execute script, record a set of call-points
- Playback
Execute once for every call-point, forcing exception
Code Under Test
def swap_keys(hash, x_key, y_key) temp = hash[x_key] hash[x_key] = hash[y_key] hash[y_key] = temp end
Record
h = {:a => 42, :b => 23} tester = ExceptionTester.new{ swap_keys(h, :a, :b) }
Playback
tester.assert{ # Assert the keys are either fully swapped # or not swapped at all (h == {:a => 42, :b => 23}) || (h == {:a => 23, :b => 42}) }
Validity vs. Consistency
An object can be consistent but invalid.
- Validity
Are the business rules for the data met?
- Consistency
Can the object operate without crashing, or exhibiting undefined behavior?
Be specific when eating exceptions
Please don't do this:
begin # ... rescue Exception end
If you can't match on class, match on message:
begin # ... rescue => error raise unless error.message =~ /foo bar/ end
Define your own error base class
- Especially important in libraries
- Wrap other exceptions
A routine should present a consistent abstraction in its interface, and so should a class. The exceptions thrown are part of the routine interface, just like the specific data types are. – Steve McConnell
rescue MyLibrary::Error raise rescue => error raise MyLibrary::Error.new( "#{error.class}: #{error.message}", error) end
What exception classes do I need?
- Based on module?
- Based on software layer?
- Based on severity?
- Let's try working backwards…
User Error
"That is not a valid selection. Please check your entry and try again."
Logic Error
"Something has gone wrong. We have been informed and are working on it."
Transient Error
"That service is temporarily unavailable. Please try again later."
Three essential exception classes
- UserError
- LogicError
- InternalError
- ClientError (for libraries)
- InternalError
- TransientError
- TimeoutError
- TimeoutError
A Recap
…is not going to happen.
Thank You!
- References
http://avdi.org/devblog/exceptional-ruby
- Contact
- Home: http://avdi.org
- Email: avdi@avdi.org
- Twitter: @avdi
- Photo Credits
"Panic Button" by Michael Manitius