Skip to content

Commit

Permalink
Begin the safety dance
Browse files Browse the repository at this point in the history
  • Loading branch information
bf4 committed Feb 8, 2018
1 parent 632a572 commit 7bd5e60
Show file tree
Hide file tree
Showing 6 changed files with 302 additions and 19 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
Gemfile.lock
/.bundle/
/.yardoc
/_yardoc/
Expand Down
91 changes: 84 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
# SafetyDance

Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/safety_dance`. To experiment with that code, run `bin/console` for an interactive prompt.
A Response Object pattern for resilient Ruby code.

TODO: Delete this and the text above, and describe your gem
Example:

```ruby
SafetyDance.new { dance! }.
then { |result| leave_friends_behind(!result) }.
rescue { |error| not_friends_of_mine(error) }.
value!
```

Strongly inspired by [John Nunemaker's 'Resilience in Ruby: Handling Failure'](https://johnnunemaker.com/resilience-in-ruby/)
post, and the implementation of [Github::Result]( https://github.com/github/github-ds/blob/fbda5389711edfb4c10b6c6bad19311dfcb1bac1/lib/github/result.rb).

Quoting the post:

> By putting a response object in between the caller and the call to get the data:
> - we always return the same object, avoiding `nil` and retaining duck typing.
> - we now have a place to add more context about the failure if necessary, which we did not have with `nil`.
> - we have a single place to update rescued exceptions if a new one pops up.
> - we have a nice place for instrumentation and circuit breakers in the future.
> - we avoid needing `begin` and `rescue` all over and instead can use conditionals or whatever makes sense.
> - we give the caller the ability to handle different failures differently (Conn refused vs Timeout vs Rate limited, etc.).
> The key to me including *a layer on top* that bakes in the resiliency,
> making it easy for callers to do the right thing in the face of failure.
> Using response objects can definitely help with that.
## Installation

Expand All @@ -20,24 +45,76 @@ Or install it yourself as:

$ gem install safety_dance

Or just copy the relevant code into your project somewhere, such as this minimal implementation:

```ruby
class Result
def initialize
@value = yield
@error = nil
rescue => e
@error = e
end

def ok?
@error.nil?
end

def value!
if ok?
@value
else
raise @error
end
end

def rescue
return self if ok?
Result.new { yield(@error) }
end
end
```

## Usage

TODO: Write usage instructions here
Start with passing a block to `SafetyDance.new`, and continue with the available API.

### Instance methods

| method call | returns |
|------------ | --------- |
| ok? | true if value when no error else false
| value! | value if no error else raises error
| error | the rescued error


### Instance chain methods

| method call | call conditions | yields | returns |
|------------ |----------------- |---------------- |--------- |
| then | ok? | return value | instance |
| rescue | error | rescued error | instance |

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
1. Check out the repo.
2. Run `bin/setup` to install dependencies.
3. Run `rake spec` to run the tests.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
To install this gem onto your local machine, run `bundle exec rake install`.

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/bf4/safety_dance. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
Bug reports and pull requests are welcome on GitHub at https://github.com/bf4/safety_dance.

This project is intended to be a safe, welcoming space for collaboration,
and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.

## License

The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).

## Code of Conduct

Everyone interacting in the SafetyDance project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/bf4/safety_dance/blob/master/CODE_OF_CONDUCT.md).
Everyone interacting in the SafetyDance project’s codebases, issue trackers,
chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/bf4/safety_dance/blob/master/CODE_OF_CONDUCT.md).
101 changes: 99 additions & 2 deletions lib/safety_dance.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,102 @@
require "safety_dance/version"

module SafetyDance
# Your code goes here...
# Generic 'Result' object for declarative result success/failure/cascade handling.
#
# Usage:
#
# def some_action_that_succeeds(msg); msg; end
# def some_action_that_fails(msg); raise msg; end
#
# SafetyDance.new { some_action_that_succeeds(:success) } #=> SafetyDance
#
# SafetyDance.new { some_action_that_succeeds(:success) }
# .value {|error| "action failed with error #{error}" } #=> :success
#
# SafetyDance.new { some_action_that_fails("fail")}
# .value {|error| "action failed with error #{error}" } #=> "action failed with error 'RuntimeError fail'"
#
# SafetyDance.new { some_action_that_succeeds(:success) }
# .then { some_action_that_succeeds(:another_success }
# .value {|error| "I am handling #{error}" } # => :another_success
#
# SafetyDance.new { some_action_that_fails("fail1") }
# .then { some_action_that_fails("fail2") }
# .then { some_action_that_succeeds(:another_success }
# .then { some_action_that_fails("fail3") }
# .value {|error| "I am handling #{error}" } # I am handling 'RuntimeError fail1'"
#
# Result object pattern is from https://johnnunemaker.com/resilience-in-ruby/
# e.g. https://github.com/github/github-ds/blob/fbda5389711edfb4c10b6c6bad19311dfcb1bac1/lib/github/result.rb
class SafetyDance
def initialize
@value = yield
@error = nil
rescue => e
@error = e
end

def ok?
@error.nil?
end

def to_s
if ok?
"#<SafetyDance:0x%x value: %s>" % [object_id, @value.inspect]
else
"#<SafetyDance:0x%x error: %s>" % [object_id, @error.inspect]
end
end

alias_method :inspect, :to_s

def error
@error
end

def value
unless block_given?
fail ArgumentError, "must provide a block to SafetyDance#value to be invoked in case of error"
end
if ok?
@value
else
yield @error
end
end

def value!
if ok?
@value
else
raise @error
end
end

def then
return self if !ok?
SafetyDance.new { yield(@value) }
end

def then_tap
self.then do |value|
yield value
value
end
end

def rescue
return self if ok?
result = SafetyDance.new { yield(@error) }
if result.ok? && result.value! == @error
self
else
result
end
end

def self.error(e)
result = allocate
result.instance_variable_set(:@error, e)
result
end
end
4 changes: 2 additions & 2 deletions lib/safety_dance/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module SafetyDance
VERSION = "0.1.0"
class SafetyDance
VERSION = "1.0.0"
end
8 changes: 4 additions & 4 deletions safety_dance.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ Gem::Specification.new do |spec|
spec.authors = ["Benjamin Fleischer"]
spec.email = ["[email protected]"]

spec.summary = %q{TODO: Write a short summary, because RubyGems requires one.}
spec.description = %q{TODO: Write a longer description or delete this line.}
spec.homepage = "TODO: Put your gem's website or public repo URL here."
spec.summary = %q{Response Objects pattern for resilient Ruby}
spec.description = %q{SafetyDance.new { dance! }.then { |result| leave_friends_behind(!result) }.rescue { |error| not_friends_of_mine(error) }.value!}
spec.homepage = "https://github.com/bf4/safety_dance"
spec.license = "MIT"

# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
# to allow pushing to a single host or delete this section to allow pushing to any host.
if spec.respond_to?(:metadata)
spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
else
raise "RubyGems 2.0 or newer is required to protect against " \
"public gem pushes."
Expand Down
116 changes: 112 additions & 4 deletions spec/safety_dance_spec.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,117 @@
RSpec.describe SafetyDance do
it "has a version number" do
expect(SafetyDance::VERSION).not_to be nil

def some_action_that_succeeds(msg); msg; end
def some_action_that_fails(msg); raise msg; end

it "returns a result object" do
result = SafetyDance.new { some_action_that_succeeds(:success) }
expect(result).to be_a(SafetyDance)
end

specify "inspecting a result object shows its value" do
result = SafetyDance.new { some_action_that_succeeds(:success) }
hex_id = format("%x", result.object_id)
expect(result.to_s).to eq("#<SafetyDance:0x#{hex_id} value: :success>")
expect(result.inspect).to eq("#<SafetyDance:0x#{hex_id} value: :success>")
end

specify "inspecting a result object shows its error" do
result = SafetyDance.new { some_action_that_fails("omg") }
hex_id = format("%x", result.object_id)
expect(result.to_s).to eq("#<SafetyDance:0x#{hex_id} error: #<RuntimeError: omg>>")
expect(result.inspect).to eq("#<SafetyDance:0x#{hex_id} error: #<RuntimeError: omg>>")
end

specify "a successful result is ok?" do
result = SafetyDance.new { some_action_that_succeeds(:success) }
expect(result).to be_ok
end

specify "a failed result is not ok?" do
result = SafetyDance.new { some_action_that_fails("failed") }
expect(result).not_to be_ok
end

specify "SafetyDance#value returns the value of the successful operation" do
result = SafetyDance.new { some_action_that_succeeds(:success) }
.value {|error| "action failed with error #{error}" }
expect(result).to eq(:success)
end

specify "SafetyDance#value yields the error to the block on failed operation" do
result = SafetyDance.new { some_action_that_fails("fail") }
.value {|error| "action failed with error #{error}" }
expect(result).to eq("action failed with error fail")
end

specify "SafetyDance#value! returns the value of the successful operation" do
result = SafetyDance.new { some_action_that_succeeds(:success) }
.value!
expect(result).to eq(:success)
end

specify "SafetyDance#value! raises the error" do
expect {
SafetyDance.new { some_action_that_fails("fail") }.value!
}.to raise_error(RuntimeError) {|e|
expect(e.message).to eq("fail")
}
end

specify "SafetyDance#then is executed when the previous result is ok" do
result = SafetyDance.new { some_action_that_succeeds(:success) }
.then { some_action_that_succeeds(:another_success) }
.value {|error| "I am handling #{error}" }
expect(result).to eq(:another_success)
end

specify "SafetyDance#then returns the first failed result at the end of a result chain" do
result = SafetyDance.new { some_action_that_fails("first failure") }
.then { some_action_that_fails("never reached failure") }
.then { some_action_that_succeeds(:never_reached_success) }
.then { some_action_that_fails("another never reached failure") }
.value {|error| "I am handling #{error}" }
expect(result).to eq("I am handling first failure")
end

specify "SafetyDance#rescue allows recovery from an error" do
result = SafetyDance.new { some_action_that_fails("fail") }
.rescue {|error| some_action_that_succeeds("Recovered from '#{error}'") }
expect(result).to be_ok
expect(result.value!).to eq("Recovered from 'fail'")
end

specify "SafetyDance#rescue is executed when the previous result is an error" do
result = SafetyDance.new { some_action_that_succeeds("success") }
.rescue {|error| some_action_that_succeeds("Not executed after success: '#{error}'") }
.then { some_action_that_fails("failure") }
.rescue {|error| some_action_that_succeeds("Recovered from '#{error}'") }
expect(result).to be_ok
expect(result.value!).to eq("Recovered from 'failure'")
end

specify "SafetyDance#rescue can itself fail and be rescued" do
result = SafetyDance.new { some_action_that_fails("failure") }
.rescue {|error| some_action_that_fails("Recover of '#{error}' failed") }
.then { some_action_that_succeeds("unreached success") }
.then { some_action_that_fails("unreached failure") }
.rescue {|error| some_action_that_succeeds("Recovered from '#{error}'") }
expect(result).to be_ok
expect(result.value!).to eq("Recovered from 'Recover of 'failure' failed'")
end

it "does something useful" do
expect(false).to eq(true)
specify "SafetyDance#rescue can no-op by returning the yielded error" do
result = SafetyDance.new { some_action_that_fails("failure is passed through") }
.rescue {|error|
case error
when RuntimeError then error
else some_action_that_succeeds("not reached")
end
}
.then { some_action_that_succeeds("unreached success") }
.then { some_action_that_fails("unreached failure") }
expect(result).not_to be_ok
hex_id = format("%x", result.object_id)
expect(result.to_s).to eq("#<SafetyDance:0x#{hex_id} error: #<RuntimeError: failure is passed through>>")
end
end

0 comments on commit 7bd5e60

Please sign in to comment.