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