Exceptions and exception handling are widely accepted concepts in most modern programming languages. Even though they're not as prevalent in Elixir as in object-oriented languages, it's still important to learn about them.
In this article, we will closely examine exceptions in Elixir, learning how to define and use them.
Let's get started!
Elixir and Exceptions
There is no single golden rule that covers when to use exceptions, especially custom ones. Throughout this article, I will keep to a definition of exceptions from StackOverflow:
An exception is thrown when a fundamental assumption of the current code block is found to be false.
So, we can expect an exception when something truly exceptional and hard to predict happens. Network failures, databases going down, or running out of memory — these are all good examples of when an exception should be thrown. However, if the form input you send to your server does not pass validation, there are better expressions to use, such as error tuples.
Check out our An Introduction to Exceptions in Elixir post for an overview of exceptions.
You might wonder how Erlang's famous "let it crash" philosophy works with exceptions. I would say it works pretty well. Exceptions are, in fact, crashes, as long as you don't catch them.
The Anatomy of Elixir's Exceptions
The most common exception you may have seen is probably NoMatchError
. And if you've used Ecto with PostgreSQL, you also must have seen Postgrex.Error
at least a few times. Among other popular exceptions, we have CaseClauseError
, UndefinedFunctionError
, or ArithmeticError
.
Let's now take a look at what exceptions are under the hood. The easiest way to do that is to cause an exception, rescue it, and then inspect it. We will use the following code to dissect NoMatchError
:
defmodule Test do
def test(x) do
:ok = x
end
end
try do
Test.test(:not_ok)
rescue
ex -> IO.inspect(ex)
end
The output will be:
%MatchError{term: :not_ok}
As we can see, this is just a struct with some additional data. Using similar code, we can check CaseClauseError
.
%CaseClauseError{term: :not_ok}
We can do more using functions provided by the Exception
module:
> Exception.exception?(ex) # note that this is deprecated in favour of Kernel.is_exception
true
> Exception.message(ex)
"no case clause matching: :not_ok"
> Exception.format(:error, ex)
"** (CaseClauseError) no case clause matching: :not_ok"
And can peek even deeper by using functions from the Map
module:
> Map.keys(ex)
[:__exception__, :__struct__, :term]
> ex.__struct__
CaseClauseError
> ex.__exception__
true
> ex.term
:not_ok
Armed with that knowledge, we create a "fake" exception using just a map:
> Exception.format(:error, %{__struct__: CaseClauseError, __exception__: true, term: :not_ok})
"** (CaseClauseError) no case clause matching: :not_ok"
However, this is not how we will define our custom exception. And before we dive into custom exceptions, let's try to answer one important question: when should we use them?
When Should You Use Custom Exceptions?
The most common use case for custom exceptions is when you are creating your own library. Take Postgrex, for example: you have Postgrex.Error
. In Tesla (an HTTP client), you have Tesla.Error
. They are useful because they immediately indicate where an error happens and how to determine its cause.
Most of us, however, do not write libraries often. It's more likely that you work on a specific application that powers your company's business (or its customers). Even in your application's code, it can be useful to define some custom exceptions.
For example, your application might send webhook notifications to a URL defined in an environment variable. Consider this very simplified code:
defmodule WebhookSender do
def send(payload) do
url = System.get_env("WEBHOOK_ENDPOINT")
HttpClient.post(url, payload)
end
end
You can reasonably expect that a WEBHOOK_ENDPOINT
environment variable is set on a machine where your application is deployed. If it's not, that's a misconfiguration. To paraphrase the earlier definition of exceptions from StackOverflow, it's a "fundamental assumption of the current code being false".
Of course, running that code when System.get_env
call evaluates to nil
will result in an exception from HttpClient
. However, instead, you can be more defensive and perform a direct check in the WebhookSender
module.
Imagine you swap HttpClient
for BetterHttpClient
in the future, and all exceptions change. Now, throughout your application, you must fix all the places where you use a reported exception (for example, when providing an informative error message to the client). And this is because you changed a dependency, an implementation detail.
How to Define a Custom Exception in Elixir
As we have seen, exceptions in Elixir are just "special" structs. They are special because they have an __exception__
field, which holds a value of true
. While we could just use a Map
of regular defstruct
, this exception would not work nicely with all the tooling around exceptions.
To define a proper exception, we should use the defexception
macro. Let's do this for the webhook example we looked at earlier:
defmodule WebhookSender.ConfigurationError do
defexception [:message]
end
It is as simple as that. You can then improve the code of the WebhookSender
:
defmodule WebhookSender do
def send(payload) do
case System.get_env("WEBHOOK_ENDPOINT") do
nil -> raise ConfigurationError, message: "WEBHOOK_ENDPOINT env var not defined"
url -> HttpClient.post(url, payload)
end
end
end
If you run this code (of course, assuming the variable is not set), it will show an error just like with a regular exception:
** (WebhookSender.ConfigurationError) WEBHOOK_ENDPOINT env var not defined
exceptions_test.exs:24: (file)
(elixir 1.14.0) lib/code.ex:1245: Code.require_file/2
This exception clearly shows where the error originated from: a WebhookSender
module. Imagine that, instead, you see something like HttpClient.CannotPerformRequest
here. Chances are you are using HttpClient
in multiple places in the application. First, you must traverse the stack trace and find out which HttpClient
invocation is the culprit. Then, you still have to figure out the actual reason for the error.
Note that :message
is just an example of a field you can define on the exception. Although it's a nice default, it is not strictly needed.
defmodule SpaceshipConstruction.IncompatibleModules do
defexception [:module_a, :module_b]
end
raise SpaceshipConstruction.IncompatibleModules,
module_a: LithiumLoadingBay,
module_b: OxygenTreatmentPlant
When this code runs, however, it will crash on attempting to format the exception message:
** (SpaceshipConstruction.IncompatibleModules) got UndefinedFunctionError with message "function SpaceshipConstruction.IncompatibleModules.message/1 is undefined or private" while retrieving Exception.message/1 for %SpaceshipConstruction.IncompatibleModules{module_a: LithiumLoadingBay, module_b: OxygenTreatmentPlant}. Stacktrace:
SpaceshipConstruction.IncompatibleModules.message(%SpaceshipConstruction.IncompatibleModules{module_a: LithiumLoadingBay, module_b: OxygenTreatmentPlant})
(elixir 1.14.0) lib/exception.ex:66: Exception.message/1
(elixir 1.14.0) lib/exception.ex:117: Exception.format_banner/3
(elixir 1.14.0) lib/kernel/cli.ex:102: Kernel.CLI.format_error/3
(elixir 1.14.0) lib/kernel/cli.ex:183: Kernel.CLI.print_error/3
(elixir 1.14.0) lib/kernel/cli.ex:145: anonymous fn/3 in Kernel.CLI.exec_fun/2
space_exceptions.exs:5: (file)
(elixir 1.14.0) lib/code.ex:1245: Code.require_file/2
There are two ways to fix this without having to manually pass an error message every time:
- Add a
message/1
function to an exception module.
defmodule SpaceshipConstruction.IncompatibleModules do
defexception [:module_a, :module_b]
def message(_), do: "Given modules are not compatible"
end
Here, an argument to the function is an exception itself, so you can construct a more precise error message from it. For example:
def message(exception) do
"Module #{exception.module_a} and #{exception.module_b} are not compatible"
end
- Provide a default message.
defmodule SpaceshipConstruction.IncompatibleModules do
defexception [:module_a, :module_b, message: "Given modules are not compatible"]
end
Repackaging Exceptions
One interesting use case for custom exceptions is when you want to "repackage" an existing exception to fit a specific condition. This can make the exception stand out more in your error tracker.
For example, we did that with database deadlocks at the company I work for. Deadlocks are one of the hardest database-related errors to track, but they are just reported as Postgrex.Error
. We wanted clearer visibility over when these errors happen (compared to other Postgrex.Error
s) and on which GraphQL mutations.
So, I added an Absinthe middleware checking for the exception. It looks like this:
defmodule MyAppWeb.Middlewares.ExceptionHandler do
alias Absinthe.Resolution
@behaviour Absinthe.Middleware
def call(resolution, resolver) do
Resolution.call(resolution, resolver)
rescue
exception ->
error = Exception.format(:error, exception, __STACKTRACE__)
if String.match?(error, ~r/ERROR 40P01/) do
report_deadlock(exception, __STACKTRACE__)
else
@error_reporter.report_exception(exception, stacktrace: __STACKTRACE__)
end
resolution
end
defp report_deadlock(ex, stacktrace) do
original_message = Exception.message(ex)
mutation = get_mutation_name_from_process_metadata()
try do
reraise DeadlockDetected,
[message: "Deadlock in mutation #{mutation}\n\n#{original_message}"],
stacktrace
rescue
exception ->
@error_reporter.report_exception(exception,
stacktrace: __STACKTRACE__
)
end
end
end
This might seem like a lot of code. Let's break it down a little. When an exception is raised, we check if its message contains ERROR 40P01
(code for a deadlock).
Then, we raise a custom DeadlockDetected
exception and immediately rescue it to send it to an error reporter, such as AppSignal.
Now, instead of generic Postgrex.Error
s, often mixed with other database exceptions, we have a separate class of exceptions just dedicated to deadlocks. And custom exception messages allow us to quickly identify the mutation with deadlock-unsafe code.
Repackaging Exits as Exceptions
Another case for custom exceptions is when you want to transform an exit into an exception. This might be because your error reporting software does not support exits, or you may just want a more specific message than the default.
The most common case for exits is timeouts:
defmodule ImportantTask do
def run do
task = Task.async(fn -> :timer.sleep(200) end)
Task.await(task, 100)
end
end
ImportantTask.run()
In the above code, we spawn an async task that takes 200 milliseconds to complete, but we allow it to run for 100 ms. Here's the result:
** (exit) exited in: Task.await(%Task{mfa: {:erlang, :apply, 2}, owner: #PID<0.96.0>, pid: #PID<0.103.0>, ref: #Reference<0.131053822.3597205508.67962>}, 100)
** (EXIT) time out
(elixir 1.14.0) lib/task.ex:830: Task.await/2
(elixir 1.14.0) lib/code.ex:1245: Code.require_file/2
It's pretty generic, isn't it? Let's make it a bit nicer.
defmodule ImportantTask do
defmodule Timeout do
defexception [:message]
end
def run do
task = Task.async(fn -> :timer.sleep(200) end)
Task.await(task, 100)
catch
:exit, {:timeout, _} = reason ->
error = Exception.format_exit(reason)
raise Timeout, message: error
end
end
ImportantTask.run()
Here, we define a custom Timeout
exception, then catch an exit and raise an exception instead. The result is:
** (ImportantTask.Timeout) exited in: Task.await(%Task{mfa: {:erlang, :apply, 2}, owner: #PID<0.96.0>, pid: #PID<0.106.0>, ref: #Reference<0.342776.2255814665.42176>}, 100)
** (EXIT) time out
exits_to_exceptions.exs:12: ImportantTask.run/0
(elixir 1.14.0) lib/code.ex:1245: Code.require_file/2
While you may consider that this error message is still a bit cryptic, it adds two main quality-of-life improvements:
- An
ImportantTask.Timeout
exception, which makes it easy to assign the error to a particular piece of functionality in your code. - A line from our code in the stack trace (
exits_to_exceptions.exs:12: ImportantTask.run/0
). Note that the default exit message does not include this, so it's much harder to find the offending place in the code.
Wrapping Up
In this post, we learned how to define custom exceptions in Elixir. They are very useful when building a library, but they also have their place in your application code.
By repackaging generic exceptions or trapping and re-raising exits, you can make your code much easier to debug if something goes wrong. Your future self (and your colleagues) will be grateful!
Happy coding!
P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!
Top comments (0)