Skip to main content

lib/terminus_db/error.ex

defmodule TerminusDB.Error do
  @moduledoc """
  A structured error returned by all `terminusdb_ex` operations.

  Public API functions return `{:error, %TerminusDB.Error{}}` on failure; the
  `!/1`-suffixed variants raise this same struct (it implements the `Exception`
  behaviour). The `:reason` field classifies the failure so callers can pattern match
  without inspecting HTTP status:

  | `:reason`   | Meaning                                                |
  | ----------- | ----------------------------------------------------- |
  | `:transport`| Network/transport failure (connection, timeout, …)    |
  | `:http`     | Non-2xx response with a non-JSON or unstructured body |
  | `:api`      | TerminusDB API error (structured `api:*` JSON body)  |
  | `:decode`   | Response body could not be decoded as JSON           |
  | `:config`   | Missing or invalid client configuration               |

  ## Examples

      iex> error = TerminusDB.Error.api(400, %{
      ...>   "@type" => "api:DbCreateErrorResponse",
      ...>   "api:error" => %{"@type" => "api:DatabaseAlreadyExists"},
      ...>   "api:message" => "Database already exists.",
      ...>   "api:status" => "api:failure"
      ...> })
      iex> error.reason
      :api
      iex> error.api_type
      "api:DatabaseAlreadyExists"
      iex> Exception.message(error)
      "TerminusDB API error 400 (api:DatabaseAlreadyExists): Database already exists."

  """

  @type reason :: :transport | :http | :api | :decode | :config

  @type t :: %__MODULE__{
          reason: reason(),
          status: pos_integer() | nil,
          message: String.t(),
          api_error: map() | nil,
          api_type: String.t() | nil,
          body: term(),
          cause: Exception.t() | nil
        }

  @enforce_keys [:reason]
  defexception [:reason, :status, :message, :api_error, :api_type, :body, :cause]

  # Public constructors -------------------------------------------------------

  @doc """
  Builds a `:transport` error from an underlying exception (e.g. `Req.TransportError`).

  ## Examples

      iex> error = TerminusDB.Error.transport(Req.TransportError.exception(reason: :econnrefused))
      iex> error.reason
      :transport

  """
  @spec transport(Exception.t()) :: t()
  def transport(%{__exception__: true} = exception) do
    %__MODULE__{
      reason: :transport,
      message: "transport error: #{Exception.message(exception)}",
      cause: exception
    }
  end

  @doc """
  Builds an `:http` error from a non-2xx status with an unstructured body.

  ## Examples

      iex> error = TerminusDB.Error.http(503, "service unavailable")
      iex> error.reason
      :http

  """
  @spec http(pos_integer(), term()) :: t()
  def http(status, body) when is_integer(status) do
    %__MODULE__{reason: :http, status: status, body: body, message: "HTTP error #{status}"}
  end

  @doc """
  Builds an `:api` error from a TerminusDB structured error body.

  TerminusDB returns JSON of the shape
  `{"@type": "api:*ErrorResponse", "api:error": {...}, "api:message": "...", "api:status": "api:failure"}`.
  The `@type` inside `api:error` (if present) is surfaced as `api_type` for easy
  pattern matching, e.g. `"api:DatabaseAlreadyExists"`.
  """
  @spec api(pos_integer(), map()) :: t()
  def api(status, %{} = body) when is_integer(status) do
    api_error = body["api:error"]
    api_type = (is_map(api_error) && api_error["@type"]) || body["@type"]
    message = body["api:message"] || "API error #{status}"

    %__MODULE__{
      reason: :api,
      status: status,
      api_error: api_error,
      api_type: api_type,
      body: body,
      message: "TerminusDB API error #{status}#{format_api_type(api_type)}: #{message}"
    }
  end

  @doc """
  Builds a `:decode` error when the response body cannot be parsed as JSON.
  """
  @spec decode(Exception.t(), binary() | nil) :: t()
  def decode(%{__exception__: true} = exception, body) do
    %__MODULE__{
      reason: :decode,
      message: "decode error: #{Exception.message(exception)}",
      cause: exception,
      body: body
    }
  end

  # Exception callback --------------------------------------------------------

  @impl true
  def exception(opts) when is_list(opts) do
    reason = Keyword.fetch!(opts, :reason)

    %__MODULE__{
      reason: reason,
      status: opts[:status],
      message: opts[:message] || default_message(reason, opts),
      api_error: opts[:api_error],
      api_type: opts[:api_type],
      body: opts[:body],
      cause: opts[:cause]
    }
  end

  @impl true
  @spec message(t()) :: String.t()
  def message(%__MODULE__{reason: :api} = error), do: error.message

  def message(%__MODULE__{reason: :transport, cause: cause} = _e) when cause != nil,
    do: "transport error: #{Exception.message(cause)}"

  def message(%__MODULE__{reason: :decode, cause: cause} = _e) when cause != nil,
    do: "decode error: #{Exception.message(cause)}"

  def message(%__MODULE__{reason: :http, status: status} = _e) when status != nil,
    do: "HTTP error #{status}"

  def message(%__MODULE__{message: message}), do: message

  defp default_message(:api, opts), do: "TerminusDB API error#{format_api_type(opts[:api_type])}"
  defp default_message(:http, opts), do: "HTTP error #{opts[:status]}"
  defp default_message(:transport, _opts), do: "transport error"
  defp default_message(:decode, _opts), do: "decode error"
  defp default_message(_, _opts), do: "unknown error"

  defp format_api_type(nil), do: ""
  defp format_api_type(type), do: " (#{type})"
end