How would you log a failure without rescuing Dry::Monads::Do::Halt? This is what my team is currently doing and I find it quite disturbing that we’re messing with the internals of Dry::Monads.
class MyOperation
def call
result1 = yield private_operation_1
result2 = yield private_operation_2
result3 = yield private_operation_3
result4 = yield private_operation_4
rescue Dry::Monads::Do::Halt => error
log_failure(error.failure)
end
end
Another option is to log the error on the client code:
class ClientOfMyOperation
def call
result = MyOperation.new.call
log_failure(...) if result.failure?
end
end
But that seems to add extra responsibilities to the client and the log_failure may not have all the context to log the failure properly.
I’m not sure this is the right way, but the way I typically manage it is to delegate to a private method that executes the optimistic pipeline and resolves the failure in call
, like so:
class MyOperation
def call
pipeline.value_or { |e| raise "some error #{e.message} : #{e.trace}" }
# or, if the error is recoverable:
pipeline.or { |failed| RecoveryOperation.call(failed.point_of_failure) }
# or, just pass the result up to the caller
pipeline.to_result(:my_operation_failed)
end
private
def pipeline
result1 = yield private_operation_1
result2 = yield private_operation_2
result3 = yield private_operation_3
result4 = yield private_operation_4
end
end
You can retain the failure point with trace
: dry-rb - dry-monads v1.3 - Tracing failures
And most monads have a way to instrument the Left branch with a failure reason:
for example:
None().to_result(:error) # => Failure(:error)
Does that answer your question?
2 Likes
In case it helps, I generally pattern match since I’m going to do something interesting with the result via the caller. Example:
class MyOperation
def call
yield private_operation_1
yield private_operation_2
yield private_operation_3
yield private_operation_4
end
end
class ClientOfMyOperation
def initialize operation: MyOperation.new
@operation = operation
end
def call
case operation.call
in Success(result) then puts result
in Failure(error) then log_failure error
else fail StandardError, "Unable to parse operation."
end
end
private
attr_reader :operation
end
2 Likes
additionally, pattern matching is super nice in the testing layer for Monad types:
it "resolves the operation pipeline" do
subject.call(data) in Success(value)
expect(value.interesting_attribute).to be_interesting
end
it "reports the error information when the pipeline fails" do
subject.call(bad_data) in Failure(value)
expect(value.message).to eq(:reason_for_failure)
end
3 Likes
Thanks, everyone. @jedschneider I love this pipeline approach! It allows the failure to be handled both internally as well as externally.
Holy shit this is amazing. It never occured to me that inline pattern match could do this
Edit: okay after playing around with this approach for a few minutes it’s less useful than I had hoped. If the pattern match fails, is just returns false
which in this case does nothing, and your spec failure will end up being kind of cryptic because value
will be nil
rather than directly telling you it was an unexpected result.
I started leaning into monads as a way to reduce NoMethodError so this feels like a step backward.
I had hoped I might write some kind of assert
to make this nicer, but I couldn’t find a way to do it cleanly without a syntax error.
I’m still looking for a really good way to combine pattern matching and rspec matchers in my specs. Everything I’ve tried has been kind of awkward.
For now, I write
result = subject.(data)
expect(result).to match Success(ValueClass)
result.bind do |value|
expect(value.interesting_value).to be_interesting
end
Maybe Dry::Matcher
could be pressganged into this. It’ll require an extension to the unexpected pattern behavior.
@alassek the pattern match is intended to catch the outside monad type, not the localized value in the container:
irb(main):010:0> Dry::Monads::Success(nil) in Dry::Monads::Failure(nil)
Traceback (most recent call last):
4: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `<main>'
3: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `load'
2: from /Users/jed/.asdf/installs/ruby/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
1: from (irb):10
NoMatchingPatternError (Success(nil))
however, you can match on the value inside the result as well:
irb(main):007:0> Dry::Monads::Success(nil) in Dry::Monads::Success(nil)
=> nil
irb(main):008:0> Dry::Monads::Success(1) in Dry::Monads::Success(Integer => x)
=> nil
irb(main):009:0> x
=> 1
I’ll leave it to the reader to translate into RSpec.
9 times out of 10, I would say command output should be pattern-matched somehow and your error logging should go there.
However, I did recently run into a similar problem where we wanted to log a failure but recover transparently, so a pattern-match wouldn’t do.
I would say that trapping a Dry::Monads::Do::Halt
error entirely is a bad idea, but you can log it and reraise safely. A bare raise
without an argument will reraise the same exception without altering the stacktrace.
class MyOperation
def call
result1 = yield private_operation_1
result2 = yield private_operation_2
result3 = yield private_operation_3
result4 = yield private_operation_4
rescue Dry::Monads::Do::Halt => error
log_failure(error.result.failure)
raise
end
end
(side-note: Halt
wraps the Failure monad on the result
attribute, so you need to unpack that first to access the failure
value)
I wrote a helper method in our Command (Operation) base class to do the unwrapping for me
# Log a Failure Result to Rollbar transparently
#
# @param err [StandardError, Failure, Dry::Monads::Do::Halt]
def log_failure(err)
err = err.result if err.is_a?(Dry::Monads::Do::Halt)
case err
in Failure(StandardError => ex)
Rollbar.error(ex)
in Failure[err_name, *errs]
Rollbar.error("Error Result: #{err_name}", failure: errs.inspect)
end
end
Hmm, I wonder if there was a change in behavior with inline matching?
main:0> RUBY_VERSION
=> "3.1.0"
main:0> M::Success(nil) in M::Failure(nil)
=> false
I don’t get a NoMatchingPatternError
. I think the exception was removed in later Ruby versions. I believe this makes it more useful generally, but not for this specific purpose.
@alassek I did not know that! I will put this on a ticket tomorrow to change, although our runtime is AWS Linux so we won’t likely be using beyond 2.7 for a bit:
irb(main):011:0> RUBY_VERSION
=> "2.7.2"
It’s super disappointing IMO that the pattern match is no longer exhaustive. it was the main feature of using case in
vs case when
.
jed@wingmate ruby % irb
irb(main):001:1* case 3
irb(main):002:1* when 2 then "two"
irb(main):003:0> end
=> nil
irb(main):005:1* case 3
irb(main):006:1* in 2
irb(main):007:1* "two"
irb(main):008:0> end
Traceback (most recent call last):
4: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `<main>'
3: from /Users/jed/.asdf/installs/ruby/2.7.2/bin/irb:23:in `load'
2: from /Users/jed/.asdf/installs/ruby/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
1: from (irb):5
NoMatchingPatternError (3)
To clarify, NoMatchingPatternError
still happens when you write case..in
. It is only the inline matching that doesn’t raise.
1 Like
Makes sense, I’ll probably delegate to a custom matcher that runs the case in
expression against expected via a proc or something. A hassle, and still a disappointing change but manageable. Thanks again, I would have been sidelined by a ruby upgrade issue for sure!