A little while ago we talked about exceptions in Ruby. This time we explore ways of creating custom exceptions specific to your app’s needs.
Let's say we have a method that handles the uploading of images while only allowing JPEG images that are between 100 Kilobytes and 10 Megabytes. To enforce these rules we raise an exception every time an image violates them.
class ImageHandler
def self.handle_upload(image)
raise "Image is too big" if image.size > 10.megabytes
raise "Image is too small" if image.size < 100.kilobytes
raise "Image is not a JPEG" unless %w[JPG JPEG].include?(image.extension)
#… do stuff
end
end
Every time a user uploads an image that doesn't meet the rules, our (Rails) web app displays the default Rails 502 error page for the uncaught error.
class ImageUploadController < ApplicationController
def upload
@image = params[:image]
ImageHandler.handle_upload(@image)
redirect_to :index, :notice => "Image upload success!"
end
end
The Rails generic error page doesn't offer the user much help, so let's see if we can improve on these errors. We have two goals: inform the user when the file size is outside the set bounds and prevent hackers from uploading potentially malicious (non-JPEG) files, by returning a 403
forbidden status code.
Custom error types
Almost everything in Ruby is an object, and errors are no exception. This means that we can subclass from any error class and create our own. We can use these custom error types in our handle_upload
method for different validations.
class ImageHandler
# Domain specific errors
class ImageExtensionError < StandardError; end
class ImageTooBigError < StandardError
def message
"Image is too big"
end
end
class ImageTooSmallError < StandardError
def message
"Image is too small"
end
end
def self.handle_upload(image)
raise ImageTooBigError if image.size > 10.megabytes
raise ImageTooSmallError if image.size < 100.kilobytes
raise ImageExtensionError unless %w[JPG JPEG].include?(image.extension)
#… do stuff
end
end
First, we've added three new classes to the handler that extend from StandardError
. For the image size errors, we've overridden the message
method of StandardError
with an error message we can show to users. The way raise
was called in the handle_upload
method has also changed, by replacing the custom StandardError
message with a different error type we can raise a different, more specific, error.
Now, we can use these custom error types in our controller to return different responses to errors. For instance, we can return the specific error message or a specific response code.
class ImageUploadController < ApplicationController
def upload
@image = params[:image]
ImageHandler.handle_upload(@image)
redirect_to :index, :notice => "Image upload success!"
rescue ImageHandler::ImageTooBigError, ImageHandler::ImageTooSmallError => e
render "edit", :alert => "Error: #{e.message}"
rescue ImageHandler::ImageExtensionError
head :forbidden
end
end
This is already a lot better than using the standard raise
calls. With a little bit more subclassing we can make it make it easier to use, by rescuing entire error groups rather than every error type separately.
class ImageHandler
class ImageExtensionError < StandardError; end
class ImageDimensionError < StandardError; end
class ImageTooBigError < ImageDimensionError
def message
"Image is too big"
end
end
class ImageTooSmallError < ImageDimensionError
def message
"Image is too small"
end
end
def self.handle_upload(image)
raise ImageTooBigError if image.size > 10.megabytes
raise ImageTooSmallError if image.size < 100.kilobytes
raise ImageExtensionError unless %w(JPG JPEG).include?(image.extension)
#… do stuff
end
end
Instead of rescuing every separate image dimension exception, we can now rescue the parent class ImageDimensionError
. This will rescue both our ImageTooBigError
and ImageTooSmallError
.
class ImageUploadController < ApplicationController
def upload
@image = params[:image]
ImageHandler.handle_upload(@image)
redirect_to :index, :notice => "Image upload success!"
rescue ImageHandler::ImageDimensionError => e
render "edit", :alert => "Error: #{e.message}"
rescue ImageHandler::ImageExtensionError
head :forbidden
end
end
The most common case for using your own error classes is when you write a gem. The mongo-ruby-driver gem is a good example of the use of custom errors. Each operation that could result in an exception has its own exception class, making it easier to handle specific use cases and generate clear exception messages and classes.
Another advantage of using custom exception classes is that when using exception monitoring tools like AppSignal. These tools give you a better idea as to where exceptions occurred, as well as grouping similar errors in the user interface.
If you liked this article, check out more of what we wrote on AppSignal Academy. AppSignal is all about building better apps. In our Academy series, we'll explore application stability and performance, and explain core programming concepts.
We'd love to know what you thought of this article, or if you have any questions. We're always on the lookout for topics to investigate and explain, so if there's anything magical in Ruby you'd like to read about, don't hesitate to leave a comment.
Top comments (3)
Writing literate exceptions is great advice! I'm certainly guilty of wrapping everything in a simple
RunTimeError
withraise "here's what happened, bub!"
.However, I'm wary of the recommendation in this article about using literate errors as an alternate to existing control flow.
Exceptions have the special property of throwing until caught. Unlike other control flow, exceptions need to be caught. If you're planning on catching something in the caller, it's probably good to use control flow that doesn't re-raise.
If you're planning on catching something in the caller of the caller, it's probably a good idea to refactor your code so you're not zipping back and forth between multiple layers. E.g. if you're always going to catch something two levels down, using an exception as a fancy "GOTO" makes reasoning about what's going on quite difficult.
There's a lot written about this "sometimes anti-pattern". Maybe most famously wiki.c2.com/?DontUseExceptionsForF...
Here's an attempt to handle the same situation with explicit control flow, e.g. maybe a case statement.
In this case, we know that
handle_upload
is local, expected edges are handled locally, and unexpected edges are thrown (as they should).Hi Michael, thanks for your excellent suggestion. I've discussed this while writing the article with my colleagues and we came to the same conclusion.
I still went ahead with the current example, because it's compact, easily understandable and covers all the scenarios I wanted to explain. I'm planning to write another article that covers the "anti-pattern" you rightfully raised.
Excellent suggestion, thanks!