I can't get Elixir error handling right

In a with case, with many operations, how can i individually handle errors?

Match on the reason. If though all of them simply return the same reason, you can only wrap your stuff in extra tuples, eg (but untested as on mobile):

with {:fetch_data, {:ok, data}} <- {:fetch_data, fetch_data(stuff)} do
  data
else
  {:fetch_data, :error} -> {:error, :fetch_data_failed}
end
1 Like

Damm, i was thinking in this exactly solution, but it just seems so verbose. Take this example:

with {:parse_limit, {:ok, limit}} <- {:parse_limit, parse_limit(request)},
     {:parse_offset, {:ok, offset}} <- {:parse_offset, parse_offset(request)},
     {:parse_filters, {:ok, filters}} <- {:parse_filters, parse_filters(request)} do
      # send the content back to user
else
  {:parse_limit, :error} -> ...
  {:parse_offset, :error} -> ...
  {:parse_filters, :error} -> ...
end

It’s seems very verbose, and other thing, how can i handle the middle pre values? Imagine that the error happens on parse_filters, from the parse_filters error, how can i access the limit and offset?

By adding them to the tuple.

I would suggest for you change each individual parse_ function to already return the error in the format you desire. Your code should look like this:

with {:ok, limit} <- parse_limit(request),
     {:ok, offset} <- parse_offset(request, offset),
     {:ok, filters} <- parse_filters(request, limit, offset) do
  # send the content back
else
  {:error, message} -> # send error message back to the user
end

If each parse function already returns the error in the format desired, it gets much simpler, as you no longer need to tag everything just to untag it right after.

4 Likes

But the thing is, maybe, each error can have a individual treatment. Parsing limit, offset and filters, always will return a 4XX HTTP error to the client, as the request is invalid. A repository error, on the other side, will return a 5XX.

In this case, the only way is to tag the errors like @NobbZ was talking about, right?

Scott Wlaschin: Use Functions!

...
  with {:ok, limit} <- parse_limit(request),
       {:ok, offset} <- parse_offset(request, limit),
       {:ok, filters} <- parse_filters(request, limit, offset) do
     # send the content back
  else
    error -> handle_parse_error(error)
  end
end

def handle_parse_error({:error, {:parse_limit, message}}) do
  # handle parse_limit error
  ...
end
def handle_parse_error({:error, {:parse_offset, message, limit}}) do
  # handle parse_offset error
  ...
end
def handle_parse_error({:error, {:parse_filters, message, limit, offset}}) do
  # handle parse_filters error
  ...
end

12 Likes

Yep, this is it, you nailed it. I’m still grasping with Elixir, but this looks very good.

1 Like

A+ for that talk link

A further step you can take is to create an error struct, which might implement the Exception behavior.

One big benefit of using structs over tuples for the error is you can add or remove data from a struct easier than you can a tuple (it won’t change the shape for pattern matching).

I generally tend towards creating a generic struct type for my app that looks like:

%YourApp.Error{type: :x, reason: :y, data: %{...}}

Using this, I would create instances of the error struct when parsing and @peerreynders’s handle error function might then look like this:

def handle_parse_error({:error, %YourApp.Error{type: :parse_limit} = error}) do
  do_some_thing_with(error.data.message)
end

def handle_parse_error({:error, %YourApp.Error{type: :parse_offset} = error}) do
  do_something_with(error.data.message, error.data.offset)
end

A further refinement I’ve been working on, particularly for Phoenix apps, is to limit the number of types for those generic errors. For example, the parse errors might be:

%YourApp.Error{
  type: :invalid_input,
  reason: :could_not_parse_limit,
  data: %{message: "..."}
}

%YourApp.Error{
  type: :invalid_input,
  reason: :could_not_parse_offset,
  data: %{message: "...", limit: ...}
}

%YourApp.Error{
  type: :invalid_input,
  reason: :could_not_parse_filters,
  data: %{message: "...", offset: ..., limit: ...}
}

Then you can define handlers for these generic errors in a action_fallback controller, in this case to render a 400 response for :invalid_input errors. Then your with clause in a controller just looks like:

with {:ok, limit} <- parse_limit(request),
     {:ok, offset} <- parse_offset(request, limit),
     {:ok, filters} <- parse_filters(request, limit, offset) do
  # send the content back
end # No need to handle errors, let them fall through and the action_fallback will render
6 Likes

If it is decoupling you’re after, the reason in {:error, reason} should be entirely opaque to the client of the module (which issued the error) - i.e.:

  • only the module that issued the error tuple should know the actual structure of reason
  • and the module should offer functions that allow the client to intelligently interrogate the relevant aspects of reason. For example:
MyModule.error_message(reason)

could retrieve the core message of the error regardless whether reason is just a plain string or whether it needs to be assembled from various labyrinthically nested maps that are part of reason.

Similarly the module can offer higher level assessments like MyModule.error_timeout?(reason) or MyModule.error_temporary?(reason) while remaining in complete control of the actual structure and content of the reason information - never disclosing any details to the client other than through the module’s reason interrogation functions.

1 Like

That is a perfectly valid (and probably often good) approach, though regardless of whether you react to the error itself or the return value of a function that interrogates the error you have a point of coupling.

I find using an error/exception struct in {:error, %Error{}} tuples provides a good, flexible solution for representing errors that can be used simply in with or an action_fallback or any other error handling situation. Tuples or opaque objects or any similar concept could work just as well, I just presented what I’ve found to be a good solution.

I think the important part is that people should strive to return context rich {:error, reason} tuples instead of just returning {:error, :not_found} or even worse, just returning :error.

1 Like

Fair enough - though under particular circumstances coupling to functions can make more sense - example: protocols.

1 Like

But this doesn’t work, does it? handle_parse_error is assuming those 3 functions return tuples that include their own function name in an inner tuple?

def parse_limit(request) do
  ..
  {:error, {:parse_limit, :request_had_typo_or_something}}
end

Instead of the more usual (I thought?) {:error, :request_had_typo_or_something}

What doesn’t work?

The problem with {:error, :reason_atom} is that it doesn’t have a lot of context. If you’re handling that error exactly at the point where the problem occurs then you probably have enough context, but if you’re dealing with the error somewhere else, such as a FallbackController, then you’ll generally want to make your errors richer.

For example, if you’re not at the point the error occurred the :error returned by Map.fetch is probably less useful than {:error, required_parameter_not_found}, which is in turn less useful than {:error, {:required_parameter_not_found, :i_am_a_required_parameter}}.

Another variation on the same theme (see previous post):

...
  with {:parse_limit, {:ok, limit}} <- {:parse_limit, parse_limit(request)},
       {:parse_offset, limit, {:ok, offset}} <- {:parse_offset, limit, parse_offset(request)},
       {:parse_filters, limit, offset, {:ok, filters}} <- {:parse_filters, limit, offset, parse_filters(request)} do
     # send the content back
  else
    error -> handle_parse_error(error)
  end
end

def handle_parse_error({:parse_limit, {:error, message}}) do
  # handle parse_limit error
  ...
end
def handle_parse_error({:parse_offset, limit, {:error, message}}) do
  # handle parse_offset error
  ...
end
def handle_parse_error({:parse_filters, limit, offset, {:error, message}}) do
  # handle parse_filters error
  ...
end