Rynaro / fluxus

Fluxus is a simple, dependencyless, and extensible use-case wrapper for your Ruby code.
MIT License
9 stars 0 forks source link

Silent Error Handling in Fluxus::Safe::Caller Reduces Error Visibility #14

Open ViniMoraes opened 3 weeks ago

ViniMoraes commented 3 weeks ago

The Fluxus::Safe::Caller class handles errors by silencing them, which can lead to a lack of visibility into underlying issues. This behavior can make debugging difficult as errors are not logged or raised, potentially leading to unexpected behavior without a clear indication of the cause.

Proposed Solution:

Impact:

Improving error visibility will help developers identify and fix issues more efficiently, leading to more robust and maintainable code.

Rynaro commented 3 weeks ago

Fluxus::Safe::Caller is designed to bring safety to the runtime. All errors are self-contained in the Fluxus::Results::Result, and take the common aspect of a Failure. The Fluxus::Safe is also optant of #on_exception specific chainable method. This method makes the code more explicit about what to do after an expected error. For example, during a payment flow, you can have a specific error type for gateway failures.

PayWithCreditCard.
  call(credit_card: my_cc_object, payment_gateway_wrapper: my_wrapper)
  on_success { |result| invoke_checkout_success(result) }.
  on_exception(MyGateway::InsuficientFunds) { |result| invoke_checkout_failure(result) }
  on_exception(MyGateway::FraudulentOperation) { |result| invoke_operational_fraud_and_report(result) }.
  on_failure { |result| invoke_unexpected_failure(result) }

Not-mapped errors will always invoke failures, and guarantee the Ruby runtime continuity by taking wrapping the error in a "sandboxed" object. If you do not properly handle the Safe object, silent failures will likely happen in your code.

If need to use the "let it crash" approach I strongly recommend you use the common Fluxus::Caller.

ViniMoraes commented 3 weeks ago

Thank you for the detailed explanation. I understand the design philosophy behind Fluxus::Safe::Caller and its focus on safety by encapsulating errors within a Failure result. However, my concern is with the visibility of these errors during runtime. While the exceptions don’t need to interrupt the flow, having at least an optional log would greatly aid in debugging and monitoring. This log could help identify underlying issues without compromising the flow control that Safe::Caller provides.

A suggestion would be to add a configurable logger in Safe::Caller. Something like this:

module Fluxus
  module Safe
    class Caller < Runner
      class << self
        attr_writer :logger

        def logger
          @logger ||= Logger.new($stdout)
        end
      end
      def self.call!(...)
        instance = new
        __call__(instance, ...)
      rescue StandardError => e
        raise e if e.is_a?(ResultTypeNotDefinedError)

        logger&.error("Exception caught in #{name}: #{e.message}")
        instance.Failure(type: :exception, result: { exception: e })
      end
    end
  end
end

this add the possibility to configure a logger in Rails for example:

# config/initializers/fluxus_logger.rb

Fluxus::Safe::Caller.logger = Rails.logger
Rynaro commented 3 weeks ago

@ViniMoraes I see your concerns, and I'm open to evaluating a pull request implementing the proposed idea! I have a few suggestions before you, or anyone else start implementing it.

Following those suggestions we are good to have it.

clsechi commented 3 weeks ago

Another proposal based on Sidekiq implementation of death_handlers, is receive a lambda on the Fluxus configuration that will be called every time an exception happens. The logic of what is going to be done when a exception happens will be kept outside of Fluxus, as the user can do more than just logging the message.

# config/initializers/fluxus.rb

Fluxus::Safe::Caller.exception_handler = ->(exception) { ... }
module Fluxus
  module Safe
    class Caller < Runner
      class << self
        attr_writer :logger

        def logger
          @logger ||= Logger.new($stdout)
        end
      end
      def self.call!(...)
        instance = new
        __call__(instance, ...)
      rescue StandardError => e
        raise e if e.is_a?(ResultTypeNotDefinedError)

        exception_handler.call(e) unless exception_handler.nil?
        instance.Failure(type: :exception, result: { exception: e })
      end
    end
  end
end