Creating Custom Exceptions in Elixir

Paweł Świątkowski

Paweł Świątkowski on

Creating Custom Exceptions in Elixir

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:

  1. 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
  1. 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.Errors) 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.Errors, 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!

Paweł Świątkowski

Paweł Świątkowski

Our guest author Paweł is a mostly backend-focused developer, always looking for new things to try and learn. When he's not looking at a screen, he can be found playing curling (in winter) or hiking (in summer).

All articles by Paweł Świątkowski

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps