diff --git a/Readme.adoc b/Readme.adoc index 4f0c3e0..6ed5e8a 100644 --- a/Readme.adoc +++ b/Readme.adoc @@ -92,6 +92,24 @@ end EchoService.use LoggerMiddleware ---- +== Request/Reply Failure Logging + +Request/reply endpoints log failed handler results at error level by default. +To change only that decision while keeping Leopard's existing callbacks and +error responses, configure a policy that accepts the failure payload: + +[source,ruby] +---- +EchoService.config.request_reply_failure_log_policy = lambda do |failure| + next if failure.status.between?(400, 499) + + EchoService.logger.error 'Error processing message: ', failure +end +---- + +The policy is called before Leopard responds with the failure payload. It must +not change the response: every failure still uses `respond_with_error`. + == JetStream Pull Consumers Leopard can also bind JetStream pull consumers through the same middleware and `Dry::Monads::Result` diff --git a/documents/enable-injectable-request-reply-callbacks.adoc b/documents/enable-injectable-request-reply-callbacks.adoc new file mode 100644 index 0000000..f0cd651 --- /dev/null +++ b/documents/enable-injectable-request-reply-callbacks.adoc @@ -0,0 +1,52 @@ += Enable Injectable Request/Reply Failure Logging + +== Goal + +Allow a service to change failure logging without replacing Leopard's +request/reply callbacks. Applications can treat expected client failures, such +as failures with particular statuses, differently from operational failures +while preserving the existing response behavior. + +== Current behavior + +Every `Dry::Monads::Failure` is currently logged at error level before its +payload is returned through `MessageWrapper#respond_with_error`. An application +cannot change that logging policy without overriding Leopard internals. + +== Plan + +. Add a `:request_reply_failure_log_policy` service setting. When present, the + policy receives each failure payload before Leopard returns it to the client. +. Keep `NatsRequestReplyCallbacks` as the callback owner. Its default policy + logs at error level, preserving current behavior when no policy is configured. +. Pass the configured policy to `NatsRequestReplyCallbacks` when the worker + constructs its memoized callback helper. +. Document that policies must leave `respond_with_error` behavior unchanged. + +== Suggested application policy + +Configure a policy directly. Leopard owns the callback mapping and invokes the +policy only for failed handler results. + +[source,ruby] +---- +MyService.config.request_reply_failure_log_policy = lambda do |failure| + next if failure.status.between?(400, 499) + + MyService.logger.error 'Error processing message: ', failure +end +---- + +Use the status accessor provided by the application's failure type (or another +application-specific classifier). The policy must not change the response: +Leopard still calls `wrapper.respond_with_error(failure)` for every failure. + +== Acceptance criteria + +* Existing request/reply services log and respond exactly as before without + configuration. +* A service can configure failure logging without monkey-patching, inheriting + from, or replacing Leopard's callbacks. +* A configured policy can select a log level, or no log entry, for any failure + status while returning the same error payload to the requester. +* Successes and exceptions continue to follow their existing callback paths. diff --git a/lib/leopard/nats_api_server.rb b/lib/leopard/nats_api_server.rb index 69a74f3..33c6ed7 100644 --- a/lib/leopard/nats_api_server.rb +++ b/lib/leopard/nats_api_server.rb @@ -31,6 +31,7 @@ def self.included(base) base.extend(Dry::Monads[:result]) base.extend(Dry::Configurable) base.setting :logger, default: Rubyists::Leopard.logger, reader: true + base.setting :request_reply_failure_log_policy, default: nil, reader: true end # Configuration for a request/reply endpoint declared with {.endpoint}. @@ -472,7 +473,10 @@ def process_transport_message(raw_msg, handler, callbacks) # # @return [NatsRequestReplyCallbacks] The request/reply callback helper. def request_reply_callbacks - @request_reply_callbacks ||= NatsRequestReplyCallbacks.new(logger:) + @request_reply_callbacks ||= NatsRequestReplyCallbacks.new( + logger:, + failure_log_policy: self.class.request_reply_failure_log_policy, + ) end # Returns the memoized message processor for this worker instance. diff --git a/lib/leopard/nats_request_reply_callbacks.rb b/lib/leopard/nats_request_reply_callbacks.rb index 8c9ffd2..20fb73d 100644 --- a/lib/leopard/nats_request_reply_callbacks.rb +++ b/lib/leopard/nats_request_reply_callbacks.rb @@ -6,11 +6,15 @@ module Leopard class NatsRequestReplyCallbacks # Builds a callback set for request/reply endpoint outcomes. # - # @param logger [#error] Logger used for failure payloads. + # @param logger [#error] Logger used by the default failure log policy. + # @param failure_log_policy [#call, nil] Optional policy called with a + # failure payload before it is returned to the requester. # # @return [void] - def initialize(logger:) - @logger = logger + def initialize(logger:, failure_log_policy: nil) + @failure_log_policy = failure_log_policy || lambda do |failure| + logger.error 'Error processing message: ', failure + end end # Returns transport callbacks for request/reply endpoints. @@ -43,7 +47,7 @@ def respond_with_success(wrapper, result) # # @return [void] def respond_with_failure(wrapper, result) - log_failure(result.failure) + @failure_log_policy.call(result.failure) wrapper.respond_with_error(result.failure) end @@ -56,15 +60,6 @@ def respond_with_failure(wrapper, result) def respond_with_error(wrapper, error) wrapper.respond_with_error(error) end - - # Logs the failure payload returned by a handler. - # - # @param failure [Object] The failure payload from the handler. - # - # @return [void] - def log_failure(failure) - @logger.error 'Error processing message: ', failure - end end end end diff --git a/test/lib/nats_api_server.rb b/test/lib/nats_api_server.rb index b737905..180dca9 100755 --- a/test/lib/nats_api_server.rb +++ b/test/lib/nats_api_server.rb @@ -204,6 +204,22 @@ def call(wrapper) assert_equal err, received end + it 'injects the configured failure log policy into request/reply callbacks' do + policy = Minitest::Mock.new + wrapper = Minitest::Mock.new + @klass.config.request_reply_failure_log_policy = policy + policy.expect(:call, nil, ['fail']) + wrapper.expect(:respond_with_error, nil, ['fail']) + + @instance.send(:request_reply_callbacks).callbacks[:on_failure].call( + wrapper, + Rubyists::Leopard::NatsApiServer::Failure.new('fail'), + ) + + policy.verify + wrapper.verify + end + def processor_for(wrapper:, result:) Rubyists::Leopard::MessageProcessor.new( wrapper_factory: ->(*) { wrapper }, diff --git a/test/lib/nats_request_reply_callbacks_test.rb b/test/lib/nats_request_reply_callbacks_test.rb index 1ceb72f..c92c515 100644 --- a/test/lib/nats_request_reply_callbacks_test.rb +++ b/test/lib/nats_request_reply_callbacks_test.rb @@ -30,6 +30,19 @@ def test_failure_logs_and_responds_with_error @logger.verify end + def test_failure_uses_injected_log_policy_and_responds_with_error + policy = Minitest::Mock.new + wrapper = Minitest::Mock.new + policy.expect(:call, nil, ['fail']) + wrapper.expect(:respond_with_error, nil, ['fail']) + + callbacks = Rubyists::Leopard::NatsRequestReplyCallbacks.new(logger: @logger, failure_log_policy: policy).callbacks + callbacks[:on_failure].call(wrapper, Dry::Monads::Result::Failure.new('fail')) + + policy.verify + wrapper.verify + end + def test_error_responds_with_error error = RuntimeError.new('boom') wrapper = Minitest::Mock.new