Sheharyar Naseer

Exception & Error Handling in Absinthe


While Absinthe is an excellent and obvious choice for adding GraphQL support to your Elixir applications, its documentation on error and exception handling are quite lacking. Though there are some simple guides available for handling certain user-defined errors in Absinthe, there are no recommended approaches for dealing with unexpected exceptions.

I’ve come up with a pattern that I’ve found very helpful and scalable when dealing with errors in both Absinthe resolvers and Phoenix controllers.

The goals of this guide are to:

  • Represent your application errors in a standardized way
  • Set up a mechanism to automatically render these errors in the correct format in both Phoenix controllers and Absinthe resolvers
  • Gracefully handle exceptions in Absinthe and return errors in correct GraphQL format

Standardizing your Errors

Throughout your app, you and the many packages you use will return errors in the {:error, reason} format. The reason here might be a simple atom, a string message, an invalid Ecto changeset or some other error struct passed from a library you’re using.

We can standardize them by defining our own Error struct, and attaching more information when not available (like status codes, error message, etc.):

defmodule MyApp.Utils.Error do
  require Logger
  alias __MODULE__

  defstruct [:code, :message, :status_code]



  # Error Tuples
  # ------------


  # Regular errors
  def normalize({:error, reason}) do
    handle(reason)
  end

  # Ecto transaction errors
  def normalize({:error, _operation, reason, _changes}) do
    handle(reason)
  end

  # Unhandled errors
  def normalize(other) do
    handle(other)
  end



  # Handle Different Errors
  # -----------------------


  defp handle(code) when is_atom(code) do
    {status, message} = metadata(code)

    %Error{
      code: code,
      message: message,
      status_code: status,
    }
  end

  defp handle(errors) when is_list(errors) do
    Enum.map(errors, &handle/1)
  end

  defp handle(%Ecto.Changeset{} = changeset) do
    changeset
    |> Ecto.Changeset.traverse_errors(fn {err, _opts} -> err end)
    |> Enum.map(fn {k, v} ->
      %Error{
        code: :validation,
        message: String.capitalize("#{k} #{v}"),
        status_code: 422,
      }
    end)
  end

  # ... Handle other error types here ...

  defp handle(other) do
    Logger.error("Unhandled error term:\n#{inspect(other)}")
    handle(:unknown)
  end



  # Build Error Metadata
  # --------------------

  defp metadata(:unknown_resource),       do: {400, "Unknown Resource"}
  defp metadata(:invalid_argument),       do: {400, "Invalid arguments passed"}
  defp metadata(:unauthenticated),        do: {401, "You need to be logged in"}
  defp metadata(:password_hash_missing),  do: {401, "Reset your password to login"}
  defp metadata(:incorrect_password),     do: {401, "Invalid credentials"}
  defp metadata(:unauthorized),           do: {403, "You don't have permission to do this"}
  defp metadata(:not_found),              do: {404, "Resource not found"}
  defp metadata(:user_not_found),         do: {404, "User not found"}
  defp metadata(:unknown),                do: {500, "Something went wrong"}

  defp metadata(code) do
    Logger.warn("Unhandled error code: #{inspect(code)}")
    {422, to_string(code)}
  end
end

Depending on the number of packages you use and other formats you return errors in, add a handle/1 method for each of them which normalizes them into %Error{} structs. You could also go a step further and use gettext here for localizing the error messages as well.

Render Errors in Absinthe

If you’ve worked a bit with Absinthe, you know everything revolves around middleware. We just need to define one that handles errors returned from the business logic called in the resolvers:

defmodule MyApp.GraphQL.Middleware.ErrorHandler do
  @behaviour Absinthe.Middleware

  @impl true
  def call(resolution, _config) do
    errors =
      resolution.errors
      |> Enum.map(&MyApp.Utils.Error.normalize/1)
      |> List.flatten()
      |> Enum.map(&to_absinthe_format/1)

    %{ resolution | errors: errors }
  end

  defp to_absinthe_format(%Error{} = error), do: Map.from_struct(error)
  defp to_absinthe_format(error), do: error
end

In your GraphQL schema, override the middleware/3 method and call it at the end of all your mutations and queries:

defmodule MyApp.GraphQL.Schema do
  use Absinthe.Schema
  alias MyApp.GraphQL.Middleware.ErrorHandler

  # Schema imports ...

  def middleware(middleware, _field, %{identifier: type}) when type in [:query, :mutation] do
    middleware ++ [ErrorHandler]
  end

  def middleware(middleware, _field, _object) do
    middleware
  end
end

For every query or mutation Absinthe processes, it’ll call the error handler at the end to normalize the error and return it in the correct format.

Render Errors in Phoenix APIs

Since we’re already doing this for our GraphQL implementation, we can reuse this for any APIs that are implemented via Phoenix controllers. For consolidating and reusing error logic, we can use Phoenix’s Fallback Controllers:

defmodule MyApp.Web.FallbackController do
  use MyApp.Web, :controller

  def call(conn, error) do
    errors =
      error
      |> MyApp.Utils.Error.normalize()
      |> List.wrap()

    status = hd(errors).status_code
    messages = Enum.map(errors, & &1.message)

    conn
    |> put_status(status)
    |> json(%{errors: messages})
  end
end

We’re returning all errors here with the status code of the first error. You might instead want to return only the first one when there are multiple errors. Now, just call this using action_fallback/1 in every API controller you want to handle errors for:

defmodule MyApp.Web.API.V1.UserController do
  use MyApp.Web, :controller

  action_fallback(MyApp.Web.FallbackController)

  # Controller actions ...
end

Handle Elixir Exceptions in Absinthe

Your app will eventually raise exceptions. While Absinthe doesn’t have a built-in mechanism for handling them and returning a generic 500 response, we can write our own middleware to do that.

But before that, it might be helpful to understand how resolvers work in Absinthe. You see, it’s turtles all the way down. Every call executed in Absinthe is actually a middleware, and that includes the resolvers via the Absinthe.Resolution middleware. We just need to wrap it in our own to control the exceptions:

defmodule MyApp.GraphQL.Middleware.SafeResolution do
  alias Absinthe.Resolution
  require Logger

  @behaviour Absinthe.Middleware
  @default_error {:error, :unknown}


  # Replacement Helper
  # ------------------

  @doc """
  Call this on existing middleware to replace instances of
  `Resolution` middleware with `SafeResolution`
  """
  @spec apply(list()) :: list()
  def apply(middleware) when is_list(middleware) do
    Enum.map(middleware, fn
      {{Resolution, :call}, resolver} -> {__MODULE__, resolver}
      other -> other
    end)
  end


  # Middleware Callbacks
  # --------------------

  @impl true
  def call(resolution, resolver) do
    Resolution.call(resolution, resolver)
  rescue
    exception ->
      error = Exception.format(:error, exception, __STACKTRACE__)
      Logger.error(error)
      Resolution.put_result(resolution, @default_error)
  end
end

This simply wraps the Resolution calls in a rescue block and returns a default :unknown error when there’s an exception. So now back in the schema file, we can update the middleware:

defmodule MyApp.GraphQL.Schema do
  use Absinthe.Schema
  alias MyApp.GraphQL.Middleware.{SafeResolution, ErrorHandler}

  # Schema imports ...

  def middleware(middleware, _field, %{identifier: type}) when type in [:query, :mutation] do
    SafeResolution.apply(middleware) ++ [ErrorHandler]
  end

  def middleware(middleware, _field, _object) do
    middleware
  end
end

And that’s it! You’ve covered yourself from all sides with a reliable and easily manageable system to handle both errors and exceptions in Absinthe. I’ve tweaked these middleware (and the approach in general) over the past year, running two apps in production without any issues so far.

I purposefully left out handling exceptions in Phoenix, because it already does respond with a default “Internal Server Error” message on production and helpful stacktrace UI in dev (plus, there are plenty of guides for APIs on the topic readily available).

Hope this helps you design more solid applications in Elixir!