Как работает обработка ошибок в 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.