What is the most idiomatic way to handle fine grained errors in Elixir?

The {:error, _} is quite common and used everywhere, but what this is telling me is that an error happened, and nothing more. Errors usually have way more context attached to it. Maybe it was an user request error, in this case I’ll not log an entry on the APM, or maybe it was an error where I can retry in a few milliseconds, you got the gist of it.

What is the best way to encode more information at the errors?
{:error, :error_type, .... }? But then this can be a pattern match nightmare because errors could have a dynamic quantity of fields. Maybe {:error, %{extra details here}}?

Do you want the small error details at your application level or logging? There are big differences and implications between one or the other. If you are trying to write defensive code that handles all errors at application layer, then you will not have many tools to help you out, as that opposes the idea of OTP to let it crash.

If we are talking about logging, then this problem is already solved. The logger formatter allows to specify your custom format of the logs. The great additional feature it has it’s called metadata:

$metadata - user controlled data presented in "key=val key2=val2 " format

In your codebase you can use Logger.metadata/1 to set all the custom metadata you might require when something gets logged, the great thing is that this is also automatically applied to when errors are logged, so you basically don’t have to do anything custom.

1 Like

The idea is not to try to handle all errors at the application level, for sure there are things that are going to happen that I’ll not catch/handle, and let it crash will come in to manage the situation. My main issue is, how to deal with business logic that derives from errors. Again, there are many things that can’t be encoded solely on a :error, depending on the error type/kind you may need to do database inserts or deletions, send messages, return different HTTP status code, and so on.

Right now I’m using {:error, %{cause: :user_exists, ....}} to encode the errors. But I don’t want to spread this pattern deeply at my codebase in case there are better ways to deal with it, that’s why I’m checking with you guys.

case operation() do
  {:error, _} -> # When I want to deal with only the error scenario.
  {:error, %{cause: :user_exists} -> # If I need to deal with specific errors.
  {:error, %{cause: :user_exists, user_id: user_id} -> # And then if I need to get contextual information from the error. But I would mostly use the first two matchs.

I am not sure I can give you much support, maybe other members have better insight. From my standpoint it seems this is a xy problem and what you are facing is just a symptom of the design choice of the project.

I think it would be great if you could showcase a full example on how you are doing it up to this point.

A common approach taken by libraries like mint and others is normalizing {:error, exceptions_struct}, which allows you to hold more data in regards to a given error, but still allow for normalized handling through {:error, _} as well as all the functionality of the Exception protocol across multiple error sources.

5 Likes

I have used this {:error, exception_struct} approach to good effect in an order processing system. Not only could I have a field in the exception struct to specify the “step” of order processing that failed, but the exception module itself served as a nice place for a mitigate function to centralize knowledge of what can be retried, what should just be logged, what should go into a manual human-monitored error queue, etc.

3 Likes