3

The Issue

There are a lot of guides available for handling error tuples in but next to zero for exceptions.

This is important because there are always unforseen issues which might raise an exception and return a response that will not conform to the response/error spec. This can be especially problematic when GraphQL clients like automatically batch requests, and an exception in one query will crash the whole BEAM web process causing all queries to fail.


Existing Approaches

My first thought was to wrap the resolvers in a try/rescue block using middleware and the only two links I came across, also suggested a similar approach:

  • Elixir Forum: How to use Absinthe.MiddleWare to catch exception?

    • Ben Wilson, one of the creators of Absinthe, recommends replacing the Resolution middleware with a custom one that executes the resolver in a try block

    • This would not handle exceptions in other middleware (but maybe that's how it should be)

  • Blog Post: Handling Elixir Exceptions in Absinthe using Middleware

    • Tries to do the same thing, but doesn't follow the Absinthe.Middleware behaviour spec
    • Instead wraps all existing middleware in anonymous functions
    • We also lose insight into the enabled middleware and their configs when inspecting them because of this

My Solution

My approach is a bit inspired from the blog post, but I've tried to follow the behaviour and use middleware tuple spec instead of anonymous functions:

Middleware Definition:

defmodule MyApp.ExceptionMiddleware do
  @behaviour Absinthe.Middleware
  @default_error {:error, :internal_server_error}
  @default_config []

  @spec wrap(Absinthe.Middleware.spec()) :: Absinthe.Middleware.spec()
  def wrap(middleware_spec) do
    {__MODULE__, [handle: middleware_spec]}
  end

  @impl true
  def call(resolution, handle: middleware_spec) do
    execute(middleware_spec, resolution)
  rescue
    error ->
      Sentry.capture_exception(error, __STACKTRACE__)
      Absinthe.Resolution.put_result(resolution, @default_error)
  end

  # Handle all the ways middleware can be defined

  defp execute({{module, function}, config}, resolution) do
    apply(module, function, [resolution, config])
  end

  defp execute({module, config}, resolution) do
    apply(module, :call, [resolution, config])
  end

  defp execute(module, resolution) when is_atom(module) do
    apply(module, :call, [resolution, @default_config])
  end

  defp execute(fun, resolution) when is_function(fun, 2) do
    fun.(resolution, @default_config)
  end
end

Applying it in Schema:

The wrap/1 method is called on all query/mutation middleware

def middleware(middleware, _field, %{identifier: type}) when type in [:query, :mutation] do
  Enum.map(middleware, &ExceptionMiddleware.wrap/1)
end

Result:

Which converts them to this:

[
  {ExceptionMiddleware, handle: {AuthMiddleware, [access: :admin]}},
  {ExceptionMiddleware, handle: {{Resolution, :call}, &some_resolver/3}},
  {ExceptionMiddleware, handle: {Subscription, []}},
  {ExceptionMiddleware, handle: &anon_middleware/2},
]

Question(s)

I'm still not fully confident in my approach because this feels a bit hacky and a misuse of absinthe's middleware. So, I'm interested in getting answers to a couple of questions:

  • What other possible approaches are there? Is using Absinthe middleware the right choice after all?
  • If so, does it make sense to wrap all middleware or just replace the Absinthe.Resolution middleware?
  • And what's the canonical way of doing that?
Sheharyar
  • 73,588
  • 21
  • 168
  • 215
  • 1
    “what's the canonical way of doing that?”—as you surely know, the canonical way would be to _let it crash_. But I understand your point with batches and I honestly do not know if it is desirable. If not, your way of doing this looks fine to me. Did you try to dig into [tag:apollo] source to maybe find any luck? – Aleksei Matiushkin Aug 15 '19 at 05:17
  • No, because we do want it to batch, and an exception in one will always crash other query processes. So it's more desirable for us to handle it on the backend. – Sheharyar Aug 15 '19 at 18:30

1 Answers1

1

Here at Decisiv we are using an internal tool called Blunder for handling exception and errors. That might be useful for you.

https://github.com/Decisiv/blunder-absinthe

Marcos Tapajós
  • 546
  • 2
  • 8