Current section

Files

Jump to
trogon_error lib trogon error.ex
Raw

lib/trogon/error.ex

defmodule Trogon.Error do
alias Trogon.Error.Metadata
import Trogon.Error.Metadata, only: [is_empty_metadata: 1]
@codes [
:CANCELLED,
:UNKNOWN,
:INVALID_ARGUMENT,
:DEADLINE_EXCEEDED,
:NOT_FOUND,
:ALREADY_EXISTS,
:PERMISSION_DENIED,
:RESOURCE_EXHAUSTED,
:FAILED_PRECONDITION,
:ABORTED,
:OUT_OF_RANGE,
:UNIMPLEMENTED,
:INTERNAL,
:UNAVAILABLE,
:DATA_LOSS,
:UNAUTHENTICATED
]
@visibility_levels [:INTERNAL, :PRIVATE, :PUBLIC]
@options_schema NimbleOptions.new!(
domain: [
type: :string,
required: true,
doc: "The error domain identifying the service or component that generated the error"
],
reason: [
type: :string,
required: true,
doc: "A unique identifier for the specific error within the domain"
],
message: [
type: {:or, [:atom, :string]},
required: false,
doc: "The error message, either an atom that maps to a standard message or a custom string"
],
metadata: [
type:
{:map, :string, {:or, [:string, {:tuple, [:string, {:in, [:INTERNAL, :PRIVATE, :PUBLIC]}]}]}},
default: %{},
doc:
"Default metadata to be merged with runtime metadata. Values will be automatically converted to MetadataValue structs."
],
code: [
type: {:in, @codes},
default: :UNKNOWN,
doc: "The standard error code"
],
visibility: [
type: {:in, @visibility_levels},
default: :INTERNAL,
doc: "Whether the error should be visible to end users or kept internal"
],
help: [
type: :map,
required: false,
keys: [
links: [
type:
{:list,
{:map,
[description: [type: :string, required: false], url: [type: :string, required: true]]}}
]
],
doc: "Help information with links to documentation"
]
)
@moduledoc """
Universal Error Specification implementation for Elixir.
This module provides a `use` macro to define structured exceptions
following the Universal Error Specification ADR.
## Usage
defmodule MyApp.NotFoundError do
use Trogon.Error,
domain: "com.myapp.mydomain",
reason: "not_found",
message: "The {resource} was not found"
end
## Creating Errors
MyApp.NotFoundError.new!(
metadata: Trogon.Error.Metadata.new(%{resource: "user"})
)
"""
@type code ::
:CANCELLED
| :UNKNOWN
| :INVALID_ARGUMENT
| :DEADLINE_EXCEEDED
| :NOT_FOUND
| :ALREADY_EXISTS
| :PERMISSION_DENIED
| :RESOURCE_EXHAUSTED
| :FAILED_PRECONDITION
| :ABORTED
| :OUT_OF_RANGE
| :UNIMPLEMENTED
| :INTERNAL
| :UNAVAILABLE
| :DATA_LOSS
| :UNAUTHENTICATED
@type visibility :: :INTERNAL | :PRIVATE | :PUBLIC
@type metadata :: Metadata.t()
@type subject :: String.t()
@type source_id :: String.t()
@type id :: String.t()
@type time :: DateTime.t()
@type help_link :: %{
description: String.t(),
url: String.t()
}
@type help :: %{
links: list(help_link())
}
@type debug_info :: %{
stack_entries: list(String.t()),
detail: String.t()
}
@type localized_message :: %{
locale: String.t(),
message: String.t()
}
@type retry_info_duration :: %{retry_offset: Duration.t()}
# TODO: Enable retry info time aftr https://github.com/aip-dev/google.aip.dev/issues/1528 is resolved
# To avoid breaking changes, and strong incompatibility with Google RPC spec, we're not adding retry info time.
# @type retry_info_time :: %{retry_time: DateTime.t()}
@type retry_info :: retry_info_duration()
@type t(struct) :: %{
__struct__: struct,
__exception__: true,
specversion: non_neg_integer(),
code: code(),
message: String.t(),
domain: String.t(),
reason: String.t(),
metadata: metadata(),
causes: list(t(module())),
visibility: visibility(),
subject: subject() | nil,
id: id() | nil,
time: time() | nil,
help: help() | nil,
debug_info: debug_info() | nil,
localized_message: localized_message() | nil,
retry_info: retry_info() | nil,
source_id: source_id() | nil
}
@type error_opts :: [
{:metadata, metadata()}
| {:causes, list(t(module()))}
| {:subject, subject() | nil}
| {:debug_info, debug_info() | nil}
| {:localized_message, localized_message() | nil}
| {:retry_info, retry_info() | nil}
| {:id, id() | nil}
| {:time, time() | nil}
| {:source_id, source_id() | nil}
]
@spec_version 1
@derive {Inspect, except: [:__trogon_error__]}
@enforce_keys [:specversion, :code, :message, :domain, :reason, :metadata]
defexception [
:__trogon_error__,
:specversion,
:code,
:message,
:domain,
:reason,
:metadata,
:causes,
:visibility,
:subject,
:id,
:time,
:help,
:debug_info,
:localized_message,
:retry_info,
:source_id
]
@impl Exception
def message(error) do
help =
(error.help || %{})
|> Map.get(:links, [])
|> Enum.map_join("\n", fn link -> "- #{link.description}: #{link.url}" end)
info_msg =
""
|> put_key_msg_line(" visibility", error.visibility)
|> put_key_msg_line(" domain", error.domain)
|> put_key_msg_line(" reason", error.reason)
|> put_key_msg_line(" code", error.code)
|> put_key_msg_line(" metadata", error.metadata)
|> put_key_msg_line(" id", error.id)
|> put_key_msg_line(" time", error.time)
|> put_key_msg_line(" subject", error.subject)
|> put_key_msg_line(" source_id", error.source_id)
|> put_key_msg_line(" retry_info", error.retry_info)
|> put_key_msg_line(" debug_info", error.debug_info)
|> put_help_msg_line(help)
String.trim_trailing(error.message) <> info_msg
end
defp put_key_msg_line(msg, _key, nil), do: msg
defp put_key_msg_line(msg, _key, ""), do: msg
defp put_key_msg_line(msg, _key, map) when is_map(map) and map_size(map) == 0, do: msg
defp put_key_msg_line(msg, _key, metadata) when is_empty_metadata(metadata), do: msg
defp put_key_msg_line(msg, key, %Metadata{} = metadata), do: "#{msg}\n#{key}:\n#{pretty_print(metadata)}"
defp put_key_msg_line(msg, key, value), do: "#{msg}\n#{key}: #{pretty_print(value)}"
defp put_help_msg_line(msg, ""), do: msg
defp put_help_msg_line(msg, help), do: "#{msg}\n#{help}"
defp pretty_print(term) when is_binary(term), do: term
defp pretty_print(%Metadata{entries: entries}) do
Enum.map_join(entries, "\n", fn {key, value} ->
" - #{key}: #{value.value} visibility=#{value.visibility}"
end)
end
defp pretty_print(term), do: inspect(term, pretty: true)
@doc false
def exception(struct_module, compile_opts, opts \\ []) do
domain = Keyword.fetch!(compile_opts, :domain)
reason = Keyword.fetch!(compile_opts, :reason)
code = Keyword.fetch!(compile_opts, :code)
message = Keyword.fetch!(compile_opts, :message)
visibility = Keyword.fetch!(compile_opts, :visibility)
help = Keyword.get(compile_opts, :help)
compile_metadata = Keyword.fetch!(compile_opts, :metadata)
runtime_metadata = Keyword.get(opts, :metadata, Metadata.new())
metadata = Metadata.merge(compile_metadata, runtime_metadata)
causes = Keyword.get(opts, :causes, [])
subject = Keyword.get(opts, :subject)
debug_info = Keyword.get(opts, :debug_info)
localized_message = Keyword.get(opts, :localized_message)
retry_info = Keyword.get(opts, :retry_info)
id = Keyword.get(opts, :id)
time = Keyword.get(opts, :time)
source_id = Keyword.get(opts, :source_id)
struct(struct_module, %{
__trogon_error__: true,
specversion: @spec_version,
code: code,
message: message,
domain: domain,
reason: reason,
metadata: metadata,
causes: causes,
visibility: visibility,
subject: subject,
id: id,
time: time,
help: help,
debug_info: debug_info,
localized_message: localized_message,
retry_info: retry_info,
source_id: source_id
})
end
@doc false
def validate_options!(opts) do
NimbleOptions.validate!(opts, @options_schema)
end
defp validate_runtime_options!(opts) do
code = opts[:code]
if code && code not in @codes do
raise ArgumentError, "Invalid code: #{inspect(code)}"
end
visibility = opts[:visibility]
if visibility && visibility not in @visibility_levels do
raise ArgumentError, "Invalid visibility: #{inspect(visibility)}"
end
domain = opts[:domain]
if not is_binary(domain) do
raise ArgumentError, "Invalid domain: #{inspect(domain)}"
end
reason = opts[:reason]
if not is_binary(reason) do
raise ArgumentError, "Invalid reason: #{inspect(reason)}"
end
opts
end
defdelegate metadata, to: Metadata, as: :new
@template_opts [:domain, :reason, :code, :message, :visibility, :help, :metadata]
@doc """
Creates a new Trogon error at runtime with dynamic values.
This function allows you to create Trogon errors without having predefined
error modules, which is useful for handling external errors from services
you don't control.
## Parameters
The function accepts the same options as template creation with `use Trogon.Error`,
but as instance parameters:
- `:domain` (required) - The error domain identifying the service
- `:reason` (required) - A unique identifier for the specific error
- `:code` (optional) - The standard error code (defaults to `:UNKNOWN`)
- `:message` (optional) - The error message (defaults to the code)
- `:visibility` (optional) - Error visibility (defaults to `:INTERNAL`)
- `:help` (optional) - Help information
- `:metadata` (optional) - Default metadata
Plus all the instance options supported by regular Trogon errors:
- `:causes` - List of causing errors
- `:subject` - The subject this error relates to
- `:debug_info` - Debug information
- `:localized_message` - Localized message
- `:retry_info` - Retry information
- `:id` - Error instance ID
- `:time` - Error timestamp
- `:source_id` - Source identifier
## Examples
# Basic external error
Trogon.Error.new!(
domain: "com.stripe.payment",
reason: "card_declined",
message: "Your card was declined"
)
# With additional metadata and options
Trogon.Error.new!(
domain: "com.external.api",
reason: "rate_limit_exceeded",
code: :RESOURCE_EXHAUSTED,
message: "Rate limit exceeded",
metadata: Trogon.Error.Metadata.new(%{
"limit" => "100",
"window" => "3600"
}),
subject: "api-client-123",
retry_info: %{retry_offset: %Duration{second: 60}}
)
"""
@spec new!(keyword()) :: t(__MODULE__)
def new!(opts) when is_list(opts) do
{template_opts, instance_opts} = Keyword.split(opts, @template_opts)
template_opts = validate_runtime_options!(template_opts)
compiled_opts =
[code: :UNKNOWN, visibility: :INTERNAL, help: nil, metadata: %{}]
|> Keyword.merge(template_opts)
|> Keyword.update!(:metadata, &to_metadata/1)
|> then(&Keyword.put_new(&1, :message, &1[:code]))
|> Keyword.update!(:message, &to_msg/1)
exception(__MODULE__, compiled_opts, instance_opts)
end
defp to_metadata(%Metadata{} = metadata) do
metadata
end
defp to_metadata(raw_metadata) when is_map(raw_metadata) do
Metadata.new(raw_metadata)
end
@doc """
Converts an atom to an integer code.
## Examples
iex> Trogon.Error.to_code_int(:CANCELLED)
1
iex> err = TestSupport.InvalidCurrencyError.new!()
...> Trogon.Error.to_code_int(err)
2
"""
@spec to_code_int(atom() | t(module())) :: non_neg_integer()
def to_code_int(%{code: code}), do: to_code_int(code)
def to_code_int(:CANCELLED), do: 1
def to_code_int(:UNKNOWN), do: 2
def to_code_int(:INVALID_ARGUMENT), do: 3
def to_code_int(:DEADLINE_EXCEEDED), do: 4
def to_code_int(:NOT_FOUND), do: 5
def to_code_int(:ALREADY_EXISTS), do: 6
def to_code_int(:PERMISSION_DENIED), do: 7
def to_code_int(:RESOURCE_EXHAUSTED), do: 8
def to_code_int(:FAILED_PRECONDITION), do: 9
def to_code_int(:ABORTED), do: 10
def to_code_int(:OUT_OF_RANGE), do: 11
def to_code_int(:UNIMPLEMENTED), do: 12
def to_code_int(:INTERNAL), do: 13
def to_code_int(:UNAVAILABLE), do: 14
def to_code_int(:DATA_LOSS), do: 15
def to_code_int(:UNAUTHENTICATED), do: 16
@doc """
Converts an error code to its corresponding HTTP status code.
## Examples
iex> Trogon.Error.to_http_status_code(:CANCELLED)
499
iex> err = TestSupport.InvalidCurrencyError.new!()
...> Trogon.Error.to_http_status_code(err)
500
"""
@spec to_http_status_code(atom() | t(module())) :: non_neg_integer()
def to_http_status_code(%{code: code}), do: to_http_status_code(code)
def to_http_status_code(:CANCELLED), do: 499
def to_http_status_code(:UNKNOWN), do: 500
def to_http_status_code(:INVALID_ARGUMENT), do: 400
def to_http_status_code(:DEADLINE_EXCEEDED), do: 504
def to_http_status_code(:NOT_FOUND), do: 404
def to_http_status_code(:ALREADY_EXISTS), do: 409
def to_http_status_code(:PERMISSION_DENIED), do: 403
def to_http_status_code(:RESOURCE_EXHAUSTED), do: 429
def to_http_status_code(:FAILED_PRECONDITION), do: 400
def to_http_status_code(:ABORTED), do: 409
def to_http_status_code(:OUT_OF_RANGE), do: 400
def to_http_status_code(:UNIMPLEMENTED), do: 501
def to_http_status_code(:INTERNAL), do: 500
def to_http_status_code(:UNAVAILABLE), do: 503
def to_http_status_code(:DATA_LOSS), do: 500
def to_http_status_code(:UNAUTHENTICATED), do: 401
@spec to_msg(atom() | String.t()) :: String.t()
def to_msg(msg) when is_binary(msg), do: msg
def to_msg(:CANCELLED), do: "the operation was cancelled"
def to_msg(:UNKNOWN), do: "unknown error"
def to_msg(:INVALID_ARGUMENT), do: "invalid argument provided"
def to_msg(:DEADLINE_EXCEEDED), do: "deadline exceeded"
def to_msg(:NOT_FOUND), do: "resource not found"
def to_msg(:ALREADY_EXISTS), do: "resource already exists"
def to_msg(:PERMISSION_DENIED), do: "permission denied"
def to_msg(:UNAUTHENTICATED), do: "unauthenticated"
def to_msg(:RESOURCE_EXHAUSTED), do: "resource exhausted"
def to_msg(:FAILED_PRECONDITION), do: "failed precondition"
def to_msg(:ABORTED), do: "operation aborted"
def to_msg(:OUT_OF_RANGE), do: "out of range"
def to_msg(:UNIMPLEMENTED), do: "not implemented"
def to_msg(:INTERNAL), do: "internal error"
def to_msg(:UNAVAILABLE), do: "service unavailable"
def to_msg(:DATA_LOSS), do: "data loss or corruption"
@doc """
Checks if a term is a Trogon error.
## Examples
iex> error = TestSupport.TestError.new!()
iex> require Trogon.Error
iex> Trogon.Error.is_trogon_error?(error)
true
iex> require Trogon.Error
iex> Trogon.Error.is_trogon_error?(%{})
false
"""
defguard is_trogon_error?(term)
when is_map(term) and is_map_key(term, :__trogon_error__) and
:erlang.map_get(:__trogon_error__, term) == true
@doc false
def compile_metadata(metadata) when is_map(metadata) do
metadata
|> Metadata.new()
|> Macro.escape()
end
@doc """
Defines an error module with the given options.
## Options
#{NimbleOptions.docs(@options_schema)}
"""
defmacro __using__(opts) do
quote bind_quoted: [opts: opts] do
alias Trogon.Error
opts = Error.validate_options!(opts)
compiled_opts =
[code: :UNKNOWN, visibility: :INTERNAL, help: nil]
|> Keyword.merge(opts)
|> Keyword.update!(:help, &Macro.escape/1)
|> Keyword.update!(:metadata, &Error.compile_metadata(&1))
|> then(&Keyword.put_new(&1, :message, &1[:code]))
|> Keyword.update!(:message, &Error.to_msg/1)
@derive {Inspect, except: [:__trogon_error__]}
@enforce_keys [:specversion, :code, :message, :domain, :reason, :metadata]
defexception [
:__trogon_error__,
:specversion,
:code,
:message,
:domain,
:reason,
:metadata,
:causes,
:visibility,
:subject,
:id,
:time,
:help,
:debug_info,
:localized_message,
:retry_info,
:source_id
]
@doc """
Raises an error with the given options.
> #### When to use `exception/1` {: .info}
> Use `exception/1` when you want to raise an error.
>
> Example:
>
> ```elixir
> raise MyApp.NotFoundError, metadata: Trogon.Error.Metadata.new(%{resource: "user"})
> ```
>
> Otherwise, use `new!/1` to create an error instance.
"""
@impl Exception
@spec exception(Trogon.Error.error_opts()) :: Trogon.Error.t(__MODULE__)
def exception(opts \\ []) do
Error.exception(__MODULE__, unquote(compiled_opts), opts)
end
@doc """
Creates a new error instance with the given options.
"""
@spec new!(Trogon.Error.error_opts()) :: Trogon.Error.t(__MODULE__)
def new!(opts \\ []) when is_list(opts) do
Error.exception(__MODULE__, unquote(compiled_opts), opts)
end
@impl Exception
def message(%__MODULE__{} = error) do
Error.message(error)
end
end
end
end