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::Results 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.