defmodule ApiBrasil.Core.Hooks do @moduledoc """ Ganchos de observabilidade — logging, métricas, tracing. Podem ser um **mapa/keyword de funções** ou um **módulo** que implementa este behaviour. ApiBrasil.new( hooks: %{ request: &IO.inspect(&1, label: "→"), response: fn info -> Logger.info("← \#{info.status} em \#{info.duration}ms") end, retry: fn info -> Logger.warning("retry em \#{info.delay}ms: \#{info.reason}") end } ) defmodule MinhaApp.ApiHooks do @behaviour ApiBrasil.Core.Hooks @impl true def on_response(info), do: :telemetry.execute([:apibrasil], %{duration: info.duration}, info) end ApiBrasil.new(hooks: MinhaApp.ApiHooks) """ @typedoc """ Dados da requisição: `:method`, `:url` (com query string), `:headers`, `:body` e `:attempt` (0 = primeira tentativa). """ @type request_info :: %{ method: ApiBrasil.Core.Transport.method(), url: String.t(), headers: map(), body: term(), attempt: non_neg_integer() } @typedoc """ Dados da resposta: `:method`, `:url`, `:status`, `:duration` (ms) e `:attempt`. """ @type response_info :: %{ method: ApiBrasil.Core.Transport.method(), url: String.t(), status: pos_integer(), duration: non_neg_integer(), attempt: non_neg_integer() } @typedoc """ Dados do retry: `:method`, `:url`, `:attempt` (a próxima), `:delay` (ms) e `:reason` (ex: `"HTTP 429"`). """ @type retry_info :: %{ method: ApiBrasil.Core.Transport.method(), url: String.t(), attempt: non_neg_integer(), delay: non_neg_integer(), reason: String.t() } @typedoc "Hooks aceitos pela configuração." @type t :: module() | map() | keyword() | nil @doc "Chamado antes de cada tentativa de requisição." @callback on_request(request_info()) :: any() @doc "Chamado quando a resposta chega, qualquer que seja o status." @callback on_response(response_info()) :: any() @doc "Chamado antes de aguardar o backoff de um retry." @callback on_retry(retry_info()) :: any() @optional_callbacks on_request: 1, on_response: 1, on_retry: 1 @events %{request: :on_request, response: :on_response, retry: :on_retry} @doc """ Dispara um evento (`:request`, `:response` ou `:retry`) nos hooks configurados. Falhas dentro de um hook nunca derrubam a requisição. """ @spec notify(t(), :request | :response | :retry, map()) :: :ok def notify(nil, _event, _info), do: :ok def notify(hooks, event, info) when is_map(hooks) and not is_struct(hooks) do run(Map.get(hooks, event), info) end def notify(hooks, event, info) when is_list(hooks) do run(Keyword.get(hooks, event), info) end def notify(module, event, info) when is_atom(module) do callback = Map.fetch!(@events, event) if Code.ensure_loaded?(module) and function_exported?(module, callback, 1) do safely(fn -> apply(module, callback, [info]) end) end :ok end defp run(nil, _info), do: :ok defp run(fun, info) when is_function(fun, 1), do: safely(fn -> fun.(info) end) defp run(_other, _info), do: :ok defp safely(fun) do fun.() :ok rescue _error -> :ok catch _kind, _reason -> :ok end end