Uncaught throw when error is returned from nested action call

I have a nested set of action calls, like this:

Action A → Action B

When the change function of Action B adds an error to its changeset, it somehow results in an uncaught throw in the call to Action B. This means that Action A can’t act on the return value from Action B when Action B returns an error.

Here’s approximately what the Action A change function might look like:

defmodule ResourceA.ActionA do
  def change(changeset, _context) do
    Ash.Changeset.before_action(changeset, fn changeset ->
      result = ResourceB.action_b()
        
      # If action_b() adds an error to its changeset,
      # the result can't be acted upon here, because there is a throw generated internally.
        
      changeset
    end)
  end
end

Here is an example repo that demonstrates the issue: hairbnb2/test/hairbnb/cart_test.exs at main · moxley/hairbnb2 · GitHub

I’ve tried using before_action, after_action, and manual, and all three techniques have the same issue.

Yeah, so any errors that happen inside of action hooks will incur a rollback (the throwing behavior you’re describing), and the primary reason for this is that this is actually a situation that can be forced by some of the errors that can come back from a data layer. So for example, if you violate a unique constraint in the db, the transaction has to be aborted (and so you’d want an after_transaction hook in place on the resource that started the transaction to handle/correct that.

If you are aware that an action you’re calling may fail in a hook, you can use the rollback_on_error?: false option when calling the action.


With all of that said, an argument could be made that that is not the best design/doesn’t follow the principle of least surprise. We could introduce a backwards compatibility config that will modify the default of that option to false instead relatively easily, which would make it such that only specific errors going wrong would rollback the transaction.

1 Like

I didn’t know about rollback_on_error?: false. Here’s the documentation, if anyone’s curious.

The final problem I’m encountering is that when Action B fails, it has an after_transaction callback that writes the error to the database. When calling Action B directly, this works as expected. If instead I call Action A, which calls Action B, then the error written to the database gets rolled back, because the after_transaction is itself inside an Action A’s transaction, which gets rolled back (this is typical SQL behavior– not just Ash). I can pass rollback_on_error?: false in Action A’s call to Action B, but other unexpected things happen: records that should get rolled back in Action B no longer get rolled back.

I think that primarily because unexpected things will happen otherwise, the rollback_on_error?: true default is the right choice.

The fact that my application writes an error to the database may seem strange, but it’s because the execution flow is very asynchronous (User synchronous interaction with the API + Stripe WebHook callback + User WebSocket listening for updates based on the WebHook handling). The solution might be to delegate the error write to a separate process, which will be outside of the transaction of Action A and Action B.

Yeah, so whenever you have something like this:

Action A (transaction?: true)
  Action B (after_transaction hook)

it typically represents something you want to rethink. Most often you’d do that by handling the error in an after_transaction hook on the calling action.

Actually, this still isn’t working.

It’s fine that all the nested SQL transactions get rolled back when Action B returns an error. The problem is that I need the code execution to continue without a throw– just as if I were calling Action B directly. Simply nesting an action call inside another shouldn’t change the behavior of the error case. Should I attempt to catch the throw, so that execution can continue?

The problem is that you are in an invalid transaction. If you try to do another database operation in that rolled back transaction it will fail. So when something rolls back the transaction, it goes all the way back up to the place that started the transaction, and only that error logic gets a chance to run.

Understood. I’m not trying to do another database operation– not anymore. But what I want is the behavior of Action B to be the same whether it’s called directly or called from inside the transaction of Action A. Action B needs to handle errors it encounters, and it can’t do that if there is a throw coming from somewhere inside a call it’s making– a throw that only started happening when Action B was wrapped in Action A’s transaction.

What seems to work for now is to add a try+catch around the call that Action B is making. If Action B is wrapped in Action A’s transaction, the the catch will execute, and it can handle the error. If Action B is called directly, it handle the error in a more traditional way.

Here’s the approximate solution in Action B’s change module:

defp after_action_upsert_registration(changeset, cart, member) do
  Ash.Changeset.after_action(changeset, fn changeset, order ->
    try do
      case upsert_registration(cart, member, payment_method, transaction) do
        {:ok, _registration} ->
          {:ok, order}

        {:error, changeset} ->
          broadcast_error({:error, changeset}, cart.id)
          {:error, changeset}
      end
    catch
      error ->
        case error do
          {_module, _ref, %Ash.Changeset{} = changeset} ->
            broadcast_error({:error, changeset}, cart.id)

          _error ->
            nil
        end

        throw(error)
    end
  end)
end

defp broadcast_error({:error, changeset}, cart_id) do
  error_message = extract_error_message(changeset)

  Phoenix.PubSub.broadcast!(
    MyApp.PubSub,
    "cart_internal:#{cart_id}",
    {:order_failed_from_cart, error_message}
  )
end

I get what you’re saying, the fundamental issue here is that Ash doesn’t know what the wrapping transaction is going to try to do after the error occurs. The fundamental issue here is around how after_transaction hooks work. If you didn’t start the transaction, then your after_transactions won’t be the one to compensate for errors that your action returns from within the scope of what would be its own transaction. Remember that there is no such thing as a nested transaction (there are savepoints but that’s a separate conversation).

But this is the point of the rollback_on_error? option. You should be able to specify rollback_on_error?: false and then handle whatever error occurs from your action, assuming the error was not an error directly from the data layer (in which case we always roll back).