defmodule ApiBrasil.Core.Error do @moduledoc """ Erro devolvido por todas as chamadas da SDK. Toda função devolve `{:ok, resultado}` ou `{:error, %ApiBrasil.Core.Error{}}`, e a falha carrega a categoria em `:kind` — o equivalente Elixir às subclasses de erro das demais SDKs da plataforma. case ApiBrasil.Data.Consulta.cpf(client, %{"cpf" => "00000000000"}) do {:ok, consulta} -> ApiBrasil.Core.CreditResponse.data(consulta) {:error, %ApiBrasil.Core.Error{kind: :insufficient_balance}} -> IO.puts("Recarregue seus créditos") {:error, %ApiBrasil.Core.Error{kind: :rate_limit} = error} -> IO.puts("Aguarde \#{error.retry_after}ms") {:error, error} -> IO.puts(Exception.message(error)) end As variantes `!` (ex: `cpf!/3`) levantam este erro em vez de devolver a tupla. """ @typedoc """ Categoria da falha. - `:api` — falha genérica da API; - `:network` — falha de rede, a requisição pode não ter chegado ao servidor; - `:timeout` — tempo limite excedido (nunca refeito automaticamente); - `:validation` — HTTP 400/422, payload inválido; - `:authentication` — HTTP 401, Bearer Token ausente, inválido ou expirado; - `:insufficient_balance` — HTTP 402, saldo/créditos insuficientes; - `:permission` — HTTP 403, sem permissão (ex: API exige conta PJ); - `:not_found` — HTTP 404/410, recurso não encontrado ou desativado; - `:rate_limit` — HTTP 429, limite de requisições atingido; - `:server` — HTTP 5xx, erro interno do gateway/provedor. """ @type kind :: :api | :network | :timeout | :validation | :authentication | :insufficient_balance | :permission | :not_found | :rate_limit | :server @type t :: %__MODULE__{ kind: kind(), message: String.t(), status: pos_integer() | nil, code: String.t() | nil, response: term(), retry_after: non_neg_integer() | nil, reason: term() } defexception kind: :api, message: "Falha na chamada à API.", status: nil, code: nil, response: nil, retry_after: nil, reason: nil @kinds [ :api, :network, :timeout, :validation, :authentication, :insufficient_balance, :permission, :not_found, :rate_limit, :server ] @doc "Lista todas as categorias de erro." @spec kinds() :: [kind()] def kinds, do: @kinds @impl true def message(%__MODULE__{} = error) do [ error.message, error.status && " (HTTP #{error.status})", error.code && " [#{error.code}]" ] |> Enum.reject(&(&1 in [nil, false])) |> IO.iodata_to_binary() end @doc "Cria um erro da categoria informada." @spec new(kind(), String.t(), keyword()) :: t() def new(kind, message, fields \\ []) when kind in @kinds do struct!(%__MODULE__{kind: kind, message: message}, fields) end @doc "Falha de rede — nenhuma resposta recebida." @spec network(String.t(), keyword()) :: t() def network(message, fields \\ []), do: new(:network, message, fields) @doc "Falha por tempo limite excedido." @spec timeout(String.t(), keyword()) :: t() def timeout(message, fields \\ []), do: new(:timeout, message, fields) @doc "Falha de validação (payload inválido)." @spec validation(String.t(), keyword()) :: t() def validation(message, fields \\ []), do: new(:validation, message, fields) @doc "Falha de autenticação." @spec authentication(String.t(), keyword()) :: t() def authentication(message, fields \\ []), do: new(:authentication, message, fields) @doc "Falha antes da resposta — rede ou tempo limite." @spec network?(t()) :: boolean() def network?(%__MODULE__{kind: kind}), do: kind in [:network, :timeout] @doc "Tempo limite excedido." @spec timeout?(t()) :: boolean() def timeout?(%__MODULE__{kind: kind}), do: kind == :timeout @doc "HTTP 400/422 — payload inválido." @spec validation?(t()) :: boolean() def validation?(%__MODULE__{kind: kind}), do: kind == :validation @doc "HTTP 401 — token ausente, inválido ou expirado." @spec authentication?(t()) :: boolean() def authentication?(%__MODULE__{kind: kind}), do: kind == :authentication @doc "HTTP 402 — saldo/créditos insuficientes." @spec insufficient_balance?(t()) :: boolean() def insufficient_balance?(%__MODULE__{kind: kind}), do: kind == :insufficient_balance @doc "HTTP 403 — sem permissão." @spec permission?(t()) :: boolean() def permission?(%__MODULE__{kind: kind}), do: kind == :permission @doc "HTTP 404/410 — não encontrado ou desativado." @spec not_found?(t()) :: boolean() def not_found?(%__MODULE__{kind: kind}), do: kind == :not_found @doc "HTTP 429 — rate limit atingido." @spec rate_limit?(t()) :: boolean() def rate_limit?(%__MODULE__{kind: kind}), do: kind == :rate_limit @doc "HTTP 5xx — erro interno do gateway/provedor." @spec server?(t()) :: boolean() def server?(%__MODULE__{kind: kind}), do: kind == :server @doc "Falha genérica da API." @spec api?(t()) :: boolean() def api?(%__MODULE__{kind: kind}), do: kind == :api @doc """ Mapeia um status HTTP + corpo de erro para o erro adequado, replicando a hierarquia de erros das demais SDKs. """ @spec from_api(pos_integer(), term(), map()) :: t() def from_api(status, body, headers \\ %{}) do kind = kind_for_status(status) retry_after = if kind == :rate_limit do parse_retry_after(headers) end %__MODULE__{ kind: kind, message: extract_message(status, body), status: status, code: extract_code(body), response: body, retry_after: retry_after } end @doc "Categoria correspondente a um status HTTP." @spec kind_for_status(pos_integer()) :: kind() def kind_for_status(status) do cond do status in [400, 422] -> :validation status == 401 -> :authentication status == 402 -> :insufficient_balance status == 403 -> :permission status in [404, 410] -> :not_found status == 429 -> :rate_limit status >= 500 -> :server true -> :api end end @doc """ Lê o header `Retry-After` — segundos ou data HTTP (IMF-fixdate) — e devolve a espera em milissegundos. """ @spec parse_retry_after(map(), integer() | nil) :: non_neg_integer() | nil def parse_retry_after(headers, now \\ nil) do with raw when is_binary(raw) <- header(headers, "retry-after"), raw = String.trim(raw), false <- raw == "" do parse_retry_after_value(raw, now || System.system_time(:second)) else _ -> nil end end @doc "Lê um header sem diferenciar maiúsculas de minúsculas." @spec header(map(), String.t()) :: String.t() | nil def header(headers, name) when is_map(headers) do wanted = String.downcase(name) Enum.find_value(headers, fn {key, value} -> String.downcase(to_string(key)) == wanted && value end) end def header(_headers, _name), do: nil defp parse_retry_after_value(raw, now) do case Float.parse(raw) do {seconds, ""} when seconds > 0 -> round(seconds * 1000) {_seconds, ""} -> nil _ -> parse_http_date(raw, now) end end defp parse_http_date(raw, now) do case :httpd_util.convert_request_date(String.to_charlist(raw)) do :bad_date -> nil datetime -> # 62_167_219_200 = segundos entre o ano 0 e a época Unix. at = :calendar.datetime_to_gregorian_seconds(datetime) - 62_167_219_200 case at - now do delta when delta > 0 -> delta * 1000 _ -> nil end end end defp extract_message(status, body) when is_map(body) do Enum.find_value(["message", "error"], fn key -> case Map.get(body, key) do text when is_binary(text) and text != "" -> text _ -> nil end end) || default_message(status) end defp extract_message(status, _body), do: default_message(status) defp default_message(status), do: "A API respondeu com HTTP #{status}." defp extract_code(body) when is_map(body) do case Map.get(body, "code") do code when is_binary(code) -> code _ -> nil end end defp extract_code(_body), do: nil end