Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 22 additions & 7 deletions lib/spectral.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 """
Expand Down Expand Up @@ -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 """
Expand All @@ -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)
Comment thread
andreashasse marked this conversation as resolved.
end

@doc """
Expand Down Expand Up @@ -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 """
Expand Down Expand Up @@ -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,
Expand All @@ -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
Comment thread
andreashasse marked this conversation as resolved.
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
78 changes: 78 additions & 0 deletions test/spectral_unexpected_crash_test.exs
Original file line number Diff line number Diff line change
@@ -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
43 changes: 43 additions & 0 deletions test/support/crashing_codec.ex
Original file line number Diff line number Diff line change
@@ -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
Loading