Continuação de GenServer the “rabbit hole”. Se você ainda não leu, recomendo passar por lá antes — vamos partir do mesmo projeto genserver_study e dos mesmos conceitos de mailbox, supervisor e max_restarts.

No artigo anterior nós exploramos como um GenServer “mal comportado” pode encerrar toda a árvore de supervisão quando estoura o max_restarts do supervisor. Mostramos como mensagens são perdidas no mailbox e como o “Let it crash” não é exatamente um cheque em branco para deixar processos quebrarem aleatoriamente.

Naquele exemplo, entretanto, o erro nascia dentro do próprio GenServer — uma exception explícita no handle_info/2. A vida real raramente é tão direta. Boa parte dos restarts de pods Elixir que eu já investiguei em produção tem a mesma assinatura: um worker singleton que não tinha nenhuma exception no seu próprio código simplesmente desaparece, levando junto seu supervisor, e o BEAM sai com exit-code != 0. Você olha a memória do pod e está em ~5GB com limite de 24Gi. Não é OOM. É supervisor cascade.

A pergunta que fica é: como um worker que nunca crasha morre?

A resposta envolve três conceitos que costumam ser tratados como detalhe nas docs oficiais e que, juntos, formam a maior fonte de “restart fantasma” em apps Elixir:

  1. Process links propagam exits silenciosamente — e quase tudo que você usa cria links por baixo dos panos.
  2. trap_exit transforma exits em mensagens, mas se você não tem clauses para todas as variantes, o :function_clause te derruba.
  3. Validações que ficaram só “na camada de cima” — erros conhecidos viram exceptions de runtime no caminho mais crítico.

Vamos por partes 🐇.


Quando um processo Elixir começa a rodar, ele pode estar conectado a outros processos por dois mecanismos diferentes: links e monitors. Os dois servem para “saber que alguém morreu”, mas a diferença é fundamental:

  • Link (Process.link/1, spawn_link/1, Task.async/1, Task.async_stream/3): conexão bidirecional. Se A está linkado com B e B morre com reason != :normal, então A também recebe o sinal de exit e morre junto (a menos que A esteja com trap_exit ativado).
  • Monitor (Process.monitor/1, Task.async_nolink/1): conexão unidirecional. A recebe uma mensagem {:DOWN, ref, :process, pid, reason} quando B morre, mas A não é afetado.

Por que isso importa? Porque um link te dá uma garantia muito forte: se um dos lados morre, o outro também morre. Em uma árvore de supervisão isso é exatamente o que você quer — o supervisor está linkado aos filhos, então ele sabe que alguém caiu e pode tomar uma decisão (restartar, escalar, encerrar). Mas em um worker comum que não deveria morrer junto com seus auxiliares, o link silencioso é uma armadilha.

E o pior: a maioria das funções “ergonômicas” de spawn em Elixir cria links por padrão. Vamos olhar uma das mais comuns: Task.async_stream/3.


Da doc oficial:

Each element of enumerable will be prepended to the given args and processed by its own task. Those tasks will be linked to an intermediate process that is then linked to the caller process.

If you find yourself trapping exits to ensure errors in the tasks do not terminate the caller process, consider using Task.Supervisor.async_stream_nolink/6 to start tasks that are not linked to the caller process.

Em outras palavras: se qualquer uma das Tasks crashar com uma exception não tratada, o processo que chamou Task.async_stream/3 morre junto. A doc inclusive já avisa, mas é fácil deixar passar quando você está fazendo um simples fan-out paralelo de IO.

Vamos reproduzir isso no nosso projeto genserver_study. Crie um novo módulo BatchServer:

defmodule BatchServer do
  use GenServer

  @prefix "[BatchServer]"
  @interval 1_500

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  @impl true
  def init(_) do
    IO.puts("#{@prefix} Initializing GenServer")
    schedule_next_run()
    {:ok, %{batches: 0}}
  end

  @impl true
  def handle_info(:run_batch, %{batches: n} = state) do
    IO.puts("#{@prefix} Iniciando batch #{n + 1} (PID #{inspect(self())})")

    1..5
    |> Task.async_stream(&process_job/1,
      ordered: false,
      max_concurrency: 3,
      timeout: 1_000
    )
    |> Stream.run()

    IO.puts("#{@prefix} Batch #{n + 1} concluído")
    schedule_next_run()
    {:noreply, %{state | batches: n + 1}}
  end

  defp process_job(id) do
    IO.puts("#{@prefix} Processando job #{id}")
    :timer.sleep(100)

    # Job 3 simula uma falha externa não esperada
    # (imagine: API externa retornou body inválido, payload > 255 chars, etc.)
    if id == 3 do
      raise "API externa devolveu erro inesperado"
    end

    IO.puts("#{@prefix} Job #{id} done")
  end

  defp schedule_next_run, do: Process.send_after(self(), :run_batch, @interval)
end

Adicione na árvore de supervisão substituindo os módulos anteriores:

def start(_type, _args) do
  children = [
    BatchServer
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

Suba a aplicação:

iex -S mix

[BatchServer] Initializing GenServer
[BatchServer] Iniciando batch 1 (PID #PID<0.156.0>)
[BatchServer] Processando job 1
[BatchServer] Processando job 2
[BatchServer] Processando job 3

18:42:11.301 [error] Task #PID<0.160.0> started from BatchServer terminating
** (RuntimeError) API externa devolveu erro inesperado
    (genserver_study 0.1.0) lib/genserver_study/batch_server.ex:34: BatchServer.process_job/1
    ...
Function: &BatchServer.process_job/1
    Args: [3]

18:42:11.302 [error] GenServer BatchServer terminating
** (EXIT from #PID<0.156.0>) shell process exited with reason:
   an exception was raised:
    ** (RuntimeError) API externa devolveu erro inesperado
        (genserver_study 0.1.0) lib/genserver_study/batch_server.ex:34: BatchServer.process_job/1

[BatchServer] Initializing GenServer
[BatchServer] Iniciando batch 1 (PID #PID<0.166.0>)
...

Repare nas duas linhas em sequência:

  1. Task #PID<0.160.0> … terminating — uma das Tasks levanta RuntimeError.
  2. GenServer BatchServer terminating — e logo em seguida o GenServer morre junto, sem ter executado uma linha sequer do código dele que pudesse falhar.

O BatchServer nunca crashou por culpa própria. A Task estava linkada a um processo intermediário que estava linkado ao BatchServer. Quando a Task levantou exception, o sinal de exit subiu pelos links e matou o GenServer. Se esse loop acontecer rapidamente o suficiente, o supervisor estoura max_restarts e a aplicação inteira sai — exatamente o cenário do incidente real que motivou esse artigo.

💡 Detalhe sutil: o erro foi numa Task. O log mostra a stack trace da Task. Quando você está investigando um restart fantasma e procura o “primeiro erro do GenServer X” nos logs, você não vai achar nada relacionado ao código do GenServer. A causa raiz está em outro processo que estava linkado a ele. Pesquise pelo nome do GenServer como o “started from”, não como o “GenServer terminating”.


A correção mais direta é desacoplar as tasks do worker que as despacha. A função Task.Supervisor.async_stream_nolink/6 faz exatamente isso: as Tasks ficam linkadas a um Task.Supervisor (não ao seu GenServer), e o stream emite tuplas {:ok, value} ou {:exit, reason} para cada elemento — você decide o que fazer com cada uma.

Vamos adicionar um Task.Supervisor na árvore e refatorar o BatchServer:

def start(_type, _args) do
  children = [
    {Task.Supervisor, name: BatchServer.TaskSupervisor},
    BatchServer
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

E no BatchServer, troque o Task.async_stream/3 por:

@impl true
def handle_info(:run_batch, %{batches: n} = state) do
  IO.puts("#{@prefix} Iniciando batch #{n + 1} (PID #{inspect(self())})")

  BatchServer.TaskSupervisor
  |> Task.Supervisor.async_stream_nolink(1..5, &process_job/1,
    ordered: false,
    max_concurrency: 3,
    timeout: 1_000,
    on_timeout: :kill_task
  )
  |> Enum.each(fn
    {:ok, _value} -> :ok
    {:exit, reason} -> IO.puts("#{@prefix} ⚠️  job falhou: #{inspect(reason)}")
  end)

  IO.puts("#{@prefix} Batch #{n + 1} concluído")
  schedule_next_run()
  {:noreply, %{state | batches: n + 1}}
end

Repare em duas mudanças importantes além da troca da função:

  1. on_timeout: :kill_task — sem essa opção, um timeout no async_stream_nolink ainda derruba o caller (on_timeout: :exit é o default!). Com :kill_task, o timeout vira mais um {:exit, :timeout} no stream.
  2. Enum.each com pattern matching para :ok e :exit — agora cada falha é uma tupla que você inspeciona, loga, métrifica, etc. A falha de uma Task não derruba mais o GenServer.

Subindo a aplicação novamente:

iex -S mix

[BatchServer] Initializing GenServer
[BatchServer] Iniciando batch 1 (PID #PID<0.158.0>)
[BatchServer] Processando job 1
[BatchServer] Processando job 2
[BatchServer] Processando job 3

19:01:44.512 [error] Task #PID<0.162.0> started from #PID<0.157.0> terminating
** (RuntimeError) API externa devolveu erro inesperado
...

[BatchServer] Processando job 4
[BatchServer] Processando job 5
[BatchServer] Job 4 done
[BatchServer] Job 5 done
[BatchServer] ⚠️  job falhou: {%RuntimeError{message: "API externa devolveu erro inesperado"}, [...]}
[BatchServer] Batch 1 concluído
[BatchServer] Iniciando batch 2 (PID #PID<0.158.0>)
...

O PID do BatchServer agora é estável entre batches (#PID<0.158.0>). A Task que falhou está logada como “started from #PID<0.157.0>” — esse é o Task.Supervisor, não o nosso GenServer.

🎯 Regra prática: se um GenServer faz fan-out paralelo de trabalho cujo escopo é “esta iteração” (e não a vida do server), use Task.Supervisor.async_stream_nolink/6. Use Task.async_stream/3 apenas quando você quer que o erro derrube o caller (cenários raros, normalmente em scripts de migração ou jobs de batch que rodam isolados em um BEAM dedicado).


Trap exit: a tentação de “só capturar tudo”

A outra opção para conter exits é o Process.flag(:trap_exit, true) que mencionamos lá em cima. Da doc oficial:

If pid is trapping exits, the exit signal is transformed into a message {:EXIT, from, reason} and delivered to the message queue of pid.

Parece a bala de prata: ative trap_exit no seu GenServer e os exits dos processos linkados viram mensagens normais que você trata no handle_info/2. Não morre mais.

Não é tão simples. E a biblioteca Highlander (um singleton manager para cluster Elixir) tem um caso histórico que ilustra bem o problema.

A Highlander ativa trap_exit para detectar conflitos de nome ({:EXIT, _, :name_conflict} em casos de netsplit). O código relevante na versão 0.2.1:

@impl true
def handle_info({:DOWN, ref, :process, _, _}, %{ref: ref} = state) do
  {:noreply, register(state)}
end

def handle_info({:EXIT, _pid, :name_conflict}, %{pid: pid} = state) do
  :ok = Supervisor.stop(pid, :shutdown)
  {:stop, {:shutdown, :name_conflict}, Map.delete(state, :pid)}
end

# ⚠️ E só. Não tem clause para {:EXIT, _, :shutdown}, {:EXIT, _, _}, nem para _msg.

Resultado: quando o supervisor interno do Highlander estourou ele mesmo o max_restarts (cascateando de um bug a montante), ele encerrou com motivo :shutdown. Esse :shutdown chegou no GenServer do Highlander como {:EXIT, pid, :shutdown}. Não existia clause para essa mensagem:function_clause → o singleton inteiro caiu.

Se você ativa trap_exit, você é responsável por tratar todas as variantes possíveis de {:EXIT, _, _}. E mais ainda: idealmente, qualquer GenServer crítico deveria ter um handle_info catch-all como defesa em profundidade contra mensagens desconhecidas (de bibliotecas, de timers órfãos, de monitores antigos):

defmodule SingletonServer do
  use GenServer

  @prefix "[SingletonServer]"

  def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)

  @impl true
  def init(_) do
    Process.flag(:trap_exit, true)
    IO.puts("#{@prefix} Initializing (PID #{inspect(self())})")
    {:ok, %{children: []}}
  end

  # Tratamento específico que importa para a lógica
  @impl true
  def handle_info({:EXIT, _pid, :normal}, state), do: {:noreply, state}

  def handle_info({:EXIT, pid, :shutdown}, state) do
    IO.puts("#{@prefix} child #{inspect(pid)} desligou ordenadamente")
    {:noreply, state}
  end

  def handle_info({:EXIT, pid, reason}, state) do
    IO.puts("#{@prefix} ⚠️  child #{inspect(pid)} morreu com #{inspect(reason)}")
    # Decide aqui: re-spawnar? métrica? alerta? mas NÃO crashar.
    {:noreply, state}
  end

  # Defesa em profundidade — qualquer outra mensagem desconhecida vira log + segue
  def handle_info(msg, state) do
    IO.puts("#{@prefix} ⚠️  mensagem desconhecida: #{inspect(msg)}")
    {:noreply, state}
  end
end

⚠️ Repare na ordem: clauses mais específicas primeiro, catch-all (handle_info(msg, state)) por último. Erlang/Elixir resolve por ordem textual no módulo.

Quando NÃO ativar trap_exit

Existe uma armadilha aqui também. trap_exit muda o ciclo de vida do seu processo de uma forma importante: ele também intercepta o sinal de shutdown vindo do supervisor pai. Isso significa que se você ativar trap_exit, precisa implementar terminate/2 corretamente, e o supervisor pai vai esperar :shutdown_timeout (default 5s) por você antes de matar com :kill. Isso pode atrasar rolling updates e graceful shutdowns se você não tomar cuidado.

Resumo prático:

CenárioUse
Worker comum que despacha tasks paralelasNão ative trap_exit. Use Task.Supervisor.async_stream_nolink.
GenServer que precisa fazer cleanup ordenado de recursos no shutdownAtive trap_exit e implemente terminate/2.
Manager de processos (singleton, registry, pool) que precisa reagir a morte de filhosAtive trap_exit e trate todas as variantes de {:EXIT, _, _}.

Vamos ver o catch-all em ação

Volte ao projeto e crie o SingletonServer acima. Adicione na árvore de supervisão junto com um GenServer “irmão” que vai morrer de propósito:

defmodule NoisyChild do
  use GenServer

  def start_link(_), do: GenServer.start_link(__MODULE__, %{})

  @impl true
  def init(_) do
    parent = Process.whereis(SingletonServer)
    Process.link(parent)   # cria link manualmente com SingletonServer
    Process.send_after(self(), :die, 200)
    {:ok, %{}}
  end

  @impl true
  def handle_info(:die, state) do
    {:stop, :weird_reason, state}
  end
end
def start(_type, _args) do
  children = [
    SingletonServer,
    NoisyChild
  ]

  opts = [strategy: :one_for_one, name: GenserverStudy.Supervisor]
  Supervisor.start_link(children, opts)
end

Suba e veja o que acontece:

iex -S mix

[SingletonServer] Initializing (PID #PID<0.156.0>)
[SingletonServer] ⚠️  child #PID<0.157.0> morreu com :weird_reason
[SingletonServer] ⚠️  child #PID<0.159.0> morreu com :weird_reason
[SingletonServer] ⚠️  child #PID<0.161.0> morreu com :weird_reason
[SingletonServer] ⚠️  child #PID<0.163.0> morreu com :weird_reason
[error] GenServer #PID<0.157.0> terminating
** (stop) :weird_reason
...

# E o supervisor encerra a app por causa do max_restarts do NoisyChild
# MAS o SingletonServer nunca crashou

O SingletonServer viu cada morte do NoisyChild, logou, e continuou rodando. Sem o catch-all e sem o clause de {:EXIT, _, _}, o SingletonServer teria caído junto na primeira morte do irmão, com um :function_clause. Foi exatamente esse pattern que o Highlander 0.2.1 falhou em proteger.


A causa raiz que ninguém quer admitir: validação na origem

Tudo que conversamos até aqui foi sobre conter o blast radius quando um erro acontece. Mas tem uma camada que vem antes: muitas vezes, o erro nunca deveria ter chegado a ser uma exception em primeiro lugar.

Em um incidente real que motivou esse artigo, a sequência foi:

  1. API externa devolveu um payload de erro grande.
  2. O código fez "#{inspect(reason)}" para salvar na coluna error VARCHAR(255).
  3. A string passou de 255 chars → Postgrex.Error 22001 na hora do Repo.update/1.
  4. Como a Task estava linkada (vide acima), o exit cascateou.

Note que os passos 2 e 3 são erros previsíveis e conhecidos. Tudo que precisaria existir era:

# No changeset
field
|> cast(attrs, [:error, :failed_at])
|> validate_length(:error, max: 255)

# OU truncar antes
error_str = reason |> inspect() |> String.slice(0, 240)

E o problema inteiro teria virado um {:error, changeset} que o caller trataria normalmente — sem exit, sem cascade, sem restart.

A regra de ouro de processos supervisionados em Elixir é uma só:

💎 Exceptions são para o inesperado. Erros conhecidos devem ser valores.

Tudo que você consegue antever — limites de tamanho de campos, formatos inválidos, indisponibilidade de APIs externas, timeouts — precisa virar {:error, reason} e ser tratado no fluxo normal. Exceptions e Process.exit/2 são reservados para o que você de fato não esperava.

Isso conecta com o “Let it crash” do artigo anterior: a filosofia funciona porque você está tratando os erros conhecidos. Quando uma exception escapa, ela representa um estado realmente inesperado, e aí sim faz sentido deixar o supervisor restartar e tentar de novo. Se você deixa erros conhecidos virarem exceptions, o restart vira um retry sem ganho — o próximo request vai bater no mesmo bug, gerar a mesma exception, e a única coisa que muda é o contador de max_restarts subindo.


Defesa em camadas — o checklist

Toda vez que você for desenhar (ou revisar) um GenServer que faz trabalho externo, passe por essa checklist:

Camada 1 — A operação em si

  • Erros previsíveis (validações de schema, respostas de API, timeouts esperados) viram {:error, reason}, não exception?
  • Inputs externos que vão para colunas de banco são truncados ou validados antes do Repo.update/insert?
  • Chamadas para APIs externas têm timeout explícito e tratamento de {:error, ...} retornado?

Camada 2 — Tasks e spawns

  • Toda chamada de Task.async_stream/3 foi avaliada: ela deveria mesmo derrubar o caller em caso de falha?
  • Workers que rodam em loop usam Task.Supervisor.async_stream_nolink/6 com on_timeout: :kill_task?
  • O resultado do stream é consumido com pattern matching de {:ok, _} e {:exit, _}?

Camada 3 — O próprio GenServer

  • Existe handle_info(msg, state) catch-all como defesa contra mensagens desconhecidas?
  • Se ativa trap_exit, tem clauses para {:EXIT, _, :normal}, {:EXIT, _, :shutdown} E {:EXIT, _, reason}?
  • terminate/2 está implementado e não levanta exception (ele roda no caminho de morte e qualquer erro lá vira um log feio)?

Camada 4 — O supervisor

  • A restart policy do filho está correta (:permanent, :transient, :temporary)?
  • O max_restarts está em um valor que faz sentido para a taxa esperada de falha (e não está mascarando um bug)?
  • Os filhos estão agrupados em subárvores de supervisão pensando em blast radius — um worker barulhento não pode levar junto seu cache, seu pool de conexões e seu Phoenix?

Camada 5 — Observabilidade

  • Existe alerta para “taxa de exception por minuto”?
  • Existe alerta para “restart count por pod”?
  • O painel de métricas distingue restart in-place (BEAM saiu) de pod replacement (rolling update)?

Resumo: o “Let it crash” maduro

O “Let it crash” não é “deixe quebrar e o supervisor resolve”. É um contrato com várias partes:

  1. Você tratou os erros conhecidos como valores no fluxo normal.
  2. Você isolou as fontes de falha imprevisível (Tasks, APIs externas, processos auxiliares) com nolink ou subárvores supervisadas.
  3. Você defendeu seus GenServers críticos com handle_info catch-all e clauses completas para {:EXIT, _, _} quando relevante.
  4. Você configurou o supervisor para um restart policy que combina com a natureza do trabalho.
  5. Você tem observabilidade para perceber quando algum desses pressupostos quebrou.

Quando uma das camadas falha, é só uma camada. O blast radius fica contido. Quando todas falham — caso clássico de incidente em produção — você vê a versão dramática: um truncamento de string derrubando o BEAM inteiro através de cinco hops de propagação de exit.

A boa notícia é que cada camada custa pouco quando você sabe que ela existe. A má notícia é que descobrir que faltava uma delas geralmente custa um post-mortem.


Referências