Exception handling is a great practice for any software development methodology. Whether it's for test-based development, agile sprints, or a hacking session with just a good old todo list, we all can benefit from ensuring our bases are covered with a robust approach to defect handling.
It's paramount to ensure errors are taken care of, whilst being aesthetically pleasing and of course not becoming a big problem logically with cryptic messages for the end user to try and glean meaning from. If you do that, you certainly are on a great route to make a solid, stable and sticky app that users enjoy working with and will recommend highly to others.
Ideally for us, Elixir provides extensive exception handling via several mechanisms such as try/catch
, throws
, and the {:error, reason}
tuple.
To display an error, use raise
in your interactive shell to get a first taste:
iex> raise "Oh noez!" ** (RuntimeError) Oh noez!
We can also add a type to this like so:
iex> raise ArgumentError, message: "error message here..." ** (ArgumentError) error message here...
How Error Handling Works in Elixir
Some of the ways errors are dealt with in Elixir may not be obvious at first glance.
- Firstly about processes—in using
spawn
, we can create independent processes. That means a failure on one thread should not affect any other process, unless there was a linkage in some manner. But by default, everything will stay stable.
- To notify the system of a failure in one of these processes, we can use the
spawn_link
macro. This is a bidirectional link, which means that if a linked process terminates, an exit signal will be triggered.
- If the exit signal is anything other than
:normal
, we know we have a problem. And if we trap the exit signal withProcess.flag(:trap_exit, true)
, the exit signal will be sent to the process's mailbox, where the logic can be placed on how to handle the message, thus avoiding a hard crash.
- Finally we have Monitors, which are similar to
spawn_links
, but these are unidirectional links, and we can create them withProcess.monitor
.
- The process which invokes the
Process.monitor
will receive the error messages on failure.
For a sample error, try adding a number to an atom and you will get the following:
iex> :foo + 69 ** (ArithmeticError) bad argument in arithmetic expression :erlang.+(:foo, 69)
To ensure the end user does not get errored out, we can use the try, catch and rescue methods provided by Elixir.
Try/Rescue
First in our toolbox for exception handling is try/rescue
, which catches errors produced by using raise
so is really best suited for developer errors, or exceptional circumstances such as input error.
try/rescue
is similar in usage to a try/catch
block you may have seen in other programming languages. Let's look at an example in action:
iex> try do ...> raise "do failed!" ...> rescue ...> e in RuntimeError -> IO.puts("Error: " <> e.message) ...> end Error: do failed! :ok
Here we utilize the try/rescue
block and the aforementioned raise
to catch the RuntimeError
.
This means the ** (RuntimeError)
default output of raise
is not displayed, and is replaced with a nicer formatted output from the IO.puts
call.
As a best practice, you must use the error message to give the user useful output in plain English, which helps them with the issue. We'll look at that more in the next example.
Multiple Errors in a Try/Rescue
A major benefit of Elixir is that you can catch multiple outcomes in one of these try/rescue
blocks. Look at this example:
try do opts |> Keyword.fetch!(:source_file) |> File.read! rescue e in KeyError -> IO.puts "missing :source_file option" e in File.Error -> IO.puts "unable to read source file" end
Here we have caught two errors in the rescue
.
- If the file is unable to read.
- If the
:source_file
symbol is missing.
As mentioned before, we can use this for making easy-to-understand error messages for our end user.
This powerful and minimal syntax approach of Elixir makes writing multiple checks very accessible for us to check many possible points of failure, in a neat and concise way. This helps us to ensure we don't need to write elaborate conditionals making long-winded scripts that may be hard to visualize fully and debug correctly during later development or for a new developer to join.
As always when working in Elixir, KISS is the best approach to take.
After
There are situations when you will require a specific action performed after the try/rescue block, regardless of if there was any error. For Java or PHP developers, you may be thinking of the try/catch/finally
or Ruby's begin/rescue/ensure
.
Let's take a look at a simple example of using after
.
iex> try do ...> raise "I wanna speak to the manager!" ...> rescue ...> e in RuntimeError -> IO.puts("An error occurred: " <> e.message) ...> after ...> IO.puts "Regardless of what happens, I always turn up like a bad penny." ...> end An error occurred: I wanna speak to the manager! Regardless of what happens, I always turn up like a bad penny. :ok
Here you see the after
being used to constantly make a message display (or this could be any function you wished to throw in there).
A more common practice you will find this used on is where a file is being accessed, for example here:
{:ok, file} = File.open "would_defo_root.jpg" try do # Try accessing file here after # Ensure we clean up afterwards File.close(file) end
Throws
As well as the raise
and try/catch
methods we have outlined earlier, we also have the throw and catch macros.
Using the throw
method exits execution with a specific value we can look for in our catch
block and use further like so:
iex> try do ...> for x <- 0..10 do ...> if x == 3, do: throw(x) ...> IO.puts(x) ...> end ...> catch ...> x -> "Caught: #{x}" ...> end 0 1 2 "Caught: 3"
So here we have the ability to catch
anything we throw
inside the try block. In this case, the conditional if x == 3
is the trigger for our do: throw(x)
.
The output from the iteration produced from the for loop gives us a clear understanding of what has occurred programmatically. Incrementally we have stepped forward, and execution has been halted on the catch
.
Because of this functionality, sometimes it can be hard to picture where the throw
catch
would be implemented in your app. One prime place would be in usage of a library where the API does not have adequate functionality for all outcomes presented to the user, and a catch would suffice to rapidly navigate around the issue, rather than having to develop much more within the library to handle the issue and return appropriately for it.
Exits
Finally in our Elixir error handling arsenal we have the exit
. Exiting is done not through the gift shop, but explicitly whenever a process dies.
Exits are signaled like so:
iex> spawn_link fn -> exit("you are done son!") end ** (EXIT from #PID<0.101.0>) "you are done son!"
Exit signals are triggered by processes for one of the following three reasons:
- A normal exit: This happens when a process has completed its job and ends execution. Since these exits are totally normal, usually nothing needs to be done when they happen, much like a
exit(0)
in C. The exit reason for this kind of exit is the atom:normal
. - Because of unhandled errors: This happens when an uncaught exception is raised inside the process, with no
try/catch/rescue
block orthrow/catch
to deal with it. - Forcefully killed: This happens when another process sends an exit signal with the reason
:kill
, which forces the receiving process to terminate.
Stack Traces
At any given update juncture on throw
, exit
or errors
, calling the System.stacktrace
will return the last occurrence in the current process.
The stack trace can be formatted quite a bit, but this is subject to change in newer versions of Elixir. For more information on this, please refer to the manual page.
To return the stack trace for the current process, you can use the following:
Process.info(self(), :current_stacktrace)
Making Your Own Errors
Yep, Elixir can do that also. Of course, you always have the built-in types such as RuntimeError
at your disposal. But wouldn't it be nice if you could go a step further?
Creating your own custom error type is easy by using the defexception
macro, which will conveniently accept the :message
option, to set a default error message like so:
defmodule MyError do defexception message: "your custom error has occurred" end
Here's how to use it in your code:
iex> try do ...> raise MyError ...> rescue ...> e in MyError -> e ...> end %MyError{message: "your custom error has occurred"}
Conclusion
Error handling in a meta-programming language like Elixir has a whole heap of potential implications for how we design our applications and make them robust enough for the rigorous bashing of the production environment.
We can ensure the end user is always left with a clue—a simple and easy-to-understand guiding message, which won't make their task difficult but rather the inverse. Error messages must always be written in plain English and give plenty of information. Cryptic error codes and variable names are no good to the average users, and can even confuse developers!
Going forward, you can monitor the exceptions raised in your Elixir application and set up specific logging for certain trouble spots, so you can analyze and plan your fix, or you can look at using an off-the-shelf solution.
Third-Party Services
Improve the accuracy of our debugging work and enable monitoring for your apps' stability with these third-party services available for Elixir:
- AppSignal can be very beneficial for the quality assurance phase of the development cycle.
- GitHub repo bugsnex is a great project for using the API interface with Bugsnag to further detect defects in your Elixir app.
- Monitor uptime, system RAM and errors with Honeybadger, which provides production error monitoring so you don't need to babysit your app.
Extending Error Handling Further
Going forward, you may wish to further extend the error handling capabilities of your app and make your code easier to read. For this, I recommend you check out this project for elegant error handling on GitHub.
I hope you have gained a further insight from this guide and will be able to practically handle any exception case you need in your Elixir app now!
No comments:
Post a Comment