From 97cb211c03b83f03995bc321a1e2c7bdd51f2fa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:07:56 +0000 Subject: [PATCH 1/4] Reraise unexpected spectra crashes instead of masking them encode/4-5, decode/4-5, and schema/3-4 rescue `error in ErlangError` to translate a few known spectra configuration errors into ArgumentError. That rescue clause also binds every other exception Elixir normalizes a raw BEAM error into (e.g. %BadMapError{}, %KeyError{}), not just literal %ErlangError{} structs. handle_erlang_error/4 only pattern-matched on %ErlangError{}, so any other crash from spectra failed inside Spectral's own handler with a misleading FunctionClauseError, discarding the original exception and stacktrace. It also used `raise` rather than `reraise`, which would have dropped the original stacktrace even for a recognized %ErlangError{} it didn't have a specific translation for. handle_erlang_error/4 now takes the rescue's __STACKTRACE__ and reraises anything that isn't one of the known configuration errors, preserving the original exception and where it happened. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WK6VcSwrRa5XLKUrEhEi2T --- CHANGELOG.md | 1 + lib/spectral.ex | 28 ++++++++++------- test/spectral_unexpected_crash_test.exs | 42 +++++++++++++++++++++++++ test/support/crashing_codec.ex | 26 +++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 test/spectral_unexpected_crash_test.exs create mode 100644 test/support/crashing_codec.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index de3a9b9..17aabbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - `Spectral.AbstractCode` now handles the Elixir non-empty list shorthand, `[elem_type, ...]` (and bare `[...]`), matching the existing `nonempty_list(elem_type)` support. Previously these types failed to compile with `unsupported type AST`. +- `encode/4-5`, `decode/4-5`, and `schema/3-4` rescue `error in ErlangError` to translate a few known spectra configuration errors into `ArgumentError`, but that rescue clause also binds every other exception Elixir normalizes a raw BEAM error into (e.g. `%BadMapError{}`, `%KeyError{}`), not just literal `%ErlangError{}` structs. Any unrelated crash inside spectra therefore hit `handle_erlang_error/4`'s single `%ErlangError{}` clause and failed with a misleading `FunctionClauseError` pointing at Spectral itself, discarding the original exception and stacktrace. Such crashes now reraise unchanged, with their original stacktrace intact. ## [0.13.0] - 2026-05-07 diff --git a/lib/spectral.ex b/lib/spectral.ex index b8498f3..23d4690 100644 --- a/lib/spectral.ex +++ b/lib/spectral.ex @@ -472,7 +472,7 @@ defmodule Spectral do |> convert_result() rescue error in ErlangError -> - handle_erlang_error(error, :encode, module, type_ref) + handle_erlang_error(error, __STACKTRACE__, :encode, module, type_ref) end @doc """ @@ -518,7 +518,7 @@ defmodule Spectral do |> convert_result() rescue error in ErlangError -> - handle_erlang_error(error, :decode, module, type_ref) + handle_erlang_error(error, __STACKTRACE__, :decode, module, type_ref) end @doc """ @@ -545,7 +545,7 @@ defmodule Spectral do :spectra.schema(format, module, type_ref) rescue error in ErlangError -> - handle_erlang_error(error, :schema, module, type_ref) + handle_erlang_error(error, __STACKTRACE__, :schema, module, type_ref) end @doc """ @@ -578,7 +578,7 @@ defmodule Spectral do :spectra.schema(format, module, type_ref, opts) rescue error in ErlangError -> - handle_erlang_error(error, :schema, module, type_ref) + handle_erlang_error(error, __STACKTRACE__, :schema, module, type_ref) end @doc """ @@ -673,27 +673,31 @@ defmodule Spectral do {:error, Spectral.Error.from_erlang_list(erlang_errors)} end - defp handle_erlang_error(%ErlangError{original: original} = error, operation, module, type_ref) do - case original do - {:module_types_not_found, ^module, _reason} -> + defp handle_erlang_error(error, stacktrace, operation, module, type_ref) do + case error do + %ErlangError{original: {:module_types_not_found, ^module, _reason}} -> raise ArgumentError, "module #{inspect(module)} not found, not loaded, or not compiled with debug_info (#{operation})" - {:type_or_record_not_found, ^type_ref} -> + %ErlangError{original: {:type_or_record_not_found, ^type_ref}} -> raise ArgumentError, "type #{inspect(type_ref)} not found in module #{inspect(module)} (#{operation})" - {:type_not_found, type_name, _arity} when type_name == type_ref -> + %ErlangError{original: {:type_not_found, type_name, _arity}} when type_name == type_ref -> raise ArgumentError, "type #{inspect(type_ref)} not found in module #{inspect(module)} (#{operation})" - {:type_not_supported, type_info} -> + %ErlangError{original: {:type_not_supported, type_info}} -> raise ArgumentError, "type not supported: #{inspect(type_info)} (#{operation})" _other -> - # Re-raise the original ErlangError if it's not a known configuration error - raise error + # Not a known configuration error — this covers both unrecognized + # %ErlangError{} originals and every other exception Elixir normalizes + # a raw BEAM error into (e.g. %BadMapError{}, %BadStructError{}), since + # `rescue error in ErlangError` binds those here too. Re-raise as-is + # with the original stacktrace instead of losing where it happened. + reraise error, stacktrace end end end diff --git a/test/spectral_unexpected_crash_test.exs b/test/spectral_unexpected_crash_test.exs new file mode 100644 index 0000000..e72fe48 --- /dev/null +++ b/test/spectral_unexpected_crash_test.exs @@ -0,0 +1,42 @@ +defmodule SpectralUnexpectedCrashTest do + use ExUnit.Case, async: true + + @moduledoc """ + `Spectral.encode/decode/schema` rescue `error in ErlangError` to translate a few + known spectra configuration errors (module/type not found, unsupported type) into + `ArgumentError`. That rescue clause also binds every other BEAM error that Elixir + normalizes into its own exception struct (e.g. `%KeyError{}`), not just literal + `%ErlangError{}` structs. `handle_erlang_error/4` used to only pattern-match on + `%ErlangError{}`, so those other exceptions crashed inside Spectral's own handler + with a misleading `FunctionClauseError`, discarding the original exception and + stacktrace. It must instead reraise the original exception with its original + stacktrace. + """ + + test "encode reraises an unexpected crash unchanged, not as a FunctionClauseError" do + error = + assert_raise KeyError, fn -> + Spectral.encode("data", CrashingCodec, :t) + end + + assert %KeyError{key: :missing_key} = error + + stacktrace = + try do + Spectral.encode("data", CrashingCodec, :t) + rescue + _ -> __STACKTRACE__ + end + + assert Enum.any?(stacktrace, fn {mod, _fun, _arity, _location} -> mod == CrashingCodec end) + end + + test "decode reraises an unexpected crash unchanged, not as a FunctionClauseError" do + error = + assert_raise KeyError, fn -> + Spectral.decode("data", CrashingCodec, :t) + end + + assert %KeyError{key: :missing_key} = error + end +end diff --git a/test/support/crashing_codec.ex b/test/support/crashing_codec.ex new file mode 100644 index 0000000..97421a9 --- /dev/null +++ b/test/support/crashing_codec.ex @@ -0,0 +1,26 @@ +defmodule CrashingCodec do + @moduledoc """ + A codec whose callbacks crash with a normal Elixir exception instead of + returning `:continue` or `{:error, _}`. Used to verify that an unexpected + crash from inside spectra surfaces to the caller unchanged, rather than + being turned into a misleading error about Spectral itself. + """ + + use Spectral.Codec + + @type t :: String.t() + + @impl Spectral.Codec + def encode(_format, _caller_type_info, {:type, :t, 0}, _target_type, _data, _config) do + Map.fetch!(Map.new(), :missing_key) + end + + def encode(_format, _caller_type_info, _type_ref, _target_type, _data, _config), do: :continue + + @impl Spectral.Codec + def decode(_format, _caller_type_info, {:type, :t, 0}, _target_type, _input, _config) do + Map.fetch!(Map.new(), :missing_key) + end + + def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue +end From 8c94954f62f346b6f7c8922632733db957893322 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:31:05 +0000 Subject: [PATCH 2/4] Split handle_erlang_error into two clauses instead of rewriting matches Keeps the original %ErlangError{original: original} = error head and its case original do ... end body untouched (content-wise) rather than moving the {:module_types_not_found, ...} etc. patterns into a case on the bare error. A second function clause now catches everything that isn't %ErlangError{} at all and reraises it, which is the only new behavior actually needed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WK6VcSwrRa5XLKUrEhEi2T --- lib/spectral.ex | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/lib/spectral.ex b/lib/spectral.ex index 23d4690..f243c9b 100644 --- a/lib/spectral.ex +++ b/lib/spectral.ex @@ -673,31 +673,42 @@ defmodule Spectral do {:error, Spectral.Error.from_erlang_list(erlang_errors)} end - defp handle_erlang_error(error, stacktrace, operation, module, type_ref) do - case error do - %ErlangError{original: {:module_types_not_found, ^module, _reason}} -> + defp handle_erlang_error( + %ErlangError{original: original} = error, + stacktrace, + operation, + module, + type_ref + ) do + case original do + {:module_types_not_found, ^module, _reason} -> raise ArgumentError, "module #{inspect(module)} not found, not loaded, or not compiled with debug_info (#{operation})" - %ErlangError{original: {:type_or_record_not_found, ^type_ref}} -> + {:type_or_record_not_found, ^type_ref} -> raise ArgumentError, "type #{inspect(type_ref)} not found in module #{inspect(module)} (#{operation})" - %ErlangError{original: {:type_not_found, type_name, _arity}} when type_name == type_ref -> + {:type_not_found, type_name, _arity} when type_name == type_ref -> raise ArgumentError, "type #{inspect(type_ref)} not found in module #{inspect(module)} (#{operation})" - %ErlangError{original: {:type_not_supported, type_info}} -> + {:type_not_supported, type_info} -> raise ArgumentError, "type not supported: #{inspect(type_info)} (#{operation})" _other -> - # Not a known configuration error — this covers both unrecognized - # %ErlangError{} originals and every other exception Elixir normalizes - # a raw BEAM error into (e.g. %BadMapError{}, %BadStructError{}), since - # `rescue error in ErlangError` binds those here too. Re-raise as-is - # with the original stacktrace instead of losing where it happened. + # Not a known configuration error — re-raise as-is with the original + # stacktrace instead of losing where it happened. reraise error, stacktrace end end + + # `rescue error in ErlangError` also binds every other exception Elixir + # normalizes a raw BEAM error into (e.g. %BadMapError{}, %BadStructError{}), + # not just literal %ErlangError{} structs. Re-raise those as-is too, rather + # than falling through to a FunctionClauseError in this module. + defp handle_erlang_error(error, stacktrace, _operation, _module, _type_ref) do + reraise error, stacktrace + end end From e9b9cccfc0cffcbbea51d256f03623f5613780e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:39:34 +0000 Subject: [PATCH 3/4] Add schema-path and unrecognized-ErlangError crash coverage Addresses Copilot review feedback: the existing regression tests only covered encode/decode, and only the non-%ErlangError{} fallback clause (via %KeyError{}). Neither schema/2 nor schema/4 were exercised, and the other fallback -- an %ErlangError{} whose original doesn't match any of the four known configuration errors -- was untested, so the raise-to-reraise fix for that branch had no coverage. CrashingCodec gains a schema/5 clause for `t` (same %KeyError{} crash as encode/decode) and a new `t2` type whose encode/decode raise a raw BEAM error Elixir has no specific exception for, producing a genuine %ErlangError{} with an unrecognized original. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WK6VcSwrRa5XLKUrEhEi2T --- test/spectral_unexpected_crash_test.exs | 36 +++++++++++++++++++++++++ test/support/crashing_codec.ex | 25 ++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/test/spectral_unexpected_crash_test.exs b/test/spectral_unexpected_crash_test.exs index e72fe48..9cb9ac3 100644 --- a/test/spectral_unexpected_crash_test.exs +++ b/test/spectral_unexpected_crash_test.exs @@ -39,4 +39,40 @@ defmodule SpectralUnexpectedCrashTest do assert %KeyError{key: :missing_key} = error end + + test "schema/2 reraises an unexpected crash unchanged, not as a FunctionClauseError" do + error = + assert_raise KeyError, fn -> + Spectral.schema(CrashingCodec, :t) + end + + assert %KeyError{key: :missing_key} = error + end + + test "schema/4 reraises an unexpected crash unchanged, not as a FunctionClauseError" do + error = + assert_raise KeyError, fn -> + Spectral.schema(CrashingCodec, :t, :json_schema, []) + end + + assert %KeyError{key: :missing_key} = error + end + + test "an ErlangError with an unrecognized original is reraised unchanged, with its original stacktrace" do + error = + assert_raise ErlangError, fn -> + Spectral.encode("data", CrashingCodec, :t2) + end + + assert %ErlangError{original: {:unexpected_codec_failure, :detail}} = error + + stacktrace = + try do + Spectral.encode("data", CrashingCodec, :t2) + rescue + _ -> __STACKTRACE__ + end + + assert Enum.any?(stacktrace, fn {mod, _fun, _arity, _location} -> mod == CrashingCodec end) + end end diff --git a/test/support/crashing_codec.ex b/test/support/crashing_codec.ex index 97421a9..45259ce 100644 --- a/test/support/crashing_codec.ex +++ b/test/support/crashing_codec.ex @@ -1,20 +1,32 @@ defmodule CrashingCodec do @moduledoc """ - A codec whose callbacks crash with a normal Elixir exception instead of - returning `:continue` or `{:error, _}`. Used to verify that an unexpected - crash from inside spectra surfaces to the caller unchanged, rather than - being turned into a misleading error about Spectral itself. + A codec whose callbacks crash instead of returning `:continue` or + `{:error, _}`. Used to verify that an unexpected crash from inside spectra + surfaces to the caller unchanged, rather than being turned into a + misleading error about Spectral itself. + + `t` crashes with a normal Elixir exception (`%KeyError{}`) to exercise the + fallback for exceptions that are not `%ErlangError{}` at all. `t2` crashes + with a raw BEAM error Elixir has no specific exception for, producing a + genuine `%ErlangError{}` with an unrecognized `original`, to exercise the + other fallback (an `%ErlangError{}` that isn't one of the known + configuration errors). """ use Spectral.Codec @type t :: String.t() + @type t2 :: String.t() @impl Spectral.Codec def encode(_format, _caller_type_info, {:type, :t, 0}, _target_type, _data, _config) do Map.fetch!(Map.new(), :missing_key) end + def encode(_format, _caller_type_info, {:type, :t2, 0}, _target_type, _data, _config) do + :erlang.error({:unexpected_codec_failure, :detail}) + end + def encode(_format, _caller_type_info, _type_ref, _target_type, _data, _config), do: :continue @impl Spectral.Codec @@ -23,4 +35,9 @@ defmodule CrashingCodec do end def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue + + @impl Spectral.Codec + def schema(_format, _caller_type_info, {:type, :t, 0}, _target_type, _config) do + Map.fetch!(Map.new(), :missing_key) + end end From 679a45dc73d86cbeb428dab8d96ee25e96a2a7ea Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 09:48:35 +0000 Subject: [PATCH 4/4] Bump CI matrix to Elixir 1.20 stable on OTP 29 1.20.0-rc.4/28.3 was a pre-release pin from before 1.20 shipped. Elixir 1.20's changelog states it requires OTP 27+ and is compatible with OTP 29, so bump both. Also rewrites CrashingCodec's KeyError crash as an explicit `raise` instead of `Map.fetch!(Map.new(), :missing_key)`: Elixir 1.20's type system now statically proves that call always raises and reports it as a type warning, which fails `MIX_ENV=test mix compile --warnings-as-errors` on the new matrix entry. Verified the full suite, credo, dialyzer, ex_dna, and format-check pass on both Elixir 1.20.4/OTP 29.0.6 and the existing Elixir 1.17/OTP 27 baseline. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WK6VcSwrRa5XLKUrEhEi2T --- .github/workflows/ci.yml | 4 ++-- test/support/crashing_codec.ex | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bf0982..9e93a65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,8 @@ jobs: otp: "27.3" - elixir: "1.19" otp: "28.3" - - elixir: "1.20.0-rc.4" - otp: "28.3" + - elixir: "1.20" + otp: "29.0" steps: - name: Checkout code diff --git a/test/support/crashing_codec.ex b/test/support/crashing_codec.ex index 45259ce..a12fcc5 100644 --- a/test/support/crashing_codec.ex +++ b/test/support/crashing_codec.ex @@ -20,7 +20,7 @@ defmodule CrashingCodec do @impl Spectral.Codec def encode(_format, _caller_type_info, {:type, :t, 0}, _target_type, _data, _config) do - Map.fetch!(Map.new(), :missing_key) + raise KeyError, key: :missing_key, term: %{} end def encode(_format, _caller_type_info, {:type, :t2, 0}, _target_type, _data, _config) do @@ -31,13 +31,13 @@ defmodule CrashingCodec do @impl Spectral.Codec def decode(_format, _caller_type_info, {:type, :t, 0}, _target_type, _input, _config) do - Map.fetch!(Map.new(), :missing_key) + raise KeyError, key: :missing_key, term: %{} end def decode(_format, _caller_type_info, _type_ref, _target_type, _input, _config), do: :continue @impl Spectral.Codec def schema(_format, _caller_type_info, {:type, :t, 0}, _target_type, _config) do - Map.fetch!(Map.new(), :missing_key) + raise KeyError, key: :missing_key, term: %{} end end