exception handling: designing robust software in ruby
DESCRIPTION
TRANSCRIPT
Exception Handling: Designing Robust Software
[email protected] 2014/9/27@RailsPacific
About me• 張⽂文鈿 a.k.a. ihower
• http://ihower.tw
• http://twitter.com/ihower
• Instructor at ALPHA Camp
• http://alphacamp.tw
• Rails Developer since 2006
• i.e. Rails 1.1.6 era
Agenda
• Why should we care? (5min)
• Exception handling in Ruby (10min)
• Caveat and Guideline (15min)
• Failure handling strategy (15min)
I’m standing on the two great books, thanks.
1. Why should we care?
Feature complete ==
Production ready?
All tests pass?
Happy Path!
Time
Cost Quality
Pick Two?
abstract software architecture to reduce
development cost
Development cost !=
Operational cost
– Michael T. Nygard, Release It!
“Don't avoid one-time development expenses at the cost of recurring
operational expenses.”
My confession
2. Exception handling in Ruby
– Steve McConnell, Code Complete
“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!.”
– Devid A. Black, The Well-Grounded Rubyist
“Raising an exception means stopping normal execution of the program and either dealing with the problem that’s been encountered or exiting
the program completely.”
1. raise exceptionbegin # do something raise 'An error has occured.' rescue => e puts 'I am rescued.' puts e.message puts e.backtrace.inspect end
1/5
raise == fail
begin # do something fail 'An error has occured.' rescue => e puts 'I am rescued.' puts e.message puts e.backtrace.inspect end
– Jim Weirich, Rake author
“I almost always use the "fail" keyword. . . [T]he only time I use “raise” is when I am catching an exception and re-raising it,
because here I’m not failing, but explicitly and purposefully raising an exception.”
“raise” method signature
!raise(exception_class_or_object, message, backtrace)
raise
raise!# is equivalent to:!raise RuntimeError
raise(string)
raise 'An error has occured.'!# is equivalent to :!raise RuntimeError, "'An error has occured.'
raise(exception, string)
raise RuntimeError, 'An error has occured.'!# is equivalent to :!raise RuntimeError.new('An error has occured.')
backtrace (Array of string)
raise RuntimeError, 'An error'!# is equivalent to :!raise RuntimeError, 'An error', caller(0)
global $! variable
!
• $!
• $ERROR_INFO
• reset to nil if the exception is rescued
2. rescue
rescue SomeError => e # ...end
2/5
rescue SomeError, SomeOtherError => e # ...end
Multiple class or module
rescue SomeError => e # ...rescue SomeOtherError => e # ...end
stacking rescue order matters
rescue # ...end!# is equivalent to:!rescue StandardError # ...end
Ruby Exception Hierarchy• Exception
• NoMemoryError
• LoadError
• SyntaxError
• StandardError -- default for rescue
• ArgumentError
• IOError
• NoMethodError
• ….
Avoid rescuing Exception class
rescue Exception => e # ...end
rescue => error # ...end # is equivalent to:!rescue StandardError => error # ...end
support * splatted active_support/core_ext/kernel/reporting.rb
suppress(IOError, SystemCallError) do open("NONEXISTENT_FILE")end!puts 'This code gets executed.'
!def suppress(*exception_classes) yieldrescue *exception_classes # nothing to doend
support * splatted (cont.) active_support/core_ext/kernel/reporting.rb
Like case, it’s support ===
begin raise "Timeout while reading from socket"rescue errors_with_message(/socket/) puts "Ignoring socket error"end
def errors_with_message(pattern) m = Module.new m.singleton_class.instance_eval do define_method(:===) do |e| pattern === e.message end end mend
arbitrary block predicate
begin raise "Timeout while reading from socket"rescue errors_matching{|e| e.message =~ /socket/} puts "Ignoring socket error"end
def errors_matching(&block) m = Module.new m.singleton_class.instance_eval do define_method(:===, &block) end mend
– Bertrand Meyer, Object Oriented Software Construction
“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.”
3. ensurebegin # do something raise 'An error has occured.' rescue => e puts 'I am rescued.' ensure puts 'This code gets executed always.'end
3/5
4. retry be careful “giving up” condition
tries = 0begin tries += 1 puts "Trying #{tries}..." raise "Didn't work"rescue retry if tries < 3 puts "I give up"end
4/5
5. elsebegin yieldrescue puts "Only on error"else puts "Only on success"ensure puts "Always executed"end
5/5
Recap• raise
• rescue
• ensure
• retry
• else
3. Caveat and Guideline
1. Is the situation truly unexpected?
1/6
– Dave Thomas and Andy Hunt, The Pragmatic Programmer
“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 non-exceptional circumstances.”
User Input error?
def create @user = User.new params[:user] @user.save! redirect_to user_path(@user)rescue ActiveRecord::RecordNotSaved flash[:notice] = 'Unable to create user' render :action => :newend
This is bad
def create @user = User.new params[:user] if @user.save redirect_to user_path(@user) else flash[:notice] = 'Unable to create user' render :action => :new endend
Record not found?
begin user = User.find(params[:id) user.do_thisrescue ActiveRecord::RecordNotFound # ???end
This is bad
Use Null object
user = User.find_by_id(params[:id) || NullUser.newuser.do_this
Replace Exception with Testdef execute(command) command.prepare rescue nil command.executeend!# =>!def execute(command) command.prepare if command.respond_to? :prepare command.executeend
– Martin Folwer, Refactoring
“Exceptions should be used for exceptional behaviour. They should not acts as substitute
for conditional tests. If you can reasonably expect the caller to check the condition before calling the operation, you should provide a test,
and the caller should use it.”
Spare Handler?begin # main implementationrescue # alternative solutionend
begin user = User.find(params[:id)rescue ActiveRecord::RecordNotFound user = NullUser.newensure user.do_thisend
This is bad
user = User.find_by_id(params[:id) || NullUser.new!user.do_this
2. raise during raise
begin raise "Error A" # this will be thrownrescue raise "Error B"end
2/6
Wrapped exception!begin begin raise "Error A" rescue => error raise MyError, "Error B" endrescue => e puts "Current failure: #{e.inspect}" puts "Original failure: #{e.original.inspect}"end
Wrapped exception (cont.)
class MyError < StandardError attr_reader :original def initialize(msg, original=$!) super(msg) @original = original; set_backtrace original.backtrace endend
Example: Rails uses the technique a lot
• ActionDispatch::ExceptionWrapper
• ActionControllerError::BadRequest, ParseError,SessionRestoreError
• ActionView Template::Error
3. raise during ensure
begin raise "Error A"ensure raise "Error B” puts "Never execute"end
3/6
begin file1.open file2.open raise "Error"ensure file1.close # if there's an error file2.closeend
a more complex examplebegin r1 = Resource.new(1) r2 = Resource.new(2) r2.run r1.run rescue => e raise "run error: #{e.message}"ensure r2.close r1.close end
class Resource attr_accessor :id! def initialize(id) self.id = id end! def run puts "run #{self.id}" raise "run error: #{self.id}" end! def close puts "close #{self.id}" raise "close error: #{self.id}" endend
begin r1 = Resource.new(1) r2 = Resource.new(2) r2.run r1.run # raise exception!!! rescue => e raise "run error: #{e.message}"ensure r2.close # raise exception!!! r1.close # never execute! end
Result lost original r1 exception and fail to close r2
run 1 run 2 close 1 double_raise.rb:15:in `close': close error: 1 (RuntimeError)
4. Exception is your method interface too
• For library, either you document your exception, or you should consider no-raise library API
• return values (error code)
• provide fallback callback
4/6
5. Exception classification
module MyLibrary class Error < StandardError endend
5/6
exception hierarchy example
• StandardError
• ActionControllerError
• BadRequest
• RoutingError
• ActionController::UrlGenerationError
• MethodNotAllowed
• NotImplemented
• …
smart exception adding more information
class MyError < StandardError attr_accessor :code! def initialize(code) self.code = code endend!begin raise MyError.new(1)rescue => e puts "Error code: #{e.code}"end
6. Readable exceptional code
begin try_something rescue begin try_something_else rescue # ... end endend
6/6
Extract it!def foo try_somethingrescue barend!def bar try_something_else # ...rescue # ...end
Recap• Use exception when you need
• Wrap exception when re-raising
• Avoid raising during ensure
• Exception is your method interface too
• Classify your exceptions
• Readable exceptional code
4. Failure handling strategy
1. Exception Safety• no guarantee
• The weak guarantee (no-leak): If an exception is raised, the object will be left in a consistent state.
• The strong guarantee (a.k.a. commit-or-rollback, all-or-nothing): If an exception is raised, the object will be rolled back to its beginning state.
• The nothrow guarantee (failure transparency): No exceptions will be raised from the method. If an exception is raised during the execution of the method it will be handled internally.
1/12
2. Operational errors v.s.
programmer errors
https://www.joyent.com/developers/node/design/errors 2/12
Operational errors• failed to connect to server
• failed to resolve hostname
• request timeout
• server returned a 500 response
• socket hang-up
• system is out of memory
Programmer errors like typo
We can handle operational errors
• Restore and Cleanup Resource
• Handle it directly
• Propagate
• Retry
• Crash
• Log it
But we can not handle programmer errors
3. Robust levels
• Robustness: the ability of a system to resist change without adapting its initial stable configuration.
• There’re four robust levels
http://en.wikipedia.org/wiki/Robustness 3/12
Level 0: Undefined
• Service: Failing implicitly or explicitly
• State: Unknown or incorrect
• Lifetime: Terminated or continued
Level 1: Error-reporting (Failing fast)
• Service: Failing explicitly
• State: Unknown or incorrect
• Lifetime: Terminated or continued
• How-achieved:
• Propagating all unhandled exceptions, and
• Catching and reporting them in the main program
Anti-pattern:Dummy handler (eating exceptions)
begin #... rescue => e nilend
Level 2: State-recovery (weakly tolerant)
• Service: Failing explicitly
• State: Correct
• Lifetime: Continued
• How-achieved:
• Backward recovery
• Cleanup
require 'open-uri'page = "titles"file_name = "#{page}.html"web_page = open("https://pragprog.com/#{page}")output = File.open(file_name, "w")begin while line = web_page.gets output.puts line end output.closerescue => e STDERR.puts "Failed to download #{page}" output.close File.delete(file_name) raiseend
Level 3: Behavior-recovery (strongly tolerant)
• Service: Delivered
• State: Correct
• Lifetime: Continued
• How-achieved:
• retry, and/or
• design diversity, data diversity, and functional diversity
Improve exception handling incrementally
• Level 0 is bad
• it’s better we require all method has Level 1
• Level 2 for critical operation. e.g storage/database operation
• Level 3 for critical feature or customer requires. it means cost * 2 because we must have two solution everywhere.
4. Use timeout for any external call
begin Timeout::timeout(3) do #... endrescue Timeout::Error => e # ...end
4/12
5. retry with circuit breaker
http://martinfowler.com/bliki/CircuitBreaker.html 5/12
6. bulkheads for external service and process
begin SomeExternalService.do_requestrescue Exception => e logger.error "Error from External Service" logger.error e.message logger.error e.backtrace.join("\n")end
6/12
!
7. Failure reporting
• A central log server
• exception_notification gem
• 3-party exception-reporting service
• Airbrake, Sentry New Relic…etc
7/12
8. Exception collectionclass Item def process #... [true, result] rescue => e [false, result] end end!collections.each do |item| results << item.processend 8/12
9. caller-supplied fallback strategy
h.fetch(:optional){ "default value" }h.fetch(:required){ raise "Required not found" }!arr.fetch(999){ "default value" }arr.detect( lambda{ "default value" }) { |x| x == "target" }
9/12
– Brian Kernighan and Rob Pike, The Practice of Programming
“In most cases, the caller should determine how to handle an error, not the callee.”
def do_something(failure_policy=method(:raise)) #...rescue => e failure_policy.call(e) end!do_something{ |e| puts e.message }
10. Avoid unexpected termination
• rescue all exceptions at the outermost call stacks.
• Rails does a great job here
• developer will see all exceptions at development mode.
• end user will not see exceptions at production mode
10/12
11. Error code v.s. exception
• error code problems
• it mixes normal code and exception handling
• programmer can ignore error code easily
11/12
Error code problem
prepare_something # return error codetrigger_someaction # still run
http://yosefk.com/blog/error-codes-vs-exceptions-critical-code-vs-typical-code.html
Replace Error Code with Exception (from Refactoring)
def withdraw(amount) return -1 if amount > @balance @balance -= amount 0end!# =>!def withdraw(amount) raise BalanceError.new if amount > @balance @balance -= amountend
Why does Go not have exceptions?
• We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional. Go takes a different approach. For plain error handling, Go's multi-value returns make it easy to report an error without overloading the return value. A canonical error type, coupled with Go's other features, makes error handling pleasant but quite different from that in other languages.
https://golang.org/doc/faq#exceptions
– Raymond Chen
“It's really hard to write good exception-based code since you have to check every single line
of code (indeed, every sub-expression) and think about what exceptions it might raise and
how your code will react to it.”
http://blogs.msdn.com/b/oldnewthing/archive/2005/01/14/352949.aspx
When use error code?!
• In low-level code which you must know the behavior in every possible situation
• error codes may be better
• Otherwise we have to know every exception that can be thrown by every line in the method to know what it will do
http://stackoverflow.com/questions/253314/exceptions-or-error-codes
12. throw/catch flowdef halt(*response) #.... throw :halt, responseend def invoke res = catch(:halt) { yield } #...end
12/12
Recap!
• exception safety
• operational errors v.s. programmer errors
• robust levels
Recap (cont.)• use timeout
• retry with circuit breaker pattern
• bulkheads
• failure reporting
• exception collection
• caller-supplied fallback strategy
• avoid unexpected termination
• error code
• throw/catch
Thanks for your listening
QUESTIONS!
begin thanks! raise if question?rescue puts "Please ask @ihower at twitter"ensure follow(@ihower).get_slides applause!end
Reference• 例外處理設計的逆襲
• Exceptional Ruby http://avdi.org/talks/exceptional-ruby-2011-02-04/
• Release it!
• Programming Ruby
• The Well-Grounded Rubyist, 2nd
• Refactoring
• The Pragmatic Programmer
• Code Complete 2
• https://www.joyent.com/developers/node/design/errors
• http://robots.thoughtbot.com/save-bang-your-head-active-record-will-drive-you-mad
• http://code.tutsplus.com/articles/writing-robust-web-applications-the-lost-art-of-exception-handling--net-36395
• http://blogs.msdn.com/b/oldnewthing/archive/2005/01/14/352949.aspx