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!