Current section

Files

Jump to
triage lib triage.ex
Raw

lib/triage.ex

defmodule Triage do
@moduledoc """
Documentation for `Triage`.
"""
alias Triage.Results
alias Triage.Stacktrace
alias Triage.WrappedError
alias Triage.Validate
require Logger
require Stacktrace
@type result() :: :ok | {:ok, any()} | :error | {:error, any()}
@type value_result() :: {:ok, any()} | :error | {:error, any()}
@doc group: "Functions > Contexts"
@doc """
Wraps `t:result/0` with additional context information, leaving `:ok` results unchanged.
Takes a result tuple and wraps error cases (`:error` or `{:error, reason}`) with
a context string, metadata, and stacktrace info contained in `Triage.WrappedError{}`.
If the second argument is a string, the context is set. If the second argument is a
keyword list or a map the metadata is set. The arity 3 version allows setting both.
The `log/2` and `user_message/1` functions support `WrappedError` results. See their
docs for more details
## Examples
iex> Triage.wrap_context(:ok, "fetching user")
:ok
iex> Triage.wrap_context({:ok, 42}, "fetching user")
{:ok, 42}
iex> Triage.wrap_context(:error, "fetching user")
{:error, %Triage.WrappedError{}}
iex> Triage.wrap_context({:error, :not_found}, "fetching user", %{user_id: 123})
{:error, %Triage.WrappedError{}}
iex> Triage.wrap_context({:error, :not_found}, %{user_id: 123})
{:error, %Triage.WrappedError{}}
"""
@spec wrap_context(result(), String.t() | keyword() | map()) ::
:ok | {:ok, any()} | {:error, Triage.WrappedError.t()}
def wrap_context(result, context) when is_binary(context) do
wrap_context(result, context, %{})
end
def wrap_context(result, metadata) when is_map(metadata) or is_list(metadata) do
wrap_context(result, nil, metadata)
end
@doc group: "Functions > Contexts"
@doc """
Wrap errors from a result with both a context string and metadata. See `wrap_context/2`
"""
@spec wrap_context(result(), String.t() | nil, keyword() | map()) ::
:ok | {:ok, any()} | {:error, Triage.WrappedError.t()}
# def wrap_context(result, context, meta \\ %{})
def wrap_context(:ok, _context, _meta), do: :ok
def wrap_context(result, _, _) when is_tuple(result) and elem(result, 0) == :ok do
result
end
def wrap_context(:error, context, metadata) do
stacktrace = Stacktrace.calling_stacktrace()
{:error, WrappedError.new(:error, context, stacktrace, metadata)}
end
def wrap_context(result, context, metadata)
when is_tuple(result) and elem(result, 0) == :error do
stacktrace = Stacktrace.calling_stacktrace()
{:error, WrappedError.new(result, context, stacktrace, metadata)}
end
@doc group: "Functions > Control Flow"
@doc """
Executes a function that returns a result tuple, without exception handling.
Calls the provided zero-arity function and checks that it returns a result
(`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`). If the function returns
any other value, it wraps it in `{:ok, value}`.
This is the "unsafe" version that doesn't catch exceptions. Use `then/1` for
exception handling.
## Parameters
* `func` - A zero-arity function that returns a result
## Examples
iex> then!(fn -> 42 end)
{:ok, 42}
iex> then!(fn -> {:ok, 42} end)
{:ok, 42}
iex> then!(fn -> {:error, :not_found} end)
{:error, :not_found}
iex> then!(fn -> :error end)
:error
"""
@spec then!((-> any())) :: result()
def then!(func) do
case func.() do
:ok -> :ok
{:ok, _} = result -> result
:error -> :error
{:error, _} = result -> result
other -> {:ok, other}
end
end
@doc group: "Functions > Control Flow"
@doc """
Executes a function with a previous result value, without exception handling.
Takes a result from a previous then and, if successful, passes the unwrapped value
to the provided function. If the previous result was an error, short-circuits and
returns the error without calling the function.
This is the "unsafe" version that doesn't catch exceptions. Use `then/2` for
exception handling.
## Parameters
* `result` - The previous result (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`)
* `func` - A function that takes the unwrapped value and returns a result
## Examples
iex> then!({:ok, 5}, fn x -> {:ok, x * 2} end)
{:ok, 10}
iex> then!({:error, :not_found}, fn x -> {:ok, x * 2} end)
{:error, :not_found}
"""
@spec then!(result(), (any() -> any())) :: result()
def then!(:ok, func) do
case func.(nil) do
:ok -> :ok
{:ok, _} = result -> result
:error -> :error
{:error, _} = result -> result
other -> {:ok, other}
end
end
def then!({:ok, value}, func) do
case func.(value) do
:ok -> :ok
{:ok, _} = result -> result
:error -> :error
{:error, _} = result -> result
other -> {:ok, other}
end
end
def then!(:error, _func), do: :error
def then!({:error, _} = result, _func), do: result
def then!(other, _), do: Validate.validate_result!(other, :strict)
@doc group: "Functions > Control Flow"
@doc """
Executes a function that returns a result tuple, with exception handling.
Calls the provided zero-arity function and ensures it returns a valid result.
If the function raises an exception, it catches it and returns
`{:error, %Triage.WrappedError{}}` with details about the exception.
## Parameters
* `func` - A zero-arity function that returns a result
## Examples
iex> then(fn -> {:ok, 42} end)
{:ok, 42}
iex> then(fn -> raise "boom" end)
{:error, %Triage.WrappedError{}}
"""
@spec then((any() -> any())) :: result()
def then(func) do
try do
then!(func)
rescue
exception ->
{:error, WrappedError.new_raised(exception, func, __STACKTRACE__)}
end
end
@doc group: "Functions > Control Flow"
@doc """
Executes a function with a previous result value, with exception handling.
Takes a result from a previous then and, if successful, passes the unwrapped value
to the provided function. If the previous result was an error, short-circuits and
returns the error. If the function raises an exception, it catches it and returns
`{:error, %Triage.WrappedError{}}` with details about the exception.
## Parameters
* `result` - The previous result (`:ok`, `{:ok, value}`, `:error`, or `{:error, reason}`)
* `func` - A function that takes the unwrapped value and returns a result
## Examples
iex> then({:ok, 5}, fn x -> {:ok, x * 2} end)
{:ok, 10}
iex> then({:ok, 5}, fn _x -> raise "boom" end)
{:error, %Triage.WrappedError{}}
"""
@spec then(result(), (any() -> any())) :: result()
def then(result, func) do
try do
then!(result, func)
rescue
exception ->
{:error, WrappedError.new_raised(exception, func, __STACKTRACE__)}
end
end
@doc group: "Functions > Control Flow"
@doc """
For dealing with `:error` cases, passing `:ok` results through unchanged.
When given result is `{:error, reason}`, the `reason` is passed into the callback
function. The callback function can then return a new `reason` which will be
returned from `handle` wrapped in an `{:error, _}` tuple.
If `:error` is the given result, `nil` will be given to the callback function.
The callback function can also return `:ok` or `{:ok, any()}` to have the error
be ignored and the `:ok` result will be returned instead.
## Examples
iex> ping_account_server() |> Triage.handle(fn _ -> :account_server_failure end)
{:error, :account_server_failure}
iex> Triage.handle({:error, :unknown}, fn :unknown -> {:ok, @default_value} end)
{:ok, ...}
iex> Triage.handle(:ok, fn _ -> :not_used end)
:ok
iex> Triage.handle({:ok, ...}, fn _ -> :not_used end)
{:ok, 42}
iex> Triage.handle(:error, fn nil -> :handled end)
{:error, :handled}
"""
@spec handle(result(), (any() -> any())) :: result()
def handle(:error, func), do: handle({:error, nil}, func)
def handle({:error, reason}, func) do
case func.(reason) do
:ok ->
:ok
{:ok, _} = result ->
result
other ->
{:error, other}
end
end
def handle(result, _) do
Validate.validate_result!(result, :strict)
result
end
@doc group: "Functions > Enumeration"
@doc """
Maps a function over an enumerable, collecting successful values and short-circuiting on the first error.
Takes an enumerable or `{:ok, enumerable}` and applies a function to each element.
If all callbacks return success with `{:ok, value}`), `map_if` returns
`{:ok, [transformed_values]}`. If any call to the callback returns an error, `map_if`
immediately stops processing and returns that error.
If `map_if` is given an `:error` result for it's first argument that argument is returned
unchanged and the callback is never called.
Since `map_if` requires an enumerable value to work with, it will fail if given `:ok` as a result argument.
This is useful when you need all transformations to succeed—if any fail, you don't want
the partial results.
## Examples
iex> Triage.map_if(xml_docs, & xml_to_json(&1, opts))
{:ok, [...]}
iex> Triage.map_if(xml_docs, & xml_to_json(&1, opts))
{:error, ...}
iex> Triage.map_if(:error, fn _ -> <not called> end)
:error
iex> Triage.map_if({:error, :not_found}, fn _ -> <not called end)
{:error, :not_found}
"""
@spec map_if(result(), (any() -> any())) :: result()
def map_if({:ok, value}, func), do: map_if(value, func)
def map_if(:error, _), do: :error
def map_if({:error, _} = error, _), do: error
def map_if(values, func) do
{:ok,
Enum.map(values, fn value ->
case func.(value) do
# :ok ->
{:ok, value} ->
value
:error ->
throw({:__ERRORS__, :error})
{:error, _} = error ->
throw({:__ERRORS__, error})
end
end)}
catch
{:__ERRORS__, result} ->
result
end
@doc group: "Functions > Enumeration"
@doc """
Finds the first successful result from applying a function to enumerable elements.
Takes an enumerable or `{:ok, enumerable}` and applies a function to each element
The first successful result (`:ok` or `{:ok, value}`) from the callback is returned
from `find_value` and no further iteration is done.
If all callbacks return errors, then `{:error, [list of error reasons]}` is returned.
For `:error` atoms in the error list, they are represented as `nil`.
If `:error` or `{:error, reason}` is given as the first argument to `find_value`,
it is passed through unchanged.
This can be useful when you're trying multiple strategies or checking multiple
values to find the first one that works.
## Examples
iex> Triage.find_value(domains, &ping_domain)
{:ok, "www.mydomain.com"}
iex> Triage.find_value({:ok, domains}, &ping_domain)
{:error, [:nxdomain, :timeout, :nxdomain]}
iex> Triage.find_value(:error, fn _ -> <not called> end)
:error
iex> Triage.find_value({:error, :not_found}, fn _ -> <not called> end)
{:error, :not_found}
"""
@spec find_value(value_result() | Enumerable.t(), (any() -> result())) ::
:ok | {:ok, any()} | {:error, [any()]}
def find_value({:ok, input}, func), do: find_value(input, func)
def find_value(:error, _), do: :error
def find_value({:error, _} = error, _), do: error
def find_value(input, func) do
errors =
Enum.map(input, fn value ->
case func.(value) do
:ok ->
throw({:__ERRORS__, :ok})
{:ok, _} = result ->
throw({:__ERRORS__, result})
:error ->
nil
{:error, reason} ->
reason
other ->
Validate.validate_result!(other, :strict, "Callback return")
end
end)
{:error, errors}
catch
{:__ERRORS__, result} ->
result
end
@doc group: "Functions > Enumeration"
@doc """
Validates that a callback function gives an `:ok` / `{:ok, _}` result for all elements
in the given enumerable.
Takes an enumerable or `{:ok, enumerable}` (giving `:ok` will return an error)
and applies a callback function to each element. If all calls to the callback
return `:ok` or `{:ok, any()}` then `all` returns `:ok`.
If any callback returns an error, immediately stops processing and returns that error.
If `:error` or `{:error, reason}` are given as the first argument, they are returned
unchanged. Note that even if callbacks return `{:ok, value}`, the values are discarded
and only `:ok` is returned — this function is for validation, not transformation.
See `map_if/2` if you need transformation which short-circuits.
This is useful when you need to validate that all items in a collection meet certain
criteria before proceeding with subsequent operations.
## Examples
iex> Triage.all(emails, &check_valid_email)
:ok
iex> Triage.all({:ok, emails}, &check_valid_email)
{:error, :invalid_hostname}
iex> Triage.all(:error, fn _ -> <not called> end)
:error
iex> Triage.all({:error, :not_found}, fn _ -> <not called> end)
{:error, :not_found}
"""
@spec all(value_result() | Enumerable.t(), (any() -> result())) ::
:ok | :error | {:error, Triage.WrappedError.t()}
def all({:ok, input}, func), do: all(input, func)
def all(:error, _), do: :error
def all({:error, _} = error, _), do: error
def all(input, func) do
for value <- input do
case func.(value) do
:ok ->
nil
{:ok, _} ->
nil
:error ->
throw({:__ERRORS__, :error})
{:error, _} = error ->
throw({:__ERRORS__, error})
other ->
Validate.validate_result!(other, :strict, "Callback return")
end
end
:ok
catch
# Wrapping throw so that callback throws will not be caught by us
{:__ERRORS__, error} ->
error
end
@doc group: "Functions > Helpers"
@doc """
Generates a user-friendly error message from various error types.
Converts `{:error, reason}` tuples into human-readable messages suitable for displaying to end users.
When the `reason` is a string, the string error message is returned.
When the `reason` is `t:Triage.WrappedError.t/0` it unwraps the error chain and includes context information in the message.
For exceptions and unknown error types, it
* generates a unique error code
* logs the error code with full error details
* returns a generic error to the user with the error code that the user can report
## Parameters
* `reason` - The error to convert (string, exception, `%Triage.WrappedError{}`, or any other value)
## Examples
iex> user_message({:error, "Invalid email"})
"Invalid email"
# WrappedError which was returned as a result of `wrap_context` being called in two places
# and where the original error was `{:error, "not found"}`
iex> user_message({:error, %Triage.WrappedError{}})
"not found (happened while: fetching user => validating email)"
iex> user_message({:error, %RuntimeError{message: "boom"}})
"There was an error. Refer to code: ABC12345"
Log generated:
ABC12345: Could not generate user error message. Error was: #RuntimeError<...> (message: boom)
"""
@spec user_message({:error, any()}) :: String.t()
def user_message({:error, reason}) when is_binary(reason), do: reason
def user_message({:error, %WrappedError{} = error}) do
errors = WrappedError.unwrap(error)
last_error = List.last(errors)
context_string = Enum.map_join(errors, " => ", & &1.context)
user_message(last_error.result) <> " (happened while: #{context_string})"
end
def user_message({:error, exception}) when is_exception(exception) do
error_code = Triage.String.generate(8)
Logger.error(
"#{error_code}: Could not generate user error message. Error was: #{Triage.Inspect.inspect(exception)} (message: #{Results.exception_message(exception)})"
)
"There was an error. Refer to code: #{error_code}"
end
def user_message({:error, reason}) do
error_code = Triage.String.generate(8)
Logger.error(
"#{error_code}: Could not generate user error message. Error was: #{Triage.Inspect.inspect(reason)}"
)
"There was an error. Refer to code: #{error_code}"
end
@doc group: "Functions > Helpers"
@doc """
Logs a result tuple and returns it unchanged.
Takes a result and logs it. By default, only errors are logged.
The `mode` argument can be either `:errors` (the default) or `:all` (logs all results)
See [this guide](logging-json.html) for information about
logging with JSON
The `result` passed in can be:
* :ok / :error
* {:ok, any()} / {:error, any()}
* {:ok, ...} / {:error, ...} (any sized tuple starting with :ok or :error)
"""
@spec log(result() | tuple()) :: result() | tuple()
def log(result, mode \\ :errors) do
Validate.validate_result!(result, :loose)
if mode not in [:errors, :all] do
raise ArgumentError, "mode must be either :errors or :all (got: #{inspect(mode)})"
end
stacktrace = Stacktrace.calling_stacktrace()
{message, result_details} = Map.pop(Results.details(result), :message)
if result_details.type in ~w[error raise] || mode == :all do
level = if(result_details.type in ~w[error raise], do: :error, else: :info)
stacktrace_line =
stacktrace
|> Stacktrace.most_relevant_entry()
|> Stacktrace.format_file_line()
parts_string =
[stacktrace_line, message]
|> Enum.reject(&is_nil/1)
|> Enum.join(": ")
{metadata, result_details} = Map.pop(result_details, :metadata, %{})
metadata = Map.put(metadata, :errors_result_details, result_details)
Logger.log(level, "[RESULT] #{parts_string}", metadata)
end
result
end
@doc group: "Functions > Helpers"
@doc """
Checks if a result is a success (`:ok` or `{:ok, any()}`).
Returns `true` if the result is `:ok` or `{:ok, any()}`, `false` if it's
`:error` or `{:error, any()}`. Raises `ArgumentError` for any other value.
## Examples
iex> Triage.ok?(:ok)
true
iex> Triage.ok?({:ok, 42})
true
iex> Triage.ok?({:ok, 42, :ignore})
true
iex> Triage.ok?(:error)
false
iex> Triage.ok?({:error, :not_found})
false
iex> Triage.ok?({:error, :not_found, 123})
false
"""
def ok?(:ok), do: true
def ok?(:error), do: false
def ok?(result) when is_tuple(result), do: elem(result, 0) == :ok
def ok?(result), do: Validate.validate_result!(result, :loose)
@doc group: "Functions > Helpers"
@doc """
Checks if a result is an error (`:error` or `{:error, any()}`).
Returns `true` if the result is `:error` or `{:error, any()}`, `false` if it's
`:ok` or `{:ok, any()}`. Raises `ArgumentError` for any other value.
## Examples
iex> Triage.error?(:error)
true
iex> Triage.error?({:error, :not_found})
true
iex> Triage.error?({:error, :not_found, 123})
true
iex> Triage.error?(:ok)
false
iex> Triage.error?({:ok, 42})
false
iex> Triage.error?({:ok, 42, :ignored})
false
"""
def error?(:ok), do: false
def error?(:error), do: true
def error?(result) when is_tuple(result), do: elem(result, 0) == :error
def error?(result), do: Validate.validate_result!(result, :loose)
end