Current section
Files
Jump to
Current section
Files
lib/rulestead/error.ex
defmodule Rulestead.Error do
@moduledoc """
Stable public error envelope for all non-bang and bang APIs.
`Rulestead` returns this struct in `{:error, error}` tuples and raises the same
struct from bang variants. Typed helper modules such as `Rulestead.StoreError`
construct this envelope instead of introducing competing public error structs.
"""
@enforce_keys [:domain, :type, :message]
defexception [
:domain,
:type,
:message,
metadata: %{},
details: [],
cause: nil,
plug_status: nil
]
@typedoc "Top-level error family used to group stable leaf error types."
@type domain :: :evaluation | :ruleset | :kill_switch | :config | :store | :auth
@typedoc """
Closed Phase 2 leaf error atoms.
Downstream phases should extend this list deliberately when they add new public
failure modes rather than returning broad atoms such as `:invalid` or `:not_found`.
"""
@type type ::
:flag_not_found
| :environment_not_found
| :snapshot_not_found
| :ruleset_not_found
| :missing_targeting_key
| :repo_not_configured
| :repo_ambiguous
| :store_not_configured
| :store_adapter_invalid
| :store_unavailable
| :invalid_command
| :invalid_ruleset
| :variant_weights_invalid
| :invalid_value_projection
| :malformed_runtime_data
| :flag_archived
| :unauthorized
| :kill_switch_active
| :not_implemented
@type metadata_scalar :: nil | boolean | integer | float | atom | String.t()
@type metadata_key :: atom | String.t()
@type metadata :: %{optional(metadata_key()) => metadata_scalar()}
@type detail_key :: atom | String.t()
@type detail_value :: nil | boolean | integer | float | atom | String.t()
@type detail :: %{optional(detail_key()) => detail_value()}
@type t :: %__MODULE__{
domain: domain(),
type: type(),
message: String.t(),
metadata: metadata(),
details: [detail()],
cause: term(),
plug_status: nil | pos_integer()
}
@domains [:evaluation, :ruleset, :kill_switch, :config, :store, :auth]
@leaf_types [
:flag_not_found,
:environment_not_found,
:snapshot_not_found,
:ruleset_not_found,
:missing_targeting_key,
:repo_not_configured,
:repo_ambiguous,
:store_not_configured,
:store_adapter_invalid,
:store_unavailable,
:invalid_command,
:invalid_ruleset,
:variant_weights_invalid,
:invalid_value_projection,
:malformed_runtime_data,
:flag_archived,
:unauthorized,
:kill_switch_active,
:not_implemented
]
@doc """
Returns the stable top-level error domains.
"""
@spec domains() :: [domain()]
def domains, do: @domains
@doc """
Returns the closed Phase 2 leaf error atoms.
"""
@spec leaf_types() :: [type()]
def leaf_types, do: @leaf_types
@doc """
Builds a new normalized error struct.
"""
@spec new(keyword() | map()) :: t()
def new(attrs) when is_list(attrs) or is_map(attrs) do
attrs = Map.new(attrs)
%__MODULE__{
domain: normalize_domain(Map.get(attrs, :domain)),
type: normalize_type(Map.get(attrs, :type)),
message: normalize_message(Map.get(attrs, :message), Map.get(attrs, :type)),
metadata: normalize_metadata(Map.get(attrs, :metadata, %{})),
details: normalize_details(Map.get(attrs, :details, [])),
cause: Map.get(attrs, :cause),
plug_status: Map.get(attrs, :plug_status)
}
end
@doc """
Normalizes a term into a `Rulestead.Error`.
"""
@spec normalize(t() | keyword() | map()) :: t()
def normalize(%__MODULE__{} = error), do: new(Map.from_struct(error))
def normalize(attrs) when is_list(attrs) or is_map(attrs), do: new(attrs)
@impl true
def exception(attrs), do: new(attrs)
@impl true
def message(%__MODULE__{message: message}) when is_binary(message), do: message
defp normalize_domain(domain) when domain in @domains, do: domain
defp normalize_domain(_domain), do: :config
defp normalize_type(type) when type in @leaf_types, do: type
defp normalize_type(_type), do: :invalid_command
defp normalize_message(message, _type) when is_binary(message) and byte_size(message) > 0,
do: message
defp normalize_message(_message, type) when is_atom(type), do: Atom.to_string(type)
defp normalize_message(_message, _type), do: "rulestead error"
defp normalize_metadata(metadata) when is_map(metadata) do
Enum.reduce(metadata, %{}, fn
{key, value}, acc when is_atom(key) or is_binary(key) ->
case normalize_metadata_value(value) do
{:ok, safe_value} -> Map.put(acc, key, safe_value)
:error -> acc
end
_, acc ->
acc
end)
end
defp normalize_metadata(_metadata), do: %{}
defp normalize_metadata_value(value)
when is_nil(value) or is_boolean(value) or is_integer(value) or is_float(value) or
is_atom(value) or is_binary(value) do
{:ok, value}
end
defp normalize_metadata_value(_value), do: :error
defp normalize_details(details) when is_list(details) do
Enum.flat_map(details, fn
detail when is_map(detail) -> [normalize_detail(detail)]
_other -> []
end)
end
defp normalize_details(_details), do: []
defp normalize_detail(detail) do
Enum.reduce(detail, %{}, fn
{key, value}, acc when is_atom(key) or is_binary(key) ->
if valid_detail_value?(value) do
Map.put(acc, key, value)
else
acc
end
_, acc ->
acc
end)
end
defp valid_detail_value?(value)
when is_nil(value) or is_boolean(value) or is_integer(value) or is_float(value) or
is_atom(value) or is_binary(value),
do: true
defp valid_detail_value?(_value), do: false
end
if Code.ensure_loaded?(Jason.Encoder) do
defimpl Jason.Encoder, for: Rulestead.Error do
def encode(error, opts) do
Jason.Encode.map(
%{
domain: error.domain,
type: error.type,
message: error.message,
metadata: error.metadata,
details: error.details,
plug_status: error.plug_status
},
opts
)
end
end
end