Did you know they have ashtrays in Airplane bathrooms even tho smoking is banned inside!? It turns out there is a good reason for them, and you’ll be glad they’re there.
In case a passenger does illegally sneak a ciggy, planes must have a safe place to put out the butts.
This intriguing detail has significantly influenced my perspective on system design. Having that ashtray even though smoking is banned just shows how critical it is to ensure safety in Airplanes and I've always wanted the systems I build to be similar in this aspect. Resilient and Robust in the face of a bad input or an error.
So I started reading about this stuff and boy is it confusing! Rightly so, because exception handling is pretty context-heavy. Right from what the business logic dictates to how "confidently" the code has been written, it can vary quite a bit.
If you too are tired of seeing nested being/rescue
blocks or abused nil
checks in your codebase, this series is for you!
So, in this and the following articles as I dive deeper(and implement them) we will be able to gain a much better understanding of how to deal with exceptions or the spaghetti code that surrounds it.
Let's start with some first-principle thinking.
My code works as it is, why should I care?
In school you might have heard the expression, "Garbage in, garbage out." That expression is essentially software development's version of caveat emptor: let the user beware.
For production software, garbage in, garbage out isn't good enough. A good program never puts out garbage, regardless of what it takes in. A good program uses "garbage in, nothing out," "garbage in, error message out," or "no garbage allowed in" instead. By today's standards, "garbage in, garbage out" is the mark of a sloppy, nonsecure program.
From the above quote and airplane example, it's evident that handling rogue or incorrect inputs is crucial for system integrity. This is particularly vital in critical systems like banking or payments, where explicit error handling is essential. In less critical systems, logging and moving on may suffice. However, in all cases, mastering failure-handling techniques is key to ensuring our systems are both reliable and robust.
Understanding Failures in Programming
Every element in a codebase serves a purpose – if it doesn't, it's probably safe to let it go. When something in that element doesn't do what it's supposed to, that's what we call a failure.
So how or when can a code element fail? Let's see a few common reasons
# Implementation issue
h = {:age=> 23}; h["age"] += 1
# External issue
HTTP.get_response(URL).code # => 500
# System/Hardware issue, eg: ran out of memory
# NoMemoryError
Okay, so failure can be caused by us or the hardware or due to someone else’s mistake, but it's our responsibility to handle this and ensure this doesn't break our system.
Now we understand what is meant by failure, the question that arises is;
"How does our program tell us there is a failure?"
This question has always confused me since some terms are used interchangeably in software, ie: Exception and Error.
Exception vs Error
These terms are commonly used in software so often that it is not my cup of tea to be able to define them(without creating more confusion). So I will just quote sources that made it actually clear for me!
- A failure is the inability of a software element to satisfy its purpose.
- An exception is the occurrence of an abnormal condition during the execution of a software element.
- An error is the presence in the software of some element not satisfying its specification.
from Object Oriented Software Construction
So the answer to the question, how does a program tell us something has failed?
It does so by raising an Exception.
And one might say, these exceptions are caused due to errors.
Still not clear enough? Steve McConnell explains it beautifully in Code Complete:
Exceptions are a specific means by which code can pass along errors or exceptional events to the code that called it. 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 that has no sense of the context of an error can return control to other parts of the system that might have a better ability to interpret the error and do something useful about it.
- code complete
The Exception Tree
Okay now let's talk in terms of Ruby. Exceptions are simply classes that the Ruby library has predefined for us.
Below is a list of exception classes that ship with Ruby’s standard library. Third-party gems like Rails will add additional exception classes to this chart. Every additional exception from Rails will inherit from some class on this list.
Exception
NoMemoryError
ScriptError
LoadError
NotImplementedError
SyntaxError
SignalException
Interrupt
StandardError
ArgumentError
IOError
EOFError
IndexError
LocalJumpError
NameError
NoMethodError
RangeError
FloatDomainError
RegexpError
RuntimeError
SecurityError
SystemCallError
SystemStackError
ThreadError
TypeError
ZeroDivisionError
SystemExit
fatal
So any error that can happen in Ruby will be a part of one of the above classes. Let's see a few of them just to make things clear.
- NoMemoryError - This is raised when memory allocation fails and the application must halt.
-
SignalException::Interrupt - This is raised when you press
ctrl + c
to stop your program. -
ScriptError::SyntaxError - Syntax errors mean that when your program comes across things like
p "this quote is not closed
, it will raise this. - __ StandardError__ - As the name implies, StandardError is the most common or standard type of exception Ruby will raise. Most of the usual errors you see are part of this.
Raising these exceptions is Ruby's way of telling us something is wrong and what that is.
What does Raise actually mean?
When an exception is raised (either explicitly with raise or implicitly by the Ruby runtime), an instance of an Exception class
(or one of its subclasses) is created. This object contains information about the exception, such as an error message and a backtrace.
It also means that an exception object has been created and the normal flow of program execution has been interrupted.
NOTE: This is not like
return ExceptionObj
This process involves more than just returning an object; it's a specific type of control flow mechanism, in which the normal flow of program execution has been interrupted.
Let's understand with a simple example:
def divide_numbers(x, y)
result = x / y
puts "Result of division is #{result}"
end
puts "Program starts"
divide_numbers(10, 0)
puts "Program ends"
- The program prints "Program starts".
- The
divide_numbers
method is called with 10 and 0 as arguments. - Inside divide_numbers, the division x / y is attempted. Since y is 0, Ruby raises a
ZeroDivisionError
. - The exception interrupts the normal flow of the program. The line
puts "Result of division is #{result}"
is never executed because the exception has been raised before this line. - Since there's no rescue block to catch the ZeroDivisionError, the exception propagates up the call stack. In this case, it propagates back to the top level of the script.
- No part of the script handles the exception, so it causes the program to terminate.
The final puts "Program ends" line is never executed because the program has already been interrupted and terminated by the unhandled exception.
When you run this script, you'll see the "Program starts" message, followed by an error message indicating a ZeroDivisionError
, and the "Program ends" message will not be displayed.
So, returning an exception object is fundamentally different from raising one. Returning an exception object treats it like any other value, requiring explicit checks and handling by the caller. Hence, an exception is always raised and not returned.
If you want to manually raise an exception, you can do so using raise
or fail
in Ruby like this
raise "This will raise an exception!"
By default, if you don't specify anything, this will assume the exception is a RuntimeError
.
The raise method accepts arguments in this format
raise [EXCEPTION_CLASS], [MESSAGE], [BACKTRACE]
There is enough documentation on how these methods work, so I won't dive into them.
An Exception is raised, what now?
Now that we understand an exception will break the flow and is just thrown out there for someone to "catch" it, we must mindfully handle these.
For eg: If in a Rails controller flow, an exception is raised and you don't handle it manually, Rails middleware will handle it and give a 500 Internal Server Error
.
Ruby gives you the begin..rescue..ensure..end
block to handle these exceptions.
Note: Ruby also has a
BEGIN...END block
too, which is different from thebegin..end
block.
This is how the being..rescue
block looks like
begin
raise 'This exception will be rescued!'
rescue StandardError => e
puts "Rescued: #{e.inspect}"
end
If you don't mention anything in rescue, by default Ruby rescues the StandardError
class. This is very much intended. As to why the default is not the Exception
class, this will give you a clear idea. https://www.honeybadger.io/blog/ruby-exception-vs-standarderror-whats-the-difference/
NOTE: You should never rescue the
Exception
class.
Now if we want to write the division code shown before with proper error handling, it will be
def divide(a, b)
begin
result = a / b
rescue ZeroDivisionError => e
puts "Error: #{e.message}"
result = nil
end
return result
end
puts divide(10, 2) # Output: 5
puts divide(10, 0) # Output: Error: divided by 0
Great! What next?
Okay, so we now understand failures, exceptions, errors and how to address them. The big question is what are the best practices around this? When should I raise an exception manually? How should I write code so it is easy to manage errors?
Understanding these things is crucial to being confident that your code works as it was intended to. We will cover these things in the next article. But before I go, I will leave you with this quote from Code Complete
Throw an exception only for conditions that are truly exceptional. Exceptions should be reserved for conditions that are truly exceptional—in other words, for conditions that cannot be addressed by other coding practices.
Top comments (0)