In Progress
Unit 1, Lesson 21
In Progress

Caller-Specified Fallback

Video transcript & code

Let's say we've written a method for fetching the current temperature of a given region from a weather API. The method definition itself is pretty straightforward: it assembles a URL, makes a request, and then parses and extracts the data it needs.

require 'ostruct'
require 'open-uri'
require 'json'

def get_temp(query)
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
end

We're not just writing this method for ourselves; several of our team members have expressed an interest in having a common temperature-fetching method for the parts of the project they are working on. So we want this method to be as robust, flexible, and user-friendly as possible.

One question that occurs to us is, what do we do in case of failure? There are several ways this method could go wrong. The network might not be available. The query that is passed in might not match a known geographical region. The API might be temporarily unavailable.

One teammate has said that when a failure occurs, she'd like for the method to do something more consistent than just passing on whatever exception happens to be raised internally. She'd like it to raise an exception specific to fetching temperature data.

require 'ostruct'
require 'open-uri'
require 'json'

class TemperatureApiError < StandardError
end

def get_temp(query)
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error
  raise TemperatureApiError.new(error.message)
end

We run this version by all of our colleagues, but some of them don't want the method to ever raise an exception. They'd rather it just return some kind of benign value when anything goes wrong.

require 'ostruct'
require 'open-uri'
require 'json'

def get_temp(query)
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error
  return "N/A"
end

Different team members want different failure-case semantics. Which one do we choose? Which is better?

When presented with a question like this, I like to call on one of my favorite software development guidelines: when in doubt, punt the decision somewhere else.

In this case, we can punt the decision to the caller. We do this by letting the method take an optional block. When something breaks, the method checks to see if a block was passed. If it was, it yields to the behavior specified in the block, passing in the rescued exception. If there is no block, it re-raises the rescued exception.

Now both of our prospective client coders are satisfied. One can have exceptions, and the other can have a benign value.

require 'ostruct'
require 'open-uri'
require 'json'

class TemperatureApiError < StandardError
end

def get_temp(query)
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error
  if block_given?
    yield(error)
  else
    raise
  end
end

get_temp("00000") { "N/A" }     # => "N/A"
get_temp("00000") do |error|
  raise TemperatureApiError, error.message
end
# ~> -:23:in `block in <main>': undefined method `[]' for nil:NilClass (TemperatureApiError)
# ~>    from -:15:in `rescue in get_temp'
# ~>    from -:9:in `get_temp'
# ~>    from -:22:in `<main>'

Another way to write this is to capture our default behavior in a lambda, and have the block argument default to that lambda if it is unspecified. That way we avoid an if/then/else statement inside the rescue clause.

require 'ostruct'
require 'open-uri'
require 'json'

class TemperatureApiError < StandardError
end

DEFAULT_FALLBACK = ->(error) { raise }

def get_temp(query, &fallback)
  fallback ||= DEFAULT_FALLBACK
  key  = ENV['WUNDERGROUND_KEY']
  url  = "http://api.wunderground.com/api/#{key}/conditions/q/#{query}.json"
  data = open(url).read
  JSON.parse(data)['current_observation']['temp_f']
rescue => error
  fallback.call(error)
end

get_temp("00000")
# ~> -:15:in `get_temp': undefined method `[]' for nil:NilClass (NoMethodError)
# ~>    from -:20:in `<main>'

You might recognize this as the same pattern used by the Hash#fetch method.

{foo: 42}.fetch(:bar) { "Not Found" } # => "Not Found"

You will find this pattern cropping up in other parts of the Ruby standard library as well. It's a great way to make a method more flexible when you can't predict every way a caller might want to use it.

That's all for today. Happy hacking!

Responses