How to prevent Ash Reactor from wrapping custom error tuples in Ash.Error.Unknown?

I am using an Ash Reactor as the implementation for a Resource Action (using run MyReactor). I want to return a simple error atom or a specific tuple from a Reactor step (e.g., {:error, :invalid_token}) and have that atom be easily accessible in my controller or LiveView.

When a step in the Reactor returns an error tuple, Ash wraps the result in a nested %Ash.Error.Unknown struct. Instead of being able to pattern match on the atom I provided, my error value is converted into a string and buried inside a RunStepError message.

Here is a simplified version of my Reactor step:

# Inside an Ash.Reactor module
step :verify_token do
  argument :token_record, result(:get_token)

  run fn %{token_record: token}, _ ->
    if is_nil(token) do
      # I want to return this cleanly
      {:error, :invalid_token}
    else
      {:ok, token}
    end
  end
end

When I call the action via the Domain Code Interface, I receive error in this format:

{:error,
 %Ash.Error.Unknown{
   errors: [
     %Ash.Error.Unknown.UnknownError{
       error: "** (Reactor.Error.Invalid) \nInvalid Error\n\n* # Run Step Error\n\n ... ## `error`:\n\n`:invalid_token` ..."
     }
   ]
 }}

  1. Is there a way to return an error from a Reactor step that Ash will recognize as a first-class error (like Ash.Error.Invalid) rather than an Unknown error?

  2. What is the recommended pattern for identifying specific “business logic” failures from a Reactor without parsing the error string or digging through the nested Reactor.Error.Invalid structs?

You would generally create your own error types with a class. Here is one from an app of mine for example:

defmodule A.Custom.Error do
  @moduledoc """
  Description of the error 
  """

  use Splode.Error,
    fields: [:status, :message, :customer_id, :source, :contact_id],
    class: :invalid

  def message(%{
        status: status,
        message: message,
        customer_id: customer_id,
        source: source,
        contact_id: contact_id
      }) do
    """
    A custom error ...
    """
  end
end

I tried with a custom error but I’m still getting error wrapped inside text.

I also tried raising exception but it didn’t help neither.

Custom error:

defmodule Sana.Auth.Errors.InvalidToken do
  use Splode.Error,
    fields: [:token_type, :message],
    class: :invalid
end

Reactor step:

  ash_step :verify_magic_link do
    argument :link, result(:get_magic_link)

    run fn %{link: link}, _ ->
      if is_nil(link),
        do:
          {:error,
           Sana.Auth.Errors.InvalidToken.exception(
             token_type: :magic_token,
             message: "Invalid magic link!"
           )},
        else: {:ok, link}
    end
  end

Error message:

{:error,
 %Ash.Error.Unknown{
   errors: [
     %Ash.Error.Unknown.UnknownError{
       error: "** (Reactor.Error.Invalid) \nInvalid Error\n\n* # Run Step Error\n\nAn error occurred while attempting to run the `:verify_magic_link` step.\n\n## `step`:\n\n%Reactor.Step{arguments: [%Reactor.Argument{description: nil, name: :link, source: %Reactor.Template.Result{name: :get_magic_link, sub_path: []}, transform: nil}], async?: true, context: %{}, description: nil, impl: {Ash.Reactor.AshStep, [run: &Sana.Auth.Reactors.MagicLinkSignIn.run_0_generated_6265D28DB1F680C00FF286873F1C8619/2, compensate: nil, undo: nil, impl: nil]}, name: :verify_magic_link, max_retries: :infinity, ref: :verify_magic_link, transform: nil, guards: []}\n\n## `error`:\n\nInvalid magic link!\n\n  (reactor 0.17.0) lib/reactor/error/invalid/run_step_error.ex:8: Reactor.Error.Invalid.RunStepError.exception/1\n  (reactor 0.17.0) lib/reactor/executor/step_runner.ex:212: Reactor.Executor.StepRunner.handle_run_result/5\n  (reactor 0.17.0) lib/reactor/executor/step_runner.ex:165: Reactor.Executor.StepRunner.do_run/4\n  (reactor 0.17.0) lib/reactor/executor/step_runner.ex:53: Reactor.Executor.StepRunner.run/4\n  (reactor 0.17.0) lib/reactor/executor/step_runner.ex:74: Reactor.Executor.StepRunner.run_async/5\n  (elixir 1.19.3) lib/task/supervised.ex:105: Task.Supervised.invoke_mfa/2\n  (elixir 1.19.3) lib/task/supervised.ex:40: Task.Supervised.reply/4",
       field: nil,
       value: nil,
       splode: Ash.Error,
       bread_crumbs: [],
       vars: [],
       path: [],
       stacktrace: #Splode.Stacktrace<>,
       class: :unknown
     }
   ]
 }}

Hm… this looks like a bug. The reactor runner should probably extract errors. Please open an issue on ash

Here it is: Ash Reactor is wrapping error tuples in Ash.Error.Unknown.UnknownError · Issue #2506 · ash-project/ash · GitHub