Skip to main content

lib/money_hub/error.ex

defmodule MoneyHub.Error do
  @moduledoc """
  A structured error returned by `MoneyHub` functions.

  Every public function in this library that can fail returns
  `{:error, %MoneyHub.Error{}}` (or raises a `MoneyHub.Error` from the `!`
  variants) rather than ad-hoc tuples, so callers can pattern match on
  `reason` regardless of where in the stack the failure occurred.

  ## Reasons

    * `:config_error` - the `MoneyHub.Config` passed in was invalid.
    * `:network_error` - the HTTP transport failed (DNS, TLS, connect
      timeout, connection reset, etc). `cause` holds the underlying
      exception or `Mint`/`Finch` error term.
    * `:api_error` - the API responded with a non-2xx status. `status` and
      `body` are populated; `code` is the Moneyhub-specific error code
      from the response body when present (e.g. `"INVALID_REQUEST"`).
    * `:rate_limited` - the API responded `429`. `retry_after` (integer
      seconds) is populated when the `Retry-After` header was present.
    * `:decode_error` - the response body could not be parsed as JSON or
      did not match the expected shape.
    * `:jwt_error` - signing or verifying a JWT (client assertion, id_token,
      webhook payload) failed. `cause` holds the underlying reason.
    * `:validation_error` - a function argument failed local validation
      before any request was made (for example an invalid claims map).

  """

  @type reason ::
          :config_error
          | :network_error
          | :api_error
          | :rate_limited
          | :decode_error
          | :jwt_error
          | :validation_error

  @type t :: %__MODULE__{
          reason: reason(),
          message: String.t(),
          status: pos_integer() | nil,
          code: String.t() | nil,
          body: term(),
          retry_after: non_neg_integer() | nil,
          cause: term()
        }

  defexception reason: :api_error,
               message: "",
               status: nil,
               code: nil,
               body: nil,
               retry_after: nil,
               cause: nil

  @impl Exception
  def message(%__MODULE__{message: message}), do: message

  @doc false
  @spec config_error(String.t()) :: t()
  def config_error(message), do: %__MODULE__{reason: :config_error, message: message}

  @doc false
  @spec validation_error(String.t()) :: t()
  def validation_error(message), do: %__MODULE__{reason: :validation_error, message: message}

  @doc false
  @spec network_error(term()) :: t()
  def network_error(cause) do
    %__MODULE__{
      reason: :network_error,
      message: "network error: #{inspect(cause)}",
      cause: cause
    }
  end

  @doc false
  @spec decode_error(String.t(), term()) :: t()
  def decode_error(message, cause \\ nil) do
    %__MODULE__{reason: :decode_error, message: message, cause: cause}
  end

  @doc false
  @spec jwt_error(String.t(), term()) :: t()
  def jwt_error(message, cause \\ nil) do
    %__MODULE__{reason: :jwt_error, message: message, cause: cause}
  end

  @doc false
  @spec api_error(pos_integer(), term(), [{String.t(), String.t()}]) :: t()
  def api_error(status, body, headers \\ []) do
    code = extract_code(body)

    %__MODULE__{
      reason: api_reason(status),
      message: api_message(status, code, body),
      status: status,
      code: code,
      body: body,
      retry_after: if(status == 429, do: extract_retry_after(headers), else: nil)
    }
  end

  defp api_reason(429), do: :rate_limited
  defp api_reason(_), do: :api_error

  defp extract_code(%{"error" => code}) when is_binary(code), do: code
  defp extract_code(%{"code" => code}) when is_binary(code), do: code
  defp extract_code(%{"error_code" => code}) when is_binary(code), do: code
  defp extract_code(_), do: nil

  defp extract_retry_after(headers) do
    case List.keyfind(headers, "retry-after", 0) do
      {_, value} ->
        case Integer.parse(value) do
          {seconds, _} -> seconds
          :error -> nil
        end

      nil ->
        nil
    end
  end

  defp api_message(status, nil, _body), do: "Moneyhub API responded with status #{status}"

  defp api_message(status, code, _body),
    do: "Moneyhub API responded with status #{status} (#{code})"
end