Error handling with try/rescue vs with

I have a function work that get’s a record, requests data from a remote api and updates that record or returns some error tuples.

It’s possible for the DB record get to fail if the id is invalid and unsure what the generally preferred way of handling that kind of error is.

Personally I prefer option 2, but I worry about its limitations as described below.

Option 1

try/rescue first, case-match no the result of that, nest the with. Seems reasonable if a little verbose? Perhaps there is a nicer way to tie the try result with the case statement? This style reads ok here, but falls down when the with content is actually fairly long.

def work(id) do
  fruit = try do
    # will raise on bad id
    Fruits.get_fruit!(id)
  rescue
    Ecto.NoResultsError ->
      {:error, :invalid_fruit_id}
  end
  
  case fruit do
    {:error, e} -> {:error, e}
    fruit ->
      with {:ok, taste} <- TastesAPi.get_taste_for_fruit(fruit.name) do
        {:ok, fruit} = Fruit.update_fruit(fruit, %{taste: taste})
      else
        {:error, 404} -> {:error, :no_taste_for_fruit}
        {:error, :rate_limit} -> {:error, :api_rate_limited}
      end      
  end
end

Option 2

Move everything into a with, use a method that wont raise, check for nil instead.
It’s nicer to read, but how would you handle two functions that might return nil and want to return a different error depending on which failed? (Imagine you had Fruits.get_fruit_or_nil along with Mouth.get_mouth_or_nil and wanted to return :invalid_fruit_id or :invalid_mouth_id) Maybe if your handling is that complex it’s just an indication that you need more than a compressed with – returning to option 1?

def work(id) do

  with fruit when not is_nil(fruit) <- Fruits.get_fruit_or_nil(id),
       {:ok, taste} <- TastesAPI.get_taste_for_fruit(fruit.name)
  do
    {:ok, fruit} = Fruit.update_fruit(fruit, %{taste: taste})
  else
    nil -> {:error, :invalid_fruit_id}
    {:error, 404} -> {:error, :no_taste_for_fruit}
    {:error, :rate_limit} -> {:error, :api_rate_limited}
  end
end

Option 3

try/rescue with a throw. Not great, means you have to wrap your caller in try/catch. Seems like a worse version of option 1 by all metrics.

def work(id) do
  fruit = try do
    # will raise on bad id
    Fruits.get_fruit!(id)
  rescue
    Ecto.NoResultsError ->
      # use throw to 'return' early.
      throw {:error, :invalid_fruit_id}
  end
  
  with {:ok, taste} <- TastesAPi.get_taste_for_fruit(fruit.name)
  do
    {:ok, fruit} = Fruit.update_fruit(fruit, %{taste: taste})
  else
    nil -> {:error, :inavlid_fruit_id}
    {:error, 404} -> {:error, :no_taste_for_fruit}
    {:error, :rate_limit} -> {:error, :api_rate_limited}
  end
end

I just tag them:

with(
  ...
  {:a, a} <- {:a, do_something()},
  {:b, b} <- {:b, do_something_else(a)},
  ...
do
  ...
else
  ...
  {:a, err} -> ...
  {:b, err} -> ...
  ...
end

The happy_path library (precursor to Elixir getting with) used @tagname to do quick-tags like that, it was very convenient.

4 Likes

Ah of course, fantastic.

1 Like