From 93e9b4e46b45c9399ae5213e7ebb0f600a23301d Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Mon, 14 Sep 2026 14:36:37 -0400 Subject: [PATCH 1/3] docs: plan injectable request reply callbacks --- ...le-injectable-request-reply-callbacks.adoc | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 documents/enable-injectable-request-reply-callbacks.adoc diff --git a/documents/enable-injectable-request-reply-callbacks.adoc b/documents/enable-injectable-request-reply-callbacks.adoc new file mode 100644 index 0000000..b46fd26 --- /dev/null +++ b/documents/enable-injectable-request-reply-callbacks.adoc @@ -0,0 +1,111 @@ += Enable Injectable Request/Reply Callbacks + +== Goal + +Allow a service to resolve its request/reply callback policy without changing +Leopard internals. The resolved object is an ordinary collaborator, not a +Leopard subclass. This lets applications treat expected client failures, such +as failures with particular statuses, differently from operational failures +while preserving the existing response behavior. + +== Current behavior + +`NatsApiServer::MessageHandling#request_reply_callbacks` constructs +`NatsRequestReplyCallbacks.new(logger:)` directly. The callback class is +therefore not configurable, despite being a separate collaborator. + +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 small `RequestReplyCallbackResolution` module to the request/reply + message-handling path. It resolves the callback object through the service + configuration and memoizes the resolved object for the worker lifetime. +. Add a `:request_reply_callback_resolver` setting when `NatsApiServer` is + included. Its default is a callable that builds + `NatsRequestReplyCallbacks.new(logger:)`, so existing services retain their + current behavior. +. Change `MessageHandling#request_reply_callbacks` to call the configured + resolver, rather than naming or constructing `NatsRequestReplyCallbacks` + directly. +. Document the resolver contract: it accepts `logger:` and returns any object + responding to `#callbacks`. The returned hash uses the existing + `:on_success`, `:on_failure`, and `:on_error` callbacks. No superclass, + mix-in, or Leopard-specific type is required. +. Add a unit test proving the default resolver is used, and a focused + `NatsApiServer` test that configures a test resolver and verifies it receives + the service logger and supplies the callbacks passed to message processing. +. Add tests for a composed callback object and a failure-logging policy object, + proving an application can replace logging without inheriting from or mixing + into a Leopard class. +. Add a README example showing the composed callback object. It delegates its + logging decision to a policy object and leaves `respond_with_error` intact. + The policy receives the complete failure and may select behavior for any + application status or other failure classification. The example must make + the application's failure/status interface explicit rather than assume a + particular error type. + +== Suggested application policy + +Implement the custom policy as a dependency of an application-owned callback +object, not in middleware. Middleware wraps handler execution; callback code +owns the mapping from handler outcomes to transport responses. + +[source,ruby] +---- +class FailureLogPolicy + def initialize(logger:, level_for:) + @logger = logger + @level_for = level_for + end + + def call(failure) + level = @level_for.call(failure) + @logger.public_send(level, 'Error processing message: ', failure) if level + end +end + +class ApiCallbacks + def initialize(logger:, failure_log_policy:) + @failure_log_policy = failure_log_policy + end + + def callbacks + { + on_success: ->(wrapper, result) { wrapper.respond(result.value!) }, + on_failure: ->(wrapper, result) do + @failure_log_policy.call(result.failure) + wrapper.respond_with_error(result.failure) + end, + on_error: ->(wrapper, error) { wrapper.respond_with_error(error) }, + } + end +end + +MyService.config.request_reply_callback_resolver = lambda do |logger:| + level_for = ->(failure) { failure.status >= 500 ? :error : nil } + policy = FailureLogPolicy.new(logger:, level_for:) + ApiCallbacks.new(logger:, failure_log_policy: policy) +end +---- + +The final implementation should use the application's real status accessor +(or another application-specific classifier). It must still call +`wrapper.respond_with_error(failure)` for every failure: changing alerting must +not change the client response. + +== Acceptance criteria + +* Existing request/reply services log and respond exactly as before without + configuration. +* A service can configure a callback resolver without monkey-patching, + inheriting from, or overriding a private Leopard method. +* The resolver can return an application-owned callback object with composed + dependencies; it does not need to inherit from or mix in Leopard code. +* A configured callback implementation can select a log level, or no log + entry, for any failure status while returning the same error payload to the + requester. +* Exceptions continue to follow the existing error path unless the custom + callback policy deliberately changes them. From afaa5b20ddd3c89f1939f3b0af6342f7b01816f0 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 15 Sep 2026 11:44:28 -0400 Subject: [PATCH 2/3] feat: add injectable request/reply failure log policy Allow services to customize or suppress failure logging NOTE: We're preserving the default error-level log and existing error response behavior, for now. We can make that injectable later. --- Readme.adoc | 18 +++ ...le-injectable-request-reply-callbacks.adoc | 105 ++++-------------- lib/leopard/nats_api_server.rb | 6 +- lib/leopard/nats_request_reply_callbacks.rb | 19 ++-- test/lib/nats_api_server.rb | 16 +++ test/lib/nats_request_reply_callbacks_test.rb | 16 +++ 6 files changed, 86 insertions(+), 94 deletions(-) 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 index b46fd26..f0cd651 100644 --- a/documents/enable-injectable-request-reply-callbacks.adoc +++ b/documents/enable-injectable-request-reply-callbacks.adoc @@ -1,111 +1,52 @@ -= Enable Injectable Request/Reply Callbacks += Enable Injectable Request/Reply Failure Logging == Goal -Allow a service to resolve its request/reply callback policy without changing -Leopard internals. The resolved object is an ordinary collaborator, not a -Leopard subclass. This lets applications treat expected client failures, such +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 -`NatsApiServer::MessageHandling#request_reply_callbacks` constructs -`NatsRequestReplyCallbacks.new(logger:)` directly. The callback class is -therefore not configurable, despite being a separate collaborator. - 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 small `RequestReplyCallbackResolution` module to the request/reply - message-handling path. It resolves the callback object through the service - configuration and memoizes the resolved object for the worker lifetime. -. Add a `:request_reply_callback_resolver` setting when `NatsApiServer` is - included. Its default is a callable that builds - `NatsRequestReplyCallbacks.new(logger:)`, so existing services retain their - current behavior. -. Change `MessageHandling#request_reply_callbacks` to call the configured - resolver, rather than naming or constructing `NatsRequestReplyCallbacks` - directly. -. Document the resolver contract: it accepts `logger:` and returns any object - responding to `#callbacks`. The returned hash uses the existing - `:on_success`, `:on_failure`, and `:on_error` callbacks. No superclass, - mix-in, or Leopard-specific type is required. -. Add a unit test proving the default resolver is used, and a focused - `NatsApiServer` test that configures a test resolver and verifies it receives - the service logger and supplies the callbacks passed to message processing. -. Add tests for a composed callback object and a failure-logging policy object, - proving an application can replace logging without inheriting from or mixing - into a Leopard class. -. Add a README example showing the composed callback object. It delegates its - logging decision to a policy object and leaves `respond_with_error` intact. - The policy receives the complete failure and may select behavior for any - application status or other failure classification. The example must make - the application's failure/status interface explicit rather than assume a - particular error type. +. 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 -Implement the custom policy as a dependency of an application-owned callback -object, not in middleware. Middleware wraps handler execution; callback code -owns the mapping from handler outcomes to transport responses. +Configure a policy directly. Leopard owns the callback mapping and invokes the +policy only for failed handler results. [source,ruby] ---- -class FailureLogPolicy - def initialize(logger:, level_for:) - @logger = logger - @level_for = level_for - end - - def call(failure) - level = @level_for.call(failure) - @logger.public_send(level, 'Error processing message: ', failure) if level - end -end - -class ApiCallbacks - def initialize(logger:, failure_log_policy:) - @failure_log_policy = failure_log_policy - end - - def callbacks - { - on_success: ->(wrapper, result) { wrapper.respond(result.value!) }, - on_failure: ->(wrapper, result) do - @failure_log_policy.call(result.failure) - wrapper.respond_with_error(result.failure) - end, - on_error: ->(wrapper, error) { wrapper.respond_with_error(error) }, - } - end -end +MyService.config.request_reply_failure_log_policy = lambda do |failure| + next if failure.status.between?(400, 499) -MyService.config.request_reply_callback_resolver = lambda do |logger:| - level_for = ->(failure) { failure.status >= 500 ? :error : nil } - policy = FailureLogPolicy.new(logger:, level_for:) - ApiCallbacks.new(logger:, failure_log_policy: policy) + MyService.logger.error 'Error processing message: ', failure end ---- -The final implementation should use the application's real status accessor -(or another application-specific classifier). It must still call -`wrapper.respond_with_error(failure)` for every failure: changing alerting must -not change the client response. +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 a callback resolver without monkey-patching, - inheriting from, or overriding a private Leopard method. -* The resolver can return an application-owned callback object with composed - dependencies; it does not need to inherit from or mix in Leopard code. -* A configured callback implementation can select a log level, or no log - entry, for any failure status while returning the same error payload to the - requester. -* Exceptions continue to follow the existing error path unless the custom - callback policy deliberately changes them. +* 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..694b575 100644 --- a/lib/leopard/nats_request_reply_callbacks.rb +++ b/lib/leopard/nats_request_reply_callbacks.rb @@ -6,11 +6,13 @@ 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 || default_failure_log_policy(logger) end # Returns transport callbacks for request/reply endpoints. @@ -43,7 +45,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 @@ -57,13 +59,8 @@ 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 + def default_failure_log_policy(logger) + ->(failure) { logger.error 'Error processing message: ', failure } 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..f001021 100644 --- a/test/lib/nats_request_reply_callbacks_test.rb +++ b/test/lib/nats_request_reply_callbacks_test.rb @@ -30,6 +30,22 @@ 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 From bf5529aff1565f81fe9ba90b193cf89d7ba593e5 Mon Sep 17 00:00:00 2001 From: "Tj (bougyman) Vanderpoel" Date: Tue, 15 Sep 2026 12:44:29 -0400 Subject: [PATCH 3/3] fix: satisfy request reply callback quality checks Inline the default failure log policy and shorten the policy-injection test to make YARD and RuboCop happy without changing callback behavior. --- lib/leopard/nats_request_reply_callbacks.rb | 8 +++----- test/lib/nats_request_reply_callbacks_test.rb | 5 +---- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/leopard/nats_request_reply_callbacks.rb b/lib/leopard/nats_request_reply_callbacks.rb index 694b575..20fb73d 100644 --- a/lib/leopard/nats_request_reply_callbacks.rb +++ b/lib/leopard/nats_request_reply_callbacks.rb @@ -12,7 +12,9 @@ class NatsRequestReplyCallbacks # # @return [void] def initialize(logger:, failure_log_policy: nil) - @failure_log_policy = failure_log_policy || default_failure_log_policy(logger) + @failure_log_policy = failure_log_policy || lambda do |failure| + logger.error 'Error processing message: ', failure + end end # Returns transport callbacks for request/reply endpoints. @@ -58,10 +60,6 @@ def respond_with_failure(wrapper, result) def respond_with_error(wrapper, error) wrapper.respond_with_error(error) end - - def default_failure_log_policy(logger) - ->(failure) { logger.error 'Error processing message: ', failure } - end end end end diff --git a/test/lib/nats_request_reply_callbacks_test.rb b/test/lib/nats_request_reply_callbacks_test.rb index f001021..c92c515 100644 --- a/test/lib/nats_request_reply_callbacks_test.rb +++ b/test/lib/nats_request_reply_callbacks_test.rb @@ -36,10 +36,7 @@ def test_failure_uses_injected_log_policy_and_responds_with_error 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 = Rubyists::Leopard::NatsRequestReplyCallbacks.new(logger: @logger, failure_log_policy: policy).callbacks callbacks[:on_failure].call(wrapper, Dry::Monads::Result::Failure.new('fail')) policy.verify