Chris Bailey

An approach for error handling in Phoenix and Absinthe

In my post about Railway Oriented Programming I spoke about to the advantages of not needing the handle errors, but instead, identifying when you're stepping off of the happy path.

I advocate that one ought to implement small pieces of business logic that can be composed together in such a way that you don't have to handle errors but instead let them propagate up to your client or call site and let them deal with it, and as the implementer or maintaner of a backend system that owns it's own errors, thats pretty simple to do—as we explored in the post above.

But not all systems are so conviniently self contained, the majority of the systems I've worked on in the last two years have been services which are exposed on the internet and utilised by numerous different client devices. What does "let errors propagate to the client" actually mean when we're talking about a backend for a web application?

Errors that propagate to the client–server boundary, if left alone will oftentimes just crash, or worse: it'll be turned into JSON and returned straight to the client. This isn't good. When an exception is indeed exceptional it should by all means crash, but for a mundane error like a customer service consulant not having access to a particular document in a CMS system, we should handle that and return the minimal viable error that is helpful and indicative of what happened—you should never leak unneccessary system internals (like an unsanitized error) into the wild.

Common error handling implementations

I've seen a lot of different approaches to handling this kind of thing; I've seen a lot of projects using Absinthe simply return a term that looks like: {:error, "something went wrong"} which will present to the user as something such as:

{
  "errors": ["something went wrong"]
}

Not too bad right? But the problem with returning free form strings is that this "something went wrong" error could be thrown in a lot of places across the codebase. Clients might need to handle this specific error but what if one of the throw sites misspells "something went wrong"? Is the "something went wrong" error semantically meaningful and used in a consistent sense across the codebase? How does the server notify clients of changes to how "something went wrong" gets thrown?

This isn't necessarily a huge problem, but in a previous project I've worked on we were designing multiple GraphQL APIs for different clients (a different graph being exposed for an Admin web portal, mobile apps etc); different developers with different styles would work on these graphs and error handling ended up becoming a huge painpoint for all clients—not only did it become hard to tackle errors, it became very difficult to discuss them across the team and between clients if the need ever arose (which it did).

Other times I've seen Phoenix projects use the following pattern across their controllers:

defmodule MyController do
  use MyApp. :controller
  
  def show(conn, %{"id" => id}) do
    case Something.get_by_id(id) do
      {:ok, something} ->
        conn
        |> put_status(:ok)
        |> render("show.json", %{something: something})

      {:error, "not_found"} ->
        conn
        |> put_status(:not_found)
    end
  end

  def show(conn, params) do
    case Something.create(params) do
      {:ok, something} ->
        conn
        |> put_status(:created)
        |> render("show.json", %{something: something})

      {:error, "unauthorized"} ->
        conn
        |> put_status(:unauthorized)
        |> put_resp_header("www-authenticate", "Bearer")

      {:error, changeset_error) ->
        conn
        |> put_status(:bad_request, build_error(changeset_error))
    end
  end

  # Some extensive pattern matching
  defp build_error(error), do: ...
  defp build_error(error), do: ...
  defp build_error(error), do: ...
  defp build_error(error), do: ...
end

Which again, appears perfectly okay; one might want to refactor the build_error/1 function if its something to be used in multiple places the same way perhaps, but generally the code is explicit and reads well enough. The main painpoint here is having to do it.

Despite the fact that we've focused purely on our happy paths in the actual business logic, every time we want to call a piece of business logic from our controllers we have to have the mental overhead of what errors they return, what errors we want to handle, how to handle it and how to coerce the errors returned by said business logic into the proper error to be returned to the client.

The nice thing is that we can handle both of these issues quite cleanly such that we don't need to worry about them again 🙂

Error handling middleware

You can think of middleware as being a function in the middle of a normal Elixir pipeline. You could imagine that your Phoenix application or Absinthe application is built up of a pipeline of middlewares which might look something like:

# Extremely simplified of course...
request
|> parse_request()
|> route_request()
|> build_response()
|> send_response()

The nice thing about this (and Absinthe/Phoenix) is that one can essentially add new steps in this pipeline to further process whatever is returned in the steps prior to said middleware. This means that if we take the examples given in the section above, we can (just like for our business logic in our normal Elixir contexts) focus only on the happy path and not care about error handling at all in our controllers/resolvers.

We can refactor the Phoenix snippet above into this for instance:

defmodule MyController do
  use MyApp. :controller
  action_fallback MyApp.ErrorHandler
  
  def show(conn, %{"id" => id}) do
    with {:ok, something} <- Something.get_by_id(id) do
        conn
        |> put_status(:ok)
        |> render("show.json", %{something: something})
    end
  end

  def show(conn, params) do
    with {:ok, something} <- Something.create(params) do
      conn
      |> put_status(:created)
      |> render("show.json", %{something: something})
  end
end

Which, in my opinion at least, is a lot nicer. The only addition to the code outside of removing unneccessary (and tedious, error prone) error handling cases is using Phoenix's action_fallback macro which conceptually works like a middleware: if a Plug.Conn.t() is not returned by your controller (and instead, something like {:error, "unauthorized"} is returned instead), then pass that term into a custom module and function.

We can implement our fallback controller quite simply as:

defmodule MyApp.ErrorHandler do
  @http_errors ["forbidden", "not_found", "conflict", ...]

  def call(conn, {:error, "unauthorized"}) do
    conn
    |> put_resp_header("www-authenticate", "Bearer")
    |> put_status(:unauthorized)
    |> halt()
  end

  def call(conn, {:error, http_error}) when http_error in @http_errors do
    conn
    |> put_status(http_error)
    |> json(%{errors: [%{code: status}]})
    |> halt()
  end
end

You can even really easily do something cool like, when given an Ecto.Changeset error, you can automatically process it to return a 422 Unprocessable Entity error with the invalid fields by pattern matching the second term in your call/2 function.

With the addition of a catch all clause at the very end if you wish, you still maintain the ability to throw errors in your controllers if they genuinely need to be handled there! 🎉

The exact same pattern works in Absinthe too, you simply need to implement your own middleware as follows:

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

  def call(resolution, _config) do
    errors =
      resolution
      |> Map.get(:errors)
      |> Enum.map(&process_error/1)

    %{resoluton | errors: errors}
  end

  defp process_error({:error, "unauthorized"}) do
    %{status: 401, error: %{code: "unauthorized"}}
  end

  defp process_error(unhandled_error) do
    Logger.warning("Unhandled error: ...")
    Sentry.capture_exception("Unhandled error: ...", extra: %{payload: unhandled_error})

    %{status: 400, error: "something went wrong"}
  end
end

So the exact same pattern pretty much follows. You can see in the Absinthe example here that you can even centralise error logger / sending events to a service such as Sentry or Datadog. Because error handling is still largely done ad-hoc in GraphQL projects I've seen it's also valuable to be able to do things such as call out unhandled errors so that we can explicitly document them and handle them to make sure nothing leaks to consumers of our API.

Typing Errors

Hopefully following this you'll find that your Phoenix controllers and GraphQL resolvers are a lot neater now, and errors are easier to reason about!

This solution scaled up really nicely for us whenever we used it/fitted it into a project. One of the places where it falls a little short is when we actually want to return some specific information as part of the error.

Right now, our middlewares as implemented as per the examples above, make it really easy to throw standard errors such as a 401 Unauthorized and such, but there are cases where we want to return something a lot more detailed. Our original take at this was to enable you to return not just something in the form of {:error, error_type} but instead: {:error, error_type, extra} where extra was just an KeywordList of arbitrary data we wanted to serialize into the error.

This works up to a point, but as we continued using it, we ended up with a completely unspecifiable mess to maintain. GETing a certain endpoint might return you a 401 Unauthorized with a set of extra metadata fields for the consumers of our API to parse, POSTing another endpoint might return you no extra fields at all. This is fine, of course, but what if one variant of an error returned a extra field reason whereas another very similar error thrown somewhere else returned an extra field message, both of which serving a similar purpose? We're essentially back to square one!

Errors could have any number of extra fields, defined as part of that error's definition. Fields should be marked as optional or necessary. Elixir made implementing something like this very easy, it's called a struct 😉

We ended up implementing all non-trivial error messages (so as to retain the ability to return an arbitrary {:error, :bad_request} here and there) as standard structs as follows:

defmodule MyApp.Errors.SomeDomainError do
  @enforce_keys [:domain_object, :user]
  defstruct [:domain_object, :timestamp, :user, message: "Some domain error"]
end

When we wanted to use these errors, we could just return {:error, %MyApp.Errors.SomeDomainError{...}} and best of all, the Elixir compiler would enforce these required fields for us so we would not even be able to compile our application if we had a malformed error (following a little extension to our middleware of course)!

We actually implemented this with some fancy macros which would automatically generate documentation about the specs of all defined errors so that we could return a schema of all errors to our users to implement against, as well as a few niceties such as custom test assertion functions, but all of that is optional. The important thing is now, where we want it, we can return controlled but highly flexible and safe errors to our consumers!

The future

It'd be interesting to spike out a small library for handling these Absinthe/Phoenix errors since the middlewares provided are genuinely really simple and generic, plus the deferror macro we ended up implementing for the more flexible typed errors.

One of the small missing things as well, which would be great, is the ability to have default fields in our error messages come from the time of initializing the error, instead of at compile time; this'd open up a whole lot of stacktrace/runtime information to be captured. Being able to specify which keys are enforced is nice too but being able to guarantee the type of certain keys would also be useful.

I've got something in the works, but its a proof of concept and nowhere near ready yet; but I'll share it once it is somewhat workable (I say, again... 😄)


Return to Posts →