Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Readme.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
52 changes: 52 additions & 0 deletions documents/enable-injectable-request-reply-callbacks.adoc
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion lib/leopard/nats_api_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 8 additions & 13 deletions lib/leopard/nats_request_reply_callbacks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
16 changes: 16 additions & 0 deletions test/lib/nats_api_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
13 changes: 13 additions & 0 deletions test/lib/nats_request_reply_callbacks_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading