Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Fumbling with Exception

Fumbling with Exception

You’ve heard of “Let it Crash” and that’s wise advice. Most of the time. Other times a custom exception might be a better choice. Let’s talk about what exceptions in Elixir truly are and how they work to gain some intuition for when they are the right tool to reach for. They can play very nicely alongside error tuples. Even if we never raise them. I’ll discuss a case in which I named an exception a “fumble” because it might be recoverable.

Greg Vaughn

October 12, 2021
Tweet

More Decks by Greg Vaughn

Other Decks in Technology

Transcript

  1. About me • @gvaughn or @gregvaughn on GitHub, Twitter, Slack,

    ElixirForum, etc. • Started learning Elixir in 2013 • Professionally since 2017 We're hiring!
  2. "Let it Crash" or maybe not (yet) •Supervision trees are

    a wonderful safety net •Sequential vs. Concurrent code •Erlang uses errors/exits via throw/catch •Elixir brings exceptions to the party •Elixir wraps most Erlang errors with exceptions
  3. Exception is a Behaviour defmodule Exception do @type t ::

    %{ required(:__struct__) => module, required(:__exception__) => true, optional(atom) => any } @callback exception(term) :: t @callback message(t) :: String.t() @callback blame(t, stacktrace) :: {t, stacktrace} @optional_callbacks [blame: 2] # 1500 lines later end
  4. Exceptions are Structs # in Kernel defmacro defexception(fields) do quote

    bind_quoted: [fields: fields] do @behaviour Exception struct = defstruct([__exception__: true] ++ fields) # conditionally supply overridable message/1 if has a :message field @impl true def exception(args) when Kernel.is_list(args) do # some backwards compatibility concerns today, # but for Elixir 2.0 it will be simply Kernel.struct!(struct, args) end end defoverridable exception: 1 end
  5. iex> defmodule Boo, do: defexception [:message, :extra] {:module, Boo, ...

    I Explore in iex base exception iex> inspect %Boo{}, structs: false "%{__exception__: true, __struct__: Boo, extra: nil, message: nil}" iex> Boo.module_info :attributes [vsn: [4258765674426512055423525333953], behaviour: [Exception]] iex> inspect %Boo{} "%Boo{extra: nil, message: nil}"
  6. I Explore in iex raise iex> raiser = fn ->

    try do raise %Boo{message: "boo!"} ...> rescue (e -> IO.puts "caught #{inspect e}") ...> end end iex> raiser.() caught %Boo{extra: nil, message: "boo!"} iex> raiser2 = fn -> try do raise(Boo, message: "boo2!", extra: "scary") ...> rescue (e -> IO.puts "caught #{inspect e}") ...> end end iex> raiser2.() caught %Boo{extra: "scary", message: "boo2!"} iex> raiser3 = fn -> try do raise Boo.exception(message: "boo3!", extra: "scary") ...> rescue (e -> IO.puts "caught #{inspect e}") ...> end end iex> raiser3.() caught %Boo{extra: "scary", message: "boo3!"}
  7. Three ways to raise • raise %Boo{message: "boo!"} • basic

    passthrough because it's already an Exception • raise(Boo, message: "boo2!", extra: "scary") • arity 2 that calls `Boo.exception/1` • raise Boo.exception(message: "boo3!", extra: "scary") • created by `defexception/1` • end up at `:erlang.error/1` • Raises an exception of class error with the arg as the reason, where reason is any term. • Generates the stacktrace
  8. iex> defmodule Boo do ...> defexception [:message, :extra] ...> def

    blame(me, stack) do ...> {%{me | message: "the devil made Boo do it"}, stack} ...> end ...> end {:module, Boo, ... I Explore in iex de fl ecting the blame iex> raise Boo ** (Boo) the devil made Boo do it
  9. Friendly blame defmodule KeyError do # snip def blame(%{term: term,

    key: key} = exception, stacktrace) do message = message(key, term) if is_atom(key) and (map_with_atom_keys_only?(term) or Keyword.keyword?(term)) do hint = did_you_mean(key, available_keys(term)) {%{exception | message: message <> IO.iodata_to_binary(hint)}, stacktrace} else {%{exception | message: message}, stacktrace} end end defp did_you_mean(missing_key, available_keys) do suggestions = for key <- available_keys, distance = String.jaro_distance(Atom.to_string(missing_key), Atom.to_string(key)), distance >= @threshold, do: {distance, key} case suggestions do [] -> [] suggestions -> [". Did you mean one of:\n\n" | format_suggestions(suggestions)] end end # snip end
  10. Erlang errors Three classes • throws • expect other code

    to handle • longjump or goto • exit • a process' "dying breath" • links/monitors react to "death" • error • something bad happened, but maybe somebody (somecode) can handle it • includes stacktrace
  11. Error handling Erlang/Elixir di ff erences • Erlang • try/catch/after

    • `catch` can pattern match errors/exits/throws • Elixir • try/rescue/catch/else/after • Erlang errors are wrapped (or ErlangError) in pattern match of `rescue` • `else` pattern matches the return value of the `try` body
  12. Libraries vs. Applications vs. Frameworks Design concerns • Libraries •

    Utility code to be called • Don't make assumptions about what is exceptional (bang and non-bang options) • Applications • Caller of utility libraries • Decides what is worth catching or crashing about • Frameworks • Hollywood Principle: don't call us, we'll call you • Opinionated on what is worth catching or crashing about (with hooks, perhaps) • Your business domain logic is provided as callbacks/hooks to the framework
  13. Exceptional Libraries O ff ering options • Use {:ok, value}

    or {:error, exception} • Bang versions are straightforward to write - case raise the exception • Unbang functions o ff er great fl exibility to callers • Simple case of with/else • {:error, exception} -> Logger.error(Exception.message(exception)) • Consider including `:reason` fi eld so pattern matches can be speci fi c
  14. Meet the OrderFumble exception refactoring technique defmodule OrderFumble do @enforce_keys

    [:reason, :step, :context] defexception @enforce_keys def new(kw) do %OrderContext{} = Keyword.get(kw, :context) struct!(__MODULE__, kw) end @impl Exception def exception(kw), do: new(kw) @impl Exception def message(%__MODULE__{} = e) do "#{inspect(e.reason)} (in #{e.step})" end # snip end
  15. Meet the OrderFumble exception refactoring technique defmodule OrderFumble do #

    snip @manual [inventory: :mismatched_counts] @retryable [payment: :timeout] def mitigate(%__MODULE__{} = e) do case {e.step, e.reason} do conditions when conditions in @manual -> handle_notification(e) conditions when conditions in @retryable -> handle_retry(e) _ -> raise e end {:error, e} end # snip handle_notification/1, handle_retry/1 end
  16. Takeaways from an exceptional talk • Exceptions are both behaviours

    and structs (aka objects) • blame/2 with impunity for great developer experience • Consider returning {:error, exception} • Exception modules can be refactoring targets Thanks! • Catch me online as @gvaughn or @gregvaughn • We're hiring at • Questions?