Resilience in Ruby: Shell Commands
This post is half a gentle nudge that you should be using GitHub::Result
more often and half a continuation of my Resilience in Ruby and Limit Everything: Timeouts for Shell Commands in Ruby posts. You can read this post and get value without reading those, but if you really want to dig in, I'd read them first.
Adding timeouts to Speaker Deck's shell commands (as discussed in the aforementioned limit everything post) was a great start. But that wasn't enough. The code was not re-usable and definitely not aesthetically pleasing.
Last night I watched Semicolon & Sons Rails Best Practices video (it's great, you should watch it). One note that stuck with me was:
Programmers like me who work on a large variety of projects can greatly improve their productivity by writing their code in a way that it can be copied and pasted into another project and, more or less, just work.
I think that sums up this next step in resilience for shell commands – improving ease of reuse. In this case, I'm talking ease of reuse in the same project. But most of this is generic enough it could easily be reused on another project as Jack advocated for.
The Foundation
First, let's start with the foundation, from which all the other commands are built. It's pretty simple:
module SpeakerDeckCommand
class Error < StandardError; end
class Timeout < Error; end
module_function
def call(command, options = {})
GitHub::Result.new {
child = POSIX::Spawn::Child.build(*command, options)
begin
child.exec!
rescue POSIX::Spawn::TimeoutExceeded
raise Timeout, child.inspect
end
if child.success?
child
else
raise Error, child.inspect
end
}
end
end
Sure, this should be generalized to Command
or ShellCommand
, but this simple layer that wraps each POSIX::Spawn
command in a GitHub::Result
makes chaining shell commands fantastically smooth.
If you are new to GitHub::Result
, you might be fine reading this, but not quite sure how to use it. Let's read through a few examples quick so you are up to speed.
Calling a command returns a GitHub::Result
:
SpeakerDeckCommand.call(["echo", "hello"]) # => GitHub::Result
The result knows if it is ok:
SpeakerDeckCommand.call(["echo", "hello"]).ok? # => true
The result also knows if it is NOT ok (assuming you don't have a "nope" 😂):
SpeakerDeckCommand.call(["nope"]).ok? # => false
The result has a value. In this case, the value is the posix spawn child instance:
SpeakerDeckCommand.call(["echo", "hello"]).value!
=> #<POSIX::Spawn::Child:0x00007ff25b906a28
@argv=[["echo", "echo"], "hello"],
@env={},
@err="",
@input=nil,
@max=nil,
@options={},
@out="hello\n",
@pid=30396,
@runtime=0.001821,
@status=#<Process::Status: pid 30396 exit 0>,
@timeout=nil>
But when there is an error, value!
will raise:
SpeakerDeckCommand.call(["nope"]).value!
Errno::ENOENT: No such file or directory - when spawning 'nope'
But remember you can check if it is ok?
prior to calling value!
:
result = SpeakerDeckCommand.call(["nope"])
if result.ok?
puts result.value!
else
puts "Boooooo"
end
# outputs: Boooooo
Or, if you prefer, you can provide a default by using value
with a block:
SpeakerDeckCommand.call(["nope"]).value { "Boooooo" } # => "Boooooo"
Please note how that code reads much more cleanly (IMO) compared to:
begin
# assuming this raises error instead of returning GitHub::Result
SpeakerDeckCommand.call(["nope"])
rescue => err
"Boooooo"
end
But the goodness doesn't stop there. Have you ever used a begin
/ rescue
in a view? Ugh. The humanity. It's terrible right?
Well GitHub::Result
s aren't terrible in views. You can very easily use them with defaults on error or a normal if
/ else
that informs the user of the problem.
This might seem like a small change moving from begin
/ rescue
to GitHub::Result
+ value
or if
/ else
. But I've found it to have pretty huge ramifications in making it easier to build resilient Ruby calls that retain the aesthetic we all know and love in Ruby-land.
The First Floor
Now that we have the foundation of easily shelling out commands and handling failure, its time to layer on top specific shell commands.
One piece of software Speaker Deck uses is imagemagick. For example, let's say we want to get the dimensions of an image using imagemagick.
First, we can whip together an ImageMagick module:
module ImageMagick
module_function
def dimensions(options = {})
path = options.fetch(:path).to_s
command = [
SpeakerDeck.identify_bin,
"-format", "%wx%h",
path,
]
SpeakerDeckCommand.call(command, timeout: SpeakerDeck.identify_timeout).map { |child|
child.out.to_s.strip.split('x').map(&:to_f)
}
end
end
Using this module, we can now easily get the dimensions of an image:
ImageMagick.dimensions({
path: "/Users/jnunemaker/Dropbox/Pictures/me/beard.jpg",
})
# => #<GitHub::Result:0x3ff93c757a04 value: [750.0, 1000.0]>
Easy peasy 🍋 squeezy, right? Let's do the same thing for an image that doesn't exist:
ImageMagick.dimensions({
path: "/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg",
})
# => #<GitHub::Result:0x3ff92dccafa4
error: #<SpeakerDeckCommand::Error: #<POSIX::Spawn::Child:0x00007ff25b995de0
@env={},
@argv=[["identify", "identify"], "-format", "%wx%h", "/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg"],
@options={},
@input=nil,
@timeout=10,
@max=nil,
@pid=31214,
@err="identify: unable to open image '/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg': No such file or directory @ error/blob.c/OpenBlob/3537.\n",
@out="",
@runtime=0.019673,
@status=#<Process::Status: pid 31214 exit 1>>>>
Hey! We have a result with an error. But please note that nothing 💥 in our face.
ImageMagick.dimensions({
path: "/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg",
}).ok? # => false
See? How does this all work? Well, if you take a peak back at the ImageMagick.dimensions
code above, you'll notice this tiny little method call map
.
You might think:
Oh! I recognize that from using map on Enumerable objects.
Yep! But this map
is even doper. It will only be invoked if the result is ok. If the result is a failure, map
is never invoked.
SpeakerDeckCommand.call(["nope"])
.map { |value| raise "this won't be invoked" }
.error
=> #<Errno::ENOENT: No such file or directory - when spawning 'nope'>
😎 AMIRITE? The error is the first nope, not the raise
in map
. Again, this may seem tiny, but all of this is building toward the roof top patio where it is always sunny.
The Second Floor
Ok, we have a solid foundation. Our main living floor is getting pretty sick. Time to layer on a second floor with bedrooms you can't stay awake in and luxury bathrooms (including bidets and those honeymoon bathtubs) for all.
module ImageMagick
module_function
def resize(options = {})
path = options.fetch(:path).to_s
output_path = options.fetch(:output_path, path).to_s
max_width = options.fetch(:width)
dimensions(path: path).then { |width, height|
if width < max_width
GitHub::Result.new { :noop }
else
SpeakerDeckCommand.call(
[SpeakerDeck.convert_bin, path, "-resize", max_width.to_s, output_path]
)
end
}
end
end
Ok, so now we have a resize
method. The cool thing is this resize
method only resizes if the image exists and the image width is greater than a provided max width.
ImageMagick.resize({
path: "/Users/jnunemaker/Dropbox/Pictures/me/beard.jpg",
output_path: "/tmp/beard_smaller.jpg",
width: 200,
})
# => #<GitHub::Result:0x3ff93c85b978 value: #<POSIX::Spawn::Child:0x00007ff2790b6df0 @env={}, @argv=[["convert", "convert"], "/Users/jnunemaker/Dropbox/Pictures/me/beard.jpg", "-resize", "200", "/tmp/beard_smaller.jpg"], @options={}, @input=nil, @timeout=30, @max=nil, @pid=31478, @err="", @out="", @runtime=0.066965, @status=#<Process::Status: pid 31478 exit 0>>>
ImageMagick.dimensions(path: "/tmp/beard_smaller.jpg")
=> #<GitHub::Result:0x3ff955cb0104 value: [200.0, 267.0]>
So that worked. But what if we try it again for a picture that doesn't exist?
ImageMagick.resize({
path: "/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg",
output_path: "/tmp/beard_smaller.jpg",
width: 200,
})
# => #<GitHub::Result:0x3ff944ac06e8 error: #<SpeakerDeckCommand::Error: #<POSIX::Spawn::Child:0x00007ff289580308 @env={}, @argv=[["identify", "identify"], "-format", "%wx%h", "/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg"], @options={}, @input=nil, @timeout=10, @max=nil, @pid=31513, @err="identify: unable to open image '/Users/jnunemaker/Dropbox/Pictures/me/chin.jpg': No such file or directory @ error/blob.c/OpenBlob/3537.\n", @out="", @runtime=0.014732, @status=#<Process::Status: pid 31513 exit 1>>>>
Booyeah! The original identify command in dimensions
failed and it stopped the chain of commands. 🚀
map
is pretty cool, but sometimes you want chain commands together that already each return a GitHub::Result
. Have no fear, GitHub::Result
has then
for that.
Ghostscript.call(cache_path, output_path, ghostscript_options)
.then { ImageMagick.resize(path: output_path, width: width) }
.then { # whatever else you want to do }
ImageMagick.resize
is only invoked if Ghostscript.call
is ok. Whereas map
wraps return values in a result, then
does not. then
leaves the returning of a result up to you.
The Roof Top Patio
I can tell you are getting a bit excited at this point. You're thinking of all the places you have ugly feeling error handling. You're remembering a few spots where you should have error handling, but because it was ugly and painful, so you left it out.
Before you go making all your code resilient though, I want to put the 🍒 on top – a killer roof top patio (with a view of course). To get the patio, it needs to be easy to test all of this and we need to know when things go wrong in production.
rescue
If an exception happens in the woods without reporting, does it make a sound? Nope. We need to ship these errors somewhere awesome – like Honeybadger.
Ghostscript.call(cache_path, output_path, ghostscript_options)
.then { ImageMagick.resize(path: output_path, width: width) }
.rescue { |error|
Honeybadger.notify(error) # <----
GitHub::Result.error(error)
}
That's it. You can tack that rescue
on any chain of results. If any link in the chain fails, rescue is invoked and you'll get your error tracking.
The one key part, and I always forget this, is that you need to return a result in the rescue
block. If there error is no big deal, you can return a result that is ok?
. If there error should stop things (like above), you can use the handy result GitHub::Result.error
creator provided (like above).
testing
All of the aforementioned glory is enough, but let's not forget about testing. Resiliency in production is great, but if we are missing tests and shipping bugs we aren't really writing resilient code.
Speaker Deck uses pdftotext to extract transcriptions from PDF documents. Additionally, it uses rspec for testing. Here is a quick example of stubbing the Pdftotext
command with an error, while ensuring the text is defaulted to nothing rather than blowing up processing entirely.
it "should report error and return nothing for text on error" do
error = StandardError.new
expect(Pdftotext).to receive(:call).and_return(GitHub::Result.error(error))
expect(Honeybadger).to receive(:notify).with(error)
result = uploader.extract_text(0)
expect(result).to eq("")
end
A deck with images but no text is better than a deck with no images because text extraction failed. GitHub::Result
makes this easy to do and, importantly, easy to test. Testing failure is just replacing the return value with an error result.
Conclusion
Sure, this post was all about resilience with shell commands, but don't let that stop you from sprinkling GitHub::Result elsewhere. For example, I use it on Box Out Sports in our graphic preview rendering code.
class Preview
def render_source
GitHub::Result.new {
# render the source to create graphic
}
end
def render
render_source.then { |body|
GitHub::Result.new {
HTTParty.post(uri, body: body, raise_on: RAISE_ON_STATUS_CODES)
}.rescue { |error|
store_error error
GitHub::Result.error error
}
}
end
end
Note that different methods are returning results for different purposes. render
itself can fail if rendering the source required to generate the graphic fails. It can also fail if the http request to the render farm fails.
As shown in the Preview
class above, you can rescue
errors or let callers rescue
. Its all very flexible and, as I keep saying, really provides building blocks for making aesthetically pleasing and resilient ruby code.
Overall, I'd say the Speaker Deck shell commands are way more resilient than they were. Same goes for pretty much all the HTTP requests I do these days as I always wrap them with a GitHub::Result
.
Try it out next time you go to do something that could fail and let me know what you think.