ElixirMiddleCoding

Как работает обработка ошибок в Elixir (tagged tuples, with, try/rescue)?

Elixir использует tagged tuples ({:ok, value}/{:error, reason}) для ожидаемых ошибок, with для цепочек операций с единым else-обработчиком, и try/rescue только для исключений от внешних библиотек — неожиданные ошибки пусть роняют процесс, Supervisor перезапустит.

Стратегии обработки ошибок в Elixir

Elixir различает два типа ошибочных ситуаций: ожидаемые (invalid input, not found, unauthorized) и неожиданные (баги, недоступная БД). Для каждого — свой инструмент.

Tagged tuples — основной паттерн

Большинство функций возвращают {:ok, value} или {:error, reason}. Это явный, типобезопасный контракт без исключений:

defmodule UserService do
  def find_user(id) when is_integer(id) do
    case Repo.get(User, id) do
      nil  -> {:error, :not_found}
      user -> {:ok, user}
    end
  end
  def find_user(_), do: {:error, :invalid_id}

  def update_email(user, email) do
    user
    |> User.changeset(%{email: email})
    |> Repo.update()
    # Ecto.Repo.update/1 сам возвращает {:ok, user} | {:error, changeset}
  end
end

with — цепочка операций с единой точкой сбора ошибок

with позволяет выстроить последовательность шагов, где каждый зависит от успеха предыдущего. Первый несовпавший паттерн уходит в else:

defmodule OrderProcessor do
  def process(order_params, user_id) do
    with {:ok, user}     <- UserService.find_user(user_id),
         :ok             <- check_subscription(user),
         {:ok, order}    <- create_order(order_params, user),
         {:ok, _payment} <- charge(user, order.total) do
      {:ok, order}
    else
      {:error, :not_found}      -> {:error, :user_not_found}
      {:error, :no_subscription} -> {:error, :upgrade_required}
      {:error, %Ecto.Changeset{} = cs} -> {:error, {:validation, cs}}
      {:error, :payment_declined} -> {:error, :payment_failed}
    end
  end

  defp check_subscription(%User{plan: :pro}), do: :ok
  defp check_subscription(_), do: {:error, :no_subscription}
end

try/rescue — для исключений и внешних библиотек

try/rescue используется для перехвата исключений (RuntimeError, ArgumentError и т.д.) — обычно от сторонних библиотек или при работе с внешними системами:

defmodule JsonParser do
  def parse(raw) do
    {:ok, Jason.decode!(raw)}
  rescue
    Jason.DecodeError -> {:error, :invalid_json}
    exception         -> {:error, {:unexpected, Exception.message(exception)}}
  end
end

# Пример с after для cleanup
def read_file(path) do
  file = File.open!(path)
  try do
    {:ok, IO.read(file, :all)}
  rescue
    e -> {:error, Exception.message(e)}
  after
    File.close(file)  # выполняется всегда
  end
end

raise и throw

# raise — для программных ошибок (баги, нарушения инвариантов)
defmodule Config do
  def get!(key) do
    case System.get_env(key) do
      nil   -> raise "Required env var #{key} is not set"
      value -> value
    end
  end
end

# throw/catch — для нелокальных выходов из глубокой рекурсии (редко)
def find_in_tree(tree, target) do
  try do
    traverse(tree, target)
    :not_found
  catch
    {:found, value} -> {:ok, value}
  end
end

Supervisor как последний рубеж

Философия Erlang/Elixir — «let it crash»: неожиданные ошибки пусть упадут процесс, Supervisor перезапустит его в чистом состоянии. try/rescue нужен только когда падение процесса неприемлемо (HTTP request handler, критичные cleanup-операции).

Подводные камни

  • Избыточный rescue. Оборачивать весь код в try/rescue — антипаттерн. Это маскирует баги вместо того чтобы дать им упасть и быть зафиксированными.
  • with без else пробрасывает несовпавшие паттерны. Если шаг вернул nil вместо {:ok, _}, with без else вернёт nil — это трудно отследить.
  • Смешение tagged tuples и исключений. Если часть функций возвращает {:error, _}, а часть бросает исключения — API непредсказуем. Выбирайте одну стратегию в модуле.
  • Атом причины ошибки — не всегда достаточно. {:error, :failed} без контекста усложняет отладку. Передавайте структурированную причину: {:error, {:db_error, pg_code}}.
  • rescue Exception — слишком широко. Перехват базового Exception скрывает ErlangError, UndefinedFunctionError и системные ошибки. Указывайте конкретные типы.
  • after не подавляет исключение. Блок after выполняется, но исключение продолжает распространяться. Не путайте его с rescue.
  • Ecto changeset — не исключение. Repo.insert!/1 бросает при ошибке валидации; Repo.insert/1 возвращает {:error, changeset}. В продакшн-коде почти всегда предпочтителен второй вариант.

Common mistakes

  • Сводить error handling к названию метода без lifecycle и failure path.
  • Игнорировать модель runtime: Elixir компилируется в BEAM bytecode и наследует процессы, message passing, supervision и hot-code friendly модель Erlang VM.
  • Не отделять validation, authorization, transaction boundary и business logic.

What the interviewer is testing

  • Объясняет error handling через конкретную точку lifecycle в Elixir.
  • Приводит корректный минимальный пример без вымышленных методов или callbacks.
  • Называет edge cases: пустые значения, ошибки, транзакции, безопасность или concurrency.

Sources

Related topics