defmodule Mercury.APIError do @moduledoc """ The base exception for Mercury HTTP API errors that don't map to a more specific exception (e.g. HTTP 403 Forbidden, or any unrecognised status). """ @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer() | nil, request_id: String.t() | nil, body: map() | nil } defexception [:message, :code, :status, :request_id, :body] @impl true def message(%{message: message, status: status, request_id: nil}) do "mercury: #{message} (status=#{inspect(status)})" end def message(%{message: message, status: status, request_id: request_id}) do "mercury: #{message} (status=#{inspect(status)}, request_id=#{request_id})" end end defmodule Mercury.AuthenticationError do @moduledoc "Raised for HTTP 401 Unauthorized responses — the API key is missing or invalid." @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer(), request_id: String.t() | nil, body: map() | nil } defexception [:message, :code, :status, :request_id, :body] @impl true def message(%{message: message}), do: "mercury: " <> message end defmodule Mercury.NotFoundError do @moduledoc "Raised for HTTP 404 Not Found responses." @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer(), request_id: String.t() | nil, body: map() | nil, resource: String.t() | nil } defexception [:message, :code, :status, :request_id, :body, :resource] @impl true def message(%{resource: resource}) when is_binary(resource) do "mercury: not found: #{resource}" end def message(%{message: message}), do: "mercury: not found: #{message}" end defmodule Mercury.ValidationError do @moduledoc "Raised for HTTP 400 Bad Request responses." @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer(), request_id: String.t() | nil, body: map() | nil } defexception [:message, :code, :status, :request_id, :body] @impl true def message(%{message: message}), do: "mercury: validation error: #{message}" end defmodule Mercury.ConflictError do @moduledoc """ Raised for HTTP 409 Conflict responses (e.g. a reused `idempotencyKey`). """ @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer(), request_id: String.t() | nil, body: map() | nil } defexception [:message, :code, :status, :request_id, :body] @impl true def message(%{message: message}), do: "mercury: conflict: #{message}" end defmodule Mercury.RateLimitError do @moduledoc "Raised for HTTP 429 Too Many Requests responses." @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer(), request_id: String.t() | nil, body: map() | nil, retry_after: non_neg_integer() | nil } defexception [:message, :code, :status, :request_id, :body, :retry_after] @impl true def message(%{retry_after: retry_after}) when is_integer(retry_after) and retry_after > 0 do "mercury: rate limited — retry after #{retry_after}s" end def message(_error), do: "mercury: rate limited" end defmodule Mercury.ServerError do @moduledoc "Raised for HTTP 5xx Server Error responses. Automatically retried." @type t :: %__MODULE__{ message: String.t(), code: atom(), status: pos_integer(), request_id: String.t() | nil, body: map() | nil } defexception [:message, :code, :status, :request_id, :body] @impl true def message(%{status: status, message: message}) do "mercury: server error #{status}: #{message}" end end defmodule Mercury.NetworkError do @moduledoc "Wraps a low-level transport failure (connection refused, DNS failure, etc)." @type t :: %__MODULE__{message: String.t(), reason: term()} defexception [:message, :reason] @impl true def message(%{message: message}), do: "mercury: network error: #{message}" end defmodule Mercury.TimeoutError do @moduledoc "Raised when a request exceeds the client's configured timeout." @type t :: %__MODULE__{timeout_ms: pos_integer()} defexception [:timeout_ms] @impl true def message(%{timeout_ms: timeout_ms}), do: "mercury: request timed out after #{timeout_ms}ms" end defmodule Mercury.Error do @moduledoc """ Classification helpers for Mercury exceptions — mirrors `errors.As` style checks from the reference Go SDK, adapted to pattern matching. ## Examples case Mercury.Accounts.get(client, id) do {:ok, account} -> account {:error, error} -> if Mercury.Error.rate_limit_error?(error) do :timer.sleep(1_000) Mercury.Accounts.get(client, id) else {:error, error} end end """ @doc "Returns `true` if `error` is a `Mercury.AuthenticationError`." @spec auth_error?(error :: term()) :: boolean() def auth_error?(%Mercury.AuthenticationError{}), do: true def auth_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.NotFoundError`." @spec not_found_error?(error :: term()) :: boolean() def not_found_error?(%Mercury.NotFoundError{}), do: true def not_found_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.ValidationError`." @spec validation_error?(error :: term()) :: boolean() def validation_error?(%Mercury.ValidationError{}), do: true def validation_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.ConflictError`." @spec conflict_error?(error :: term()) :: boolean() def conflict_error?(%Mercury.ConflictError{}), do: true def conflict_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.RateLimitError`." @spec rate_limit_error?(error :: term()) :: boolean() def rate_limit_error?(%Mercury.RateLimitError{}), do: true def rate_limit_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.ServerError`." @spec server_error?(error :: term()) :: boolean() def server_error?(%Mercury.ServerError{}), do: true def server_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.NetworkError`." @spec network_error?(error :: term()) :: boolean() def network_error?(%Mercury.NetworkError{}), do: true def network_error?(_error), do: false @doc "Returns `true` if `error` is a `Mercury.TimeoutError`." @spec timeout_error?(error :: term()) :: boolean() def timeout_error?(%Mercury.TimeoutError{}), do: true def timeout_error?(_error), do: false @doc false @spec classify( status :: pos_integer(), message :: String.t(), request_id :: String.t() | nil, resource :: String.t(), body :: map() | nil ) :: Exception.t() def classify(status, message, request_id, resource, body) do base = %{message: message, status: status, request_id: request_id, body: body} case status do 401 -> struct(Mercury.AuthenticationError, Map.put(base, :code, :unauthorized)) 403 -> struct(Mercury.APIError, Map.put(base, :code, :forbidden)) 404 -> struct(Mercury.NotFoundError, Map.merge(base, %{code: :not_found, resource: resource})) 400 -> struct(Mercury.ValidationError, Map.put(base, :code, :bad_request)) 409 -> struct(Mercury.ConflictError, Map.put(base, :code, :conflict)) status when status >= 500 -> struct(Mercury.ServerError, Map.put(base, :code, :server_error)) _other -> struct(Mercury.APIError, Map.put(base, :code, :unknown)) end end end