Creating Custom Exceptions in Elixir
Tags: elixir
Published: 2024-03-05 11:25:35

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](https://stackoverflow.com/a/77361):

> 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](https://blog.appsignal.com/2023/09/26/an-introduction-to-exceptions-in-elixir.html) 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`:

```elixir
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:

```sh
%MatchError{term: :not_ok}
```

As we can see, this is just a struct with some additional data. Using similar code, we can check `CaseClauseError`.

```sh
%CaseClauseError{term: :not_ok}
```

We can do more using functions provided by the `Exception` module:

```elixir
> 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:

```elixir
> 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:

```elixir
> 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:

```elixir
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](https://stackoverflow.com/questions/77127/when-to-throw-an-exception/77361#77361), 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.

<Banner lang="elixir" />

## 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:

```elixir
defmodule WebhookSender.ConfigurationError do
  defexception [:message]
end
```

It is as simple as that. You can then improve the code of the `WebhookSender`:

```elixir
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:

```sh
** (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.

```elixir
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:

```sh
** (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.

```elixir
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:

```elixir
def message(exception) do
  "Module #{exception.module_a} and #{exception.module_b} are not compatible"
end
```

2. Provide a default message.

```elixir
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](https://hexdocs.pm/absinthe/Absinthe.Middleware.html) checking for the exception. It looks like this:

```elixir
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](https://www.appsignal.com/tour/errors).

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:

```elixir
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:

```sh
** (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.

```elixir
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:

```sh
** (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](/elixir-alchemy)!**