Current section
Files
Jump to
Current section
Files
lib/codat/error.ex
defmodule Codat.Error do
@moduledoc """
Structured error types returned by all Codat API functions.
All API functions return `{:ok, result}` or `{:error, %Codat.Error{}}`.
## Error Types
| Type | HTTP Status | Description |
|------------------------|-------------|---------------------------------------------|
| `:unauthorized` | 401 | Invalid or missing API key |
| `:payment_required` | 402 | Free tier limit exceeded |
| `:forbidden` | 403 | Insufficient permissions |
| `:not_found` | 404 | Resource or data type not found |
| `:conflict` | 409 | Data not yet ready or resource conflict |
| `:rate_limited` | 429 | Too many requests |
| `:bad_request` | 400 | Invalid request body or parameters |
| `:server_error` | 5xx | Codat internal server error |
| `:service_unavailable` | 503 | Service temporarily unavailable |
| `:network_error` | N/A | TCP/TLS/connection failure |
| `:timeout` | N/A | Request timed out |
| `:json_decode_error` | N/A | Unexpected response body |
"""
@type error_type ::
:unauthorized
| :payment_required
| :forbidden
| :not_found
| :conflict
| :rate_limited
| :bad_request
| :unprocessable
| :server_error
| :service_unavailable
| :network_error
| :timeout
| :json_decode_error
| :unknown
@type t :: %__MODULE__{
type: error_type(),
message: String.t() | nil,
status_code: non_neg_integer() | nil,
correlation_id: String.t() | nil,
retry_after: non_neg_integer() | nil,
detail: map() | nil,
raw_body: String.t() | nil
}
@enforce_keys [:type]
defstruct [
:type,
:message,
:status_code,
:correlation_id,
:retry_after,
:detail,
:raw_body
]
@doc "Builds a `%Codat.Error{}` from an HTTP response map."
@spec from_response(map()) :: t()
def from_response(%{status: status, body: body, headers: headers}) do
%__MODULE__{
type: type_from_status(status),
status_code: status,
message: extract_message(body),
correlation_id: extract_correlation_id(body),
retry_after: extract_retry_after(headers),
detail: if(is_map(body), do: body, else: nil),
raw_body: if(is_binary(body), do: body, else: nil)
}
end
@doc "Builds a `%Codat.Error{}` from a network/transport-level error."
@spec from_exception(Exception.t()) :: t()
def from_exception(%{__exception__: true} = exc) do
{type, msg} = classify_exception(exc)
%__MODULE__{type: type, message: msg}
end
@doc "Returns true if the error is considered retryable (transient)."
@spec retryable?(t()) :: boolean()
def retryable?(%__MODULE__{type: type}) do
type in [:rate_limited, :server_error, :service_unavailable, :timeout, :network_error]
end
@doc "Human-readable string representation of the error."
@spec message(t()) :: String.t()
def message(%__MODULE__{} = error) do
[
"[#{error.type}]",
status_fragment(error.status_code),
error.message,
correlation_fragment(error.correlation_id)
]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
defp classify_exception(%{reason: :timeout}), do: {:timeout, "Request timed out"}
defp classify_exception(%{reason: :econnrefused}), do: {:network_error, "Connection refused"}
defp classify_exception(%{reason: :nxdomain}), do: {:network_error, "DNS resolution failed"}
defp classify_exception(exc), do: {:network_error, Exception.message(exc)}
defp type_from_status(400), do: :bad_request
defp type_from_status(401), do: :unauthorized
defp type_from_status(402), do: :payment_required
defp type_from_status(403), do: :forbidden
defp type_from_status(404), do: :not_found
defp type_from_status(409), do: :conflict
defp type_from_status(422), do: :unprocessable
defp type_from_status(429), do: :rate_limited
defp type_from_status(503), do: :service_unavailable
defp type_from_status(s) when s >= 500, do: :server_error
defp type_from_status(_status), do: :unknown
defp extract_correlation_id(%{"correlationId" => id}), do: id
defp extract_correlation_id(%{"correlation_id" => id}), do: id
defp extract_correlation_id(_map), do: nil
defp extract_message(%{"message" => m}) when is_binary(m), do: m
defp extract_message(%{"error" => m}) when is_binary(m), do: m
defp extract_message(%{"statusMessage" => m}) when is_binary(m), do: m
defp extract_message(_map), do: nil
defp extract_retry_after(headers) when is_list(headers) do
case List.keyfind(headers, "retry-after", 0) do
{_key, value} -> parse_retry_after(value)
nil -> nil
end
end
defp extract_retry_after(_headers), do: nil
defp parse_retry_after(value) do
case Integer.parse(value) do
{seconds, _rest} -> seconds * 1_000
:error -> nil
end
end
defp status_fragment(nil), do: nil
defp status_fragment(code), do: "HTTP #{code}"
defp correlation_fragment(nil), do: nil
defp correlation_fragment(id), do: "(correlation_id: #{id})"
defimpl String.Chars do
def to_string(error), do: Codat.Error.message(error)
end
end