DEV Community

Cover image for Creating Custom Exceptions in Elixir
Paweł Świątkowski for AppSignal

Posted on • Originally published at blog.appsignal.com

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
Enter fullscreen mode Exit fullscreen mode

The output will be:

%MatchError{term: :not_ok}
Enter fullscreen mode Exit fullscreen mode

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

%CaseClauseError{term: :not_ok}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  1. Provide a default message.
defmodule SpaceshipConstruction.IncompatibleModules do
  defexception [:module_a, :module_b, message: "Given modules are not compatible"]
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)