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/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..f243c9b 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,7 +673,13 @@ 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 + 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, @@ -692,8 +698,17 @@ defmodule Spectral do "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 — 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 diff --git a/test/spectral_unexpected_crash_test.exs b/test/spectral_unexpected_crash_test.exs new file mode 100644 index 0000000..9cb9ac3 --- /dev/null +++ b/test/spectral_unexpected_crash_test.exs @@ -0,0 +1,78 @@ +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 + + 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 new file mode 100644 index 0000000..a12fcc5 --- /dev/null +++ b/test/support/crashing_codec.ex @@ -0,0 +1,43 @@ +defmodule CrashingCodec do + @moduledoc """ + 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 + raise KeyError, key: :missing_key, term: %{} + 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 + def decode(_format, _caller_type_info, {:type, :t, 0}, _target_type, _input, _config) do + 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 + raise KeyError, key: :missing_key, term: %{} + end +end