Handling Errors
Elixir has 3 varieties of errors.
- Exceptions
- Throws
- Exits
Each type serves a different purpose. Before we dig in, it is worth mentioning that these are used far less frequently in Elixir than in other languages. Expect that your usage of these will also be “exceptional”, meaning infrequent and only when required.
Contents
Exceptions
Exceptions are not intended for managing Code Flow or control flow. They are intended for actual errors. Here’s an example of a ArithmeticError
raised exception.
:customer + 10
#=> ** (ArithmeticError) bad argument in arithmetic expression: :customer + 10
#=> :erlang.+(:customer, 10)
Raising an exception
You can raise
a RuntimeError
using the raise
keyword.
raise "something failed!"
#=> ** (RuntimeError) something failed!
You can also raise a specific error type using raise/2
. You provide the exception name/type and a message. Other attributes may be defined on an exception and using a keyword list you can set those as well.
raise ArgumentError, "you must provide a valid arg"
#=> ** (ArgumentError) you must provide a valid arg
raise ArgumentError, message: "you must provide a valid arg"
#=> ** (ArgumentError) you must provide a valid arg
Defining custom exceptions
You can create custom exceptions as well. They are defined within a module this way:
defmodule MySpecialError do
defexception message: "Something special blew up", extra: nil
end
# raising with default message
raise MySpecialError
#=> ** (MySpecialError) Something special blew up
# overriding the message
raise MySpecialError, "what just happened?"
#=> ** (MySpecialError) what just happened?
# setting :extra value requires keyword usage
raise MySpecialError, message: "custom message", extra: 123
#=> ** (MySpecialError) custom message
The defexception
works like a defstruct
where you provide the keys that your exception will have and optionally any default values.
The :extra
value is set in the MySpecialError
but a simplified error representation is written to the console. In order to access that data or handle the error, we need to use try/rescue
.
Handling with try/rescue
The try/rescue
block is used to handle raised exceptions.
try do
raise "Boom!"
rescue
e in RuntimeError ->
"Raised error: #{e.message}"
end
#=> "Raised error: Boom!"
If we wanted to handle the custom exception we defined before and access the data on the :extra
field, we can do the following.
defmodule MySpecialError do
defexception message: "Something special blew up", extra: nil
end
try do
raise MySpecialError, message: "custom message", extra: 123
rescue
e in MySpecialError ->
IO.puts("Found MySpecialError.extra value #{e.extra}")
{:error, "#{e.message}: #{e.extra}"}
end
#=> Found MySpecialError.extra value 123
#=> {:error, "custom message: 123"}
This example handles the raised custom error. The rescue
block doesn’t support full pattern matching on the exception. It expects the expression to be a variable, a module, or a var in Module
format. This still lets us get basic matching enough to access the :extra
information on the error and transform it to an {:error, reason}
tuple.
If you don’t care about the error type or even referencing the error, you can do this:
try do
raise "Boom!"
rescue
_ ->
"Hit an error"
end
#=> "Hit an error"
The above matches on any exception type.
Specifying an exception’s module name, you can match on specific error type if needed.
try do
raise "Boom!"
rescue
RuntimeError ->
"A runtime error was hit"
_ ->
"Hit an error"
end
#=> "A runtime error was hit"
Writing functions that raise exceptions
Sometimes you may want to create a function that raises an exception when something is really wrong. If your code explicitly raises exceptions, then the convention is to name it ending with a !
. This is a naming convention used to convey the function may raise an exception. It typically means, “Perform the operation or raise an exception.” This is also called a “bang”. An example is the Enum.fetch!/2
function. You would read this as the “Enum fetch bang” function. From the documentation, it is described as:
Finds the element at the given
https://hexdocs.pm/elixir/Enum.html#fetch!/2index
(zero-based). RaisesOutOfBoundsError
if the givenindex
is outside the range of theenumerable
.
Many of the functions in the File
module have !
(bang) versions as a failure to read a file may be considered truly exceptional and a reason to fail entirely.
As mentioned previously, in Elixir it is far less common to deal with exceptions than what you find in other languages. For this reason, the File
module also contains {:ok, result}
and error responses for all the same functions. It is easier to pattern match and more common to respond to an :ok/:error
tuple response than raised exceptions.
Alternate Syntax
An alternate syntax exists for the try/rescue
block. Within a function, you can just use the rescue
at the end of the function block without including the try
.
defmodule Testing do
def full_try do
try
# operation that might raise exception
rescue
# handling logic
end
end
def without_try do
# operation that might raise exception
rescue
# handling logic
end
end
The above are equivalent. When your try/rescue
block would encompass the full function body, you can use the abbreviated version. It is cleaner and has less indentation noise.
Exercise #1 – Conditionally raise an exception
The project has an existing function CodeFlow.Fake.Users.find/1
. It finds a user and returns an {:ok, user}
or {:error, reason}
result. Your team wants you to create a find_user!/1
function that calls the existing Users.find/1
function but unwraps the tuples. When found, return the user not in a tuple. When not found, raise an exception where the message is the reason from the :error
tuple.
The tests for this exercise are here:
mix test test/errors_test.exs:18
Exercise #2 – Transforming an exception
As your team has gotten more comfortable with Elixir and has started “thinking Elixir”, they realize they have too many functions raising exceptions. They brought habits from other languages with them when they were new to Elixir. Now they want to add more functions that return tuples but not break all the existing code using the exception versions.
The project has a function named CodeFlow.Fake.Orders.find!/1
that raises an exception when an order is not found. Your team wants you to create a CodeFlow.Errors.find_order/1
function that uses the existing CodeFlow.Fake.Orders.find!/1
function to do the work but transforms a raised exception into a tuple response. Capture the exception’s message and use that as the reason for the failure. When an order is found, wrap it in an {:ok, order}
tuple.
The tests for this exercise are here:
mix test test/errors_test.exs:35
Throw and Catch
The throw
and catch
keywords are designed for Code Flow or control flow. It is reserved for situations where it is the only way you can get a value back. Again, like raise
and rescue
, throw
and catch
should be used sparingly.
The times where you might need to use throw
and catch
are when working with poorly designed libraries. So, truly, you don’t use these much. However, here’s what it looks like if you did need it.
This example stops at the first number evenly divisible by 17 but is not 17. It throws the matching value which is caught outside the loop.
try do
Enum.each 1..1_000_000, fn(n) ->
if rem(n, 17) == 0 && n != 17 do
throw n
end
end
catch
found -> "Caught #{found}"
end
I’ve never encountered an actual need for the throw
keyword. You aren’t likely to either.
Exit
Elixir has a great concurrency story and that all happens because of green thread processes. We aren’t going to get into those at this point. However, this is relevant to an exit
. An exit
is a signal sent to the process running the code telling it to die.
exit("things went BOOM!")
#=> ** (exit) "things went BOOM!"
An exit
signal is like a special thrown value which can be handled using catch
.
try do
exit "Abort! Abort!"
catch
:exit, reason ->
IO.inspect reason
"Phew... caught that."
end
#=> "Abort! Abort!"
#=> "Phew... caught that."
Exits deal with killing a process. In Elixir, processes are typically supervised. The exit
is caught and handled by the supervisor who’s job is to restart a new process in a known good state to keep the system running smooth. Exit signals are an important part of a resilient system.
If using throw
and catch
are uncommon, then catching an exit
is even less common! There may be cases where you want to trigger an exit
, but you’ll be wanting a supervisor to handle it.
After
The after
keyword is used to help ensure some cleanup operations happen either if the try
block succeeds or blows up.
try do
IO.puts "try block succeeded"
after
IO.puts "after performed"
end
#=> try block succeeded
#=> after performed
try do
raise "try block failed"
after
IO.puts "after performed"
end
#=> after performed
#=> ** (RuntimeError) try block failed
An important point to realize about the after
block is that it only offers a soft guarantee. The documentation on this is helpful to review.
Note that the process will exit as usual when receiving an exit signal that causes it to exit abruptly and so the
https://hexdocs.pm/elixir/Kernel.SpecialForms.html?#try/1-after-clausesafter
clause is not guaranteed to be executed. Luckily, most resources in Elixir (such as open files, ETS tables, ports, sockets, and so on) are linked to or monitor the owning process and will automatically clean themselves up if that process exits.
We learn that when a process accessing a resource dies, the resources linked to it are automatically closed. Typically in other languages you might use an after
to close open files or resources. However, in Elixir you probably don’t need that case either. This means there aren’t likely to be many cases where you actually need an after
.
Else
The else
block is optional. If present, it provides the ability to pattern match on the result of the try
block when it does not fail.
try do
:success
rescue
_ ->
:error
else
:success ->
"Yay, it worked!"
end
#=> "Yay, it worked!"
Note: any errors or failures in the else
block code are not caught.
Variables and Scope
An important point to make with the try/rescue/catch/after
blocks is that any variables bound inside those block do not leak out. For instance, when the code in the try block below fails, would you expect some variables to be bound but others not?
try do
raise "boom!"
status = "no error"
rescue
_ ->
status = "rescued"
end
status
#=> ** (CompileError) iex:9: undefined function status/0
Instead, bind the result of the try/rescue
block to the value.
status =
try do
raise "boom!"
"no error"
rescue
_ ->
"rescued"
end
status
#=> "rescued"
Recap
Putting all of these pieces together looks like this:
try do
# code that might raise, throw or exit
rescue
# handle exceptions
catch
# catch any "thrown" values
# catch any "exit" signals
after
# code to always run for cleanup
else
# code that runs when nothing errors in the try block
end
Within a function body, using a rescue/catch/after
will cause the compiler to include a try
block for you. This helps the syntax stay a bit cleaner. It looks like this:
def do_work() do
# code that might raise, throw or exit
rescue
# handle exceptions
catch
# catch any "thrown" values
# catch any "exit" signals
after
# code to always run for cleanup
end
Some key points to remember:
- All of these error handling options are not encountered often. They are available but seldom used.
- The most common one you are likely to use use is
raise
andrescue
. Other libraries may raise exceptions that you must deal with. - Pattern matching is preferred to exceptions.
- Functions that do raise an exception end with a
!
bang as a naming convention.
Comments are closed on this static version of the site.
Comments are closed
This is a static version of the site. Comments are not available.