[dry-transaction] Handling failures in chained transactions

Does anybody have any patterns for trying two or more similar transactions? I want to return Success from first transaction that succeeds, but should the all fail, I want to know which step failed.

Our use-case is that we perform authentication via JWT bearer tokens, primarily through Auth0, but we’ve recently had to add support for our own. Both use the same style JWT, but need to be verified using different keys.

We need to return a different response based on how the JWT failed:

  • Missing or bogus JWT -> 401 Not Authorized
  • Valid JWT, but no or inactive user -> 403 Forbidden
  • Valid user -> Success

That is, a 401 is “I don’t know who you are”, and 403 is “I know who you are, but you’re not allowed”.

Previously when we had only a single Auth0 Authenticate transaction, we had distinct steps for “decode+verify JWT” and “find the user”. Then, the controller could use the monad block matcher to trigger different status code responses based on the step that failed.

Now that we’ve added another flavor of JWT with different configs, I created another transaction that works just like the original, and joined them together with #or. The nature of transactions, though, means if they both fail, the failure that gets returned from the outer transaction has discarded the step that failed in the inner.

Here’s the code that shows the intent, but doesn’t work because the on.failure(:verify_jwt_token) never matches.

class Authentication::DecodeToken

step :decode_bearer_token

def decode_bearer_token(payload)
  decode_auth0 = Authentication::DecodeAuth0Jwt.new(auth0: @auth0)
  decode_api_token = Authentication::DecodeApiTokenJwt.new(config: bar)

  decode_auth0.call(payload).or { decode_api_token.call(payload) }
end

class Authentication::DecodeAuth0Jwt
  
  def initialize(auth0_config:, **)
    @auth0_config = auth0_config
    super
  end

  step :verify_jwt_token
  step :validate_payload
  map :user_for_token

end

class ApiController

  def current_user
    @current_user ||= authenticate
  end

  def authenticate
    authenticate_with_http_token do |token, options|
      Authenticate
        .new(auth0_config: Rails.config.auth0) 
        .call(token: token, **options) do |on|
          on.failure(:verify_jwt_token) { raise Forbidden }
          on.failure                    { raise NotAuthenticated }
          on.success                    { |user| user }
        end
      end
    end
  end
end

The not-great solution I’ve come up with is to handle the Failure inside the or block, return a different failure, and then add a case statement inside the failure branch in the controller.

# decode_bearer_token
decode_auth0.call(payload).or do 
  decode_api_token.call(payload) do |on|
    on.failure(:verify_jwt_token) { Failure(:invalid_token) }
    on.failure                    { |err| Failure(err) }
    on.success                    { |value| value }
  end
end

# controller
.call(token: token, **options) do |on|
  on.success { |user| user }
  on.failure do |error|
    case error
    when :invalid_token then raise Forbidden
    else
      raise NotAuthenticated
    end
  end
end

I’m not thrilled with this approach, I don’t really like having to handle the inner transactions Failure just to wrap it in a different failure, and then using a case to decide what kind of failure it was. Having the StepFailure exposed to the do block, instead of the Failure it wraps (as I mentioned in Ideas awhile back) would probably help with this a lot.

Hi @paul! Yep, this is definitely not a nice thing to have to deal with. Ideally dry-transaction should help with this. I’d love for this to be the case — would you like to have a go at addressing the issue in a PR?