Summary
to_error/2's keyword-list branch passes a :value option to the configured unknown_error unconditionally. Only Ash.Error.Unknown.UnknownError happens to declare that field (fields: [:error, :field, :value]), so for any other unknown error struct!/2 raises KeyError: key :value not found.
This makes the branch unusable outside Ash, including for the fields: [:error] shape used throughout splode's own documentation and tests.
Reproduction
Using the same shape as splode's own test suite:
defmodule Own.UnknownError do
use Splode.Error, fields: [:error], class: :unknown
def message(e), do: "unknown: #{inspect(e.error)}"
end
defmodule Own.Unknown do
use Splode.ErrorClass, class: :unknown
end
defmodule Own.Errors do
use Splode, error_classes: [unknown: Own.Unknown], unknown_error: Own.UnknownError
end
Every keyword list crashes, including the intended one:
[noproc: {GenServer, :call, [:n, :ping, 5000]}] => {:raised, KeyError, "key :value not found"}
[timeout: 5000] => {:raised, KeyError, "key :value not found"}
[shutdown: :closed] => {:raised, KeyError, "key :value not found"}
[message: "a real error opts list"] => {:raised, KeyError, "key :value not found"}
The README documents returning "a string, or a keyword list" as supported, so this is the documented path.
Second, related problem: the branch is entered too eagerly
The branch is selected with Keyword.keyword?/1, which is true for any list of two-element tuples with atom keys. OTP exit reasons qualify:
Keyword.keyword?([{:noproc, {GenServer, :call, [:some_name, :ping, 5000]}}])
#=> true
So an exit reason gets destructured as a set of options — error: nil (there is no :message key), with the reason itself put in :value. Even with a :value field declared, the result renders as:
# Unknown Error
An unknown error occurred.
## `error`:
`nil`
The real reason is hidden in a field that most message/1 implementations don't render. Note that the same reason as a three-element tuple fails Keyword.keyword?/1, takes the catch-all, and renders correctly as unknown error: {:noproc, {GenServer, :call, [...]}} — so the keyword branch is strictly worse than the fallback it pre-empts, and conversion depends on tuple arity.
This surfaced as a reactor bug report where an async step's task exit replaced the real error with KeyError: key :value not found. Filed for reactor separately, but the crash is reachable from any consumer.
Notes
- Not an Elixir 1.20 change:
defexception's generated exception/1 has been a bare struct!(__MODULE__, args) at least back through 1.17, so there is no unknown-field filtering to have lost.
Keyword.put_new(:value, list) has been present since the initial commit.
Summary
to_error/2's keyword-list branch passes a:valueoption to the configuredunknown_errorunconditionally. OnlyAsh.Error.Unknown.UnknownErrorhappens to declare that field (fields: [:error, :field, :value]), so for any other unknown errorstruct!/2raisesKeyError: key :value not found.This makes the branch unusable outside Ash, including for the
fields: [:error]shape used throughout splode's own documentation and tests.Reproduction
Using the same shape as splode's own test suite:
Every keyword list crashes, including the intended one:
The README documents returning "a string, or a keyword list" as supported, so this is the documented path.
Second, related problem: the branch is entered too eagerly
The branch is selected with
Keyword.keyword?/1, which is true for any list of two-element tuples with atom keys. OTP exit reasons qualify:So an exit reason gets destructured as a set of options —
error: nil(there is no:messagekey), with the reason itself put in:value. Even with a:valuefield declared, the result renders as:The real reason is hidden in a field that most
message/1implementations don't render. Note that the same reason as a three-element tuple failsKeyword.keyword?/1, takes the catch-all, and renders correctly asunknown error: {:noproc, {GenServer, :call, [...]}}— so the keyword branch is strictly worse than the fallback it pre-empts, and conversion depends on tuple arity.This surfaced as a reactor bug report where an async step's task exit replaced the real error with
KeyError: key :value not found. Filed for reactor separately, but the crash is reachable from any consumer.Notes
defexception's generatedexception/1has been a barestruct!(__MODULE__, args)at least back through 1.17, so there is no unknown-field filtering to have lost.Keyword.put_new(:value, list)has been present since the initial commit.