Skip to main content

lib/coffrify/error.ex

defmodule Coffrify.Error.Transport do
  @moduledoc """
  Network-level failure (timeout, TCP reset, TLS error, DNS).

  These never reach the Coffrify servers; the SDK retries them by default.
  """
  defexception [:status, :code, :message, :request_id, :details]

  @type t :: %__MODULE__{
          status: 0,
          code: String.t() | nil,
          message: String.t(),
          request_id: String.t() | nil,
          details: term()
        }

  @impl true
  def message(%{message: m}), do: "Transport error: #{m}"
end

defmodule Coffrify.Error.RateLimited do
  @moduledoc """
  HTTP 429 — workspace exceeded its API rate limit.

  Inspect `retry_after_ms` to know how long to wait before retrying.
  """
  defexception [:status, :code, :message, :request_id, :details, :retry_after_ms]

  @impl true
  def message(%{message: m, status: s, retry_after_ms: r}) do
    "RateLimited (HTTP #{s}, retry in #{r}ms): #{m}"
  end
end

defmodule Coffrify.Error do
  @moduledoc """
  Errors raised by the Coffrify SDK.

  `Coffrify.Error` is the base struct. Specialised sub-errors give the caller
  a fine-grained pattern-match without losing the original response details.

  ## Examples

      try do
        Coffrify.Resources.Transfers.get(client, "tf_404")
      rescue
        e in Coffrify.Error.NotFound -> Logger.warn("missing", id: e.details)
        e in Coffrify.Error.RateLimited -> :timer.sleep(e.retry_after_ms)
      end
  """

  @type t :: %__MODULE__{
          status: non_neg_integer(),
          code: String.t() | nil,
          message: String.t(),
          request_id: String.t() | nil,
          details: term()
        }

  defexception [
    :status,
    :code,
    :message,
    :request_id,
    :details
  ]

  @impl true
  def message(%__MODULE__{message: msg, status: status}) when is_binary(msg) do
    "Coffrify API error (HTTP #{status}): #{msg}"
  end

  def message(%__MODULE__{} = err), do: "Coffrify API error (HTTP #{err.status})"

  @doc """
  Build the most specific `Coffrify.Error.*` struct for a given HTTP status.

  Falls back to the generic `Coffrify.Error` when no specialised exception
  exists for the status code.
  """
  @spec from_response(integer(), term(), keyword()) :: Exception.t()
  def from_response(status, body, opts \\ [])

  def from_response(status, body, opts) when is_map(body) do
    %{
      message: extract_message(body) || "HTTP #{status}",
      code: extract_code(body),
      details: body,
      request_id: opts[:request_id],
      status: status
    }
    |> build(status)
  end

  def from_response(status, body, opts) do
    %{
      message: "HTTP #{status}",
      code: nil,
      details: body,
      request_id: opts[:request_id],
      status: status
    }
    |> build(status)
  end

  @doc """
  Build a `Coffrify.Error` representing a transport-level failure
  (timeout, DNS, TLS, ...).
  """
  @spec from_transport(term(), keyword()) :: Coffrify.Error.Transport.t()
  def from_transport(reason, opts \\ []) do
    %Coffrify.Error.Transport{
      status: 0,
      code: "transport_error",
      message: format_transport(reason),
      details: reason,
      request_id: opts[:request_id]
    }
  end

  defp build(attrs, 400), do: struct(Coffrify.Error.BadRequest, attrs)
  defp build(attrs, 401), do: struct(Coffrify.Error.Unauthorized, attrs)
  defp build(attrs, 403), do: struct(Coffrify.Error.Forbidden, attrs)
  defp build(attrs, 404), do: struct(Coffrify.Error.NotFound, attrs)
  defp build(attrs, 409), do: struct(Coffrify.Error.Conflict, attrs)
  defp build(attrs, 422), do: struct(Coffrify.Error.UnprocessableEntity, attrs)
  defp build(attrs, 429), do: struct(Coffrify.Error.RateLimited, Map.put(attrs, :retry_after_ms, attrs[:retry_after_ms] || 0))
  defp build(attrs, status) when status in 500..599, do: struct(Coffrify.Error.Server, attrs)
  defp build(attrs, _other), do: struct(__MODULE__, attrs)

  defp extract_message(%{"error" => %{"message" => m}}) when is_binary(m), do: m
  defp extract_message(%{"error" => m}) when is_binary(m), do: m
  defp extract_message(%{"message" => m}) when is_binary(m), do: m
  defp extract_message(_), do: nil

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

  defp format_transport({:timeout, _}), do: "request timed out"
  defp format_transport(:timeout), do: "request timed out"
  defp format_transport({:closed, _}), do: "connection closed by remote"
  defp format_transport({reason, _}) when is_atom(reason), do: Atom.to_string(reason)
  defp format_transport(other) when is_atom(other), do: Atom.to_string(other)
  defp format_transport(other), do: inspect(other)
end

defmodule Coffrify.Error.BadRequest do
  @moduledoc "HTTP 400 — request was malformed."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "BadRequest (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.Unauthorized do
  @moduledoc "HTTP 401 — API key missing, malformed, or revoked."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "Unauthorized (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.Forbidden do
  @moduledoc "HTTP 403 — API key valid but lacks the required scope/plan."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "Forbidden (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.NotFound do
  @moduledoc "HTTP 404 — resource does not exist or is not visible to your key."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "NotFound (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.Conflict do
  @moduledoc "HTTP 409 — current state forbids the action (e.g. duplicate key)."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "Conflict (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.UnprocessableEntity do
  @moduledoc "HTTP 422 — request was well-formed but semantically invalid."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "UnprocessableEntity (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.Server do
  @moduledoc "HTTP 5xx — upstream Coffrify server error. Safe to retry."
  defexception [:status, :code, :message, :request_id, :details]

  @impl true
  def message(%{message: m, status: s}), do: "ServerError (HTTP #{s}): #{m}"
end

defmodule Coffrify.Error.CircuitOpen do
  @moduledoc "Raised when the local circuit breaker is open and rejects the request."
  defexception [:retry_at_ms, :message]

  @impl true
  def message(%{retry_at_ms: r}) do
    "Circuit breaker is OPEN — retry at #{DateTime.from_unix!(div(r, 1000))}"
  end
end

defmodule Coffrify.Error.InvalidApiKey do
  @moduledoc "Raised at client construction when the API key has an unknown prefix."
  defexception [:message]

  @impl true
  def message(%{message: m}), do: m
end