An Introduction to Exceptions in Elixir

pulkit110

Pulkit Goyal

Posted on October 3, 2023

An Introduction to Exceptions in Elixir

Exceptions are a core aspect of programming, and a way to signal when something goes wrong with a program. An exception could result from a simple error, or your program might crash because of underlying constraints. Exceptions are not necessarily bad, though — they are fundamental to any working application.

Let’s see what our options are for handling exceptions in Elixir.

Raising Exceptions in Elixir

The Elixir (and Erlang) community is generally quite amenable to exceptions: “let it crash” is a common phrase. This is due, in part, to the excellent OTP primitives in Erlang/Elixir. The OTP primitives allow us to create supervisors that manage and restart processes (or a group of related processes) on failure.

Exceptions can occur when something unexpected happens in your Elixir application. For example, division by zero raises an ArithmeticError.

iex> 1 / 0
** (ArithmeticError) bad argument in arithmetic expression: 1 / 0
    :erlang./(1, 0)
    iex:1: (file)
Enter fullscreen mode Exit fullscreen mode

Exceptions can also be raised manually:

iex> raise "BOOM"
** (RuntimeError) BOOM
    iex:1: (file)
Enter fullscreen mode Exit fullscreen mode

By default, raise creates a RuntimeError. You can also raise other errors by using raise/2:

defmodule Math do
  def div(a, b) do
    if b == 0, do: raise ArgumentError, message: "cannot divide by zero"
    a / b
  end
end

iex> Math.div(1, 0)
** (ArgumentError) cannot divide by zero
    iex:4: Math.div/2
    iex:3: (file)
Enter fullscreen mode Exit fullscreen mode

A function call that doesn’t match a defined function raises an ArgumentError by default:

defmodule Math do
  def div(a, b) when b != 0 do
    a / b
  end
end

iex> Math.div(1, 0)
** (FunctionClauseError) no function clause matching in Math.div/2

    The following arguments were given to Math.div/2:

        # 1
        1

        # 2
        0
Enter fullscreen mode Exit fullscreen mode

Handling Elixir Exceptions

Elixir provides the try-rescue construct to handle exceptions:

try do
  raise "foo"
rescue
  e in RuntimeError -> IO.inspect(e)
end
Enter fullscreen mode Exit fullscreen mode

This prints %RuntimeError{message: "foo"} in the console but doesn’t crash anything. Use this when you want to recover from exceptions in Elixir. It is also possible to skip binding the variable if it is unnecessary. For example:

try do
  raise "foo"
rescue
  RuntimeError -> IO.puts("something bad happened")
end
Enter fullscreen mode Exit fullscreen mode

In both of the above examples, we only rescue RuntimeError. If there is another error, it will still raise an exception. To rescue all exceptions raised inside the try block, use the rescue without an error type, like this:

try do
  1 / 0
rescue
  e -> IO.inspect(e)
end
Enter fullscreen mode Exit fullscreen mode

Running the above prints %ArithmeticError{message: "bad argument in arithmetic expression"}. If you want to perform different actions based on different exceptions, just add more clauses to the rescue branch.

try do
  1 / 0
rescue
  RuntimeError -> IO.puts("Runtime Error")
  ArithmeticError -> IO.puts("Arithmetic Error")
  _e -> IO.puts("Unknown error")
end
Enter fullscreen mode Exit fullscreen mode

While rescuing all errors (without using a specific type) sounds tempting, it is a good practice to rely on specific exceptions because:

  1. You can perform different recovery tasks for different exceptions.
  2. It saves you from future breaking changes or programming errors going unnoticed.

For example, consider the below function:

defmodule Config do
  def get(file) do
    try do
      contents = File.read!(file)
      parse!(contents)
    rescue
      _e -> %{some: "default config"}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

It reads and parses a config file, returning the result. When written, it uses a generic clause to rescue from missing file-related errors. Let’s say that a while later, the input file gets corrupted somehow (e.g., a simple missing } or an extra , in a JSON file), and now there are parsing errors. Our function will still work without raising any issues, and we will be left guessing why it doesn’t work even though there’s an existing file.

Another common practice in the Elixir community is to use ok/error tuples to signal errors instead of raising exceptions (for both internal and external libraries). So, instead of returning a simple result or raising an exception on an issue, a function in Elixir will return {:ok, result} on success and {:error, reason} on failure.

This means that most of the time, a case block will be preferable to try/rescue. For example, the above File.read! can be replaced by:

case File.read(file) do
  {:ok, contents} -> parse!(contents)
  {:error, reason} -> %{some: "default config"}
end
Enter fullscreen mode Exit fullscreen mode

In practice, you should reach out for try/raise only in exceptional cases, never as a means of control flow. This is quite different from some other popular languages like Ruby or Java, where unexpected operations usually raise an error, then handle control flow for cases like non-existent files.

Re-raising Exceptions

Sometimes, we just want to know that there is an exception but not rescue from it — for example, to log an exception before allowing the process to crash or to wrap the exception into something more useful/understandable to the user.

This is where reraise can be helpful, as it preserves an exception's existing stack trace:

try do
  1 / 0
rescue
  e ->
    IO.puts(Exception.format(:error, e, __STACKTRACE__))
    reraise e, __STACKTRACE__
end
Enter fullscreen mode Exit fullscreen mode

Here, we use the __STACKTRACE__ to retrieve the original trace of the exception, including its origin and the full call stack.

In a real-world application, you might use this to report a metric / send an exception to a third-party exception tracking service, or log something informative to a third-party logging service. For example, you might send telemetry data to AppSignal under these circumstances.

In addition, it is also common practice to have custom exceptions for cases where unexpected things happen in libraries. reraise/3 can be useful here:

defmodule DivisionByZeroError do
  defexception [:message]
end

defmodule Math do
  def div(a, b) do
    try do
      a / b
    rescue
      e in ArithmeticError ->
        reraise DivisionByZeroError, [message: e.message], __STACKTRACE__
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now using Math.div(1, 0) will raise a DivisionByZeroError instead of a generic ArithmeticError.

Rescue And Catch in Elixir

In Elixir, try/raise/rescue and try/throw/catch are different. raise and rescue are used for exception handling, whereas throw and catch are used for control flow.

A throw stops code execution (much like a return — the difference being that it bubbles up until a catch is encountered). It is rarely needed and is only an escape hatch to be used when an API doesn’t provide an option to do something.

For example, let's say that there’s an API that produces numbers and invokes a callback:

defmodule Producer do
  def produce(callback) do
    Enum.each((1..100) , fn x -> callback.(x) end)
  end
end
Enter fullscreen mode Exit fullscreen mode

(This is just an example. Assume that with the real API, it is much more expensive to produce each number, and it produces an infinite list of numbers.)

We want to find the first number divisible by 13, since we don’t have any other apparent way of finding that number and stopping the production at that point. Let’s see how we can use throw/catch to do this:

try do
  Producer.produce(fn x ->
    if rem(x, 13) == 0 do
      throw(x)
    end
  end)
catch
  x -> IO.puts("First number divisible by 13 is #{x}")
end
Enter fullscreen mode Exit fullscreen mode

It's worth stating that this is a bit of a contrived case, and most good APIs are designed in such a way that you never need to reach out for throw/catch.

After and Else Blocks in Elixir

You can use after and else blocks with all try blocks. An after block is called after processing all other blocks related to the try, regardless of whether any of the blocks raised an error. This is useful for performing any required clean-up operations.

For example, the below code creates a new producer and makes some results. If there’s an error during production, it will raise an exception. But the after block ensures that the producer is still disposed of regardless.

producer = Producer.new
try do
  Producer.produce!(producer)
after
  Producer.dispose(producer)
end
Enter fullscreen mode Exit fullscreen mode

On the other hand, an else block is called only if the try block is completed without raising an error. The else block receives the try block's result. The return value from the else block is the final return value of the try. For example:

try do
  File.read!("/path/to/file")
rescue
  File.Error -> :not_found
else
  "a" -> :a
  _other -> :other
end
Enter fullscreen mode Exit fullscreen mode

In the above code, if the file exists with content a, the result will be :a. The result is :not_found if the file doesn’t exist, and :other if the content is anything else.

Exceptions and Processes

No discussion about Elixir exceptions is complete without examining their impact on processes. Any unhandled exceptions cause a process to exit.

With this in mind, let’s revisit the “let it crash” strategy. If we don’t handle an exception from a process, it will crash. This is good in a way because:

  1. Since all processes are separate from each other, an exception or unhandled crash from one process can never affect the state of another process.
  2. In most sophisticated Elixir applications, all the processes run under a supervision tree. So, an unexpected exit will restart the process (depending on the supervision strategy) and any linked processes with a clean slate.

In most cases, intermittent issues will resolve themselves in the next run. This is much easier (and usually also cleaner) than handling each failure separately and performing a recovery step.

If you want to learn more about supervisors, I suggest the official Elixir Supervisor and Application guide as a great starting point.

Monitoring Exceptions

As we've seen, exceptions are a fundamental part of any application. Handling them is good, but sometimes, letting them crash a process is even better.

But in all cases, it is better to monitor exceptions happening in the real world so that you can take action if there’s something concerning (for example, a developer error that crashes an app).

This is where AppSignal for Elixir can help. Once set up, it automatically records all errors and can also trigger alerts based on predefined conditions.

Here's an example of an individual error you can get to from the "Errors" -> "Issue list" in AppSignal for debugging:

Trace sample

Check out the AppSignal for Elixir installation guide to get started.

Wrapping Up

In this post, we explored how errors are treated in Elixir and how to recover from them. We also saw that sometimes it is better to just let a process crash and be restarted through the supervisor than to manually perform recovery steps for all possible exceptions.

Elixir’s API makes a clear distinction between functions that raise an exception (usually ending in !) and functions that return a success/error tuple. If you are a library author, it is better to provide both options for users.

Reach out for the tuple-based methods when you need to handle error cases separately. In Elixir, we rarely use try blocks for control flow — the ! functions are for when we want a process to crash on unexpected events.

Until next time, 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!

💖 💪 🙅 🚩
pulkit110
Pulkit Goyal

Posted on October 3, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related