Rack::Robustness, the rescue clause of your Rack stack.

Rack::Robustness is the rescue clause of your Rack's call stack. In other words, a middleware that ensures the robustness of your web stack, because exceptions occur either intentionally or unintentionally. Rack::Robustness is the rack middleware you would have written manually (see below) but provides a DSL for scaling from zero configuration (a default shield) to specific rescue clauses for specific errors.

Build Status Dependency Status

##
#
# The middleware you would have written
#
class Robustness

  def initialize(app)
    @app = app
  end

  def call(env)
    @app.call(env)
  rescue ArgumentError => ex
    [400, { 'Content-Type' => 'text/plain' }, [ ex.message ] ]  # suppose the message can be safely used
  rescue SecurityError => ex
    [403, { 'Content-Type' => 'text/plain' }, [ ex.message ] ]
  ensure
    env['rack.errors'].write(ex.message) if ex
  end

end

...becomes...

use Rack::Robustness do |g|
  g.on(ArgumentError){|ex| 400 }
  g.on(SecurityError){|ex| 403 }

  g.content_type 'text/plain'

  g.body{|ex|
    ex.message
  }

  g.ensure(true){|ex|
    env['rack.errors'].write(ex.message)
  }
end

Why?

In my opinion, Sinatra's error handling is sometimes a bit limited for real-case needs. So I came up with something a bit more Rack-ish, that allows handling exceptions actively, because exceptions occur and that you'll handle them... enventually. A more theoretic argumentation would be:

  • Exceptions occur, because you can't always test/control boundary conditions. E.g. your code can pro-actively test that a file exists before reading it, but it cannot pro-actively test that the user removes the network cable in the middle of a download.
  • The behavior to adopt when obstacles occur is not necessary defined where the exception is thrown, but often higher in the call stack.
  • In ruby web apps, the Rack's call stack is a very important part of your stack. Middlewares, routes and controllers do rarely rescue all errors, so it's still your job to rescue errors higher in the call stack.

Rack::Robustness is therefore a try/catch/finally mechanism as a middleware, to be used along the Rack call stack as you would use a standard one in a more conventional call stack:

try {
  // main shield, typically in a main

  try {
    // try to achieve a goal here
  } catch (...) {
    // fallback to an alternative
  } finally {
    // ensure something is executed in all cases
  }

  // continue your flow

} catch (...) {
  // something goes really wrong, inform the user as you can
}

becomes:

class Main < Sinatra::Base

  # main shield, main = rack top level
  use Rack::Robustness do
    # something goes really wrong, inform the user as you can
    # probably a 5xx http status here
  end

  # continue your flow
  use Other::Useful::Middlewares

  use Rack::Robustness do
    # fallback to an alternative
    # 3xx, 4xx errors maybe

    # ensure something is executed in all cases
  end

  # try to achieve your goal through standard routes

end

Additional examples

class App < Sinatra::Base

  ##
  # Catch everything but hide root causes, for security reasons, for instance.
  #
  # This handler should never be fired unless the application has a bug...
  #
  use Rack::Robustness do |g|
    g.status 500
    g.content_type 'text/plain'
    g.body 'A fatal error occured.'
  end

  ##
  # Some middleware here for logging, content length of whatever.
  #
  # Those middleware might fail, even if unlikely.
  #
  use ...
  use ...

  ##
  # Catch some exceptions that denote client errors by convention in our app.
  #
  # Those exceptions are considered safe, so the message is sent to the user.
  #
  use Rack::Robustness do |g|
    g.no_catch_all                 # do not catch all errors

    g.status 400                   # default status to 400, client error
    g.content_type 'text/plain'    # a default content-type, maybe
    g.body{|ex| ex.message }       # by default, send the message

    # catch ArgumentError, it denotes a coercion error in our app
    g.on(ArgumentError)

    # we use SecurityError for handling forbidden accesses.
    # The default status is 403 here
    g.on(SecurityError){|ex| 403 }

    # ensure logging in all exceptional cases
    g.ensure(true){|ex| env['rack.errors'].write(ex.message) }
  end

  get '/some/route/:id' do |id|
    id = Integer(id) # will raise an ArgumentError if +id+ not an integer

    ...
  end

  get '/private' do |id|
    raise SecurityError unless logged?

    ...
  end

end

Without configuration

##
# Catches all errors.
#
# Respond with
#   status:  500,
#   headers: {'Content-Type' => 'text/plain'}
#   body:    [ "Sorry, an error occured." ]
#
use Rack::Robustness

Specifying static status, headers and/or body

##
# Catches all errors.
#
# Respond as specified.
#
use Rack::Robustness do |g|
  g.status 400
  g.headers 'Content-Type' => 'text/html'
  g.content_type 'text/html'               # shortcut over headers
  g.body "<p>an error occured</p>"
end

Specifying dynamic status, content_type and/or body

##
# Catches all errors.
#
# Respond as specified.
#
use Rack::Robustness do |g|
  g.status{|ex| ArgumentError===ex ? 400 : 500 }

  # global dynamic headers
  g.headers{|ex| {'Content-Type' => 'text/plain', ...} }

  # local dynamic and/or static headers
  g.headers 'Content-Type' => lambda{|ex| ... },
            'Foo' => 'Bar'

  # dynamic content type
  g.content_type{|ex| ...}

  # dynamic body (String allowed here)
  g.body{|ex| ex.message }
end

Specific behavior for specific errors

##
# Catches all errors using defaults as above
#
# Respond to specific errors as specified by 'on' clauses.
#
use Rack::Robustness do |g|
  g.status 500                    # this is the default behavior, as above
  g.content_type 'text/plain'     # ...

  # Override status on TypeError and descendants
  g.on(TypeError){|ex| 400 }

  # Override body on ArgumentError and descendants
  g.on(ArgumentError){|ex| ex.message }

  # Override everything on SecurityError and descendants
  # Default headers will be merged with returned ones so content-type will be
  # "text/plain" unless specified below
  g.on(SecurityError){|ex|
    [ 403, { ... }, [ "Forbidden, sorry" ] ]
  }
end

Ensure common block in happy/exceptional/all cases

##
# Ensure in all cases (no arg) or exceptional cases only (true)
#
use Rack::Robustness do |g|

  # Ensure in all cases
  g.ensure{|ex|
    # ex might be nil here
  }

  # Ensure in exceptional cases only (for logging purposes for instance)
  g.ensure(true){|ex|
    # an exception occured, ex is never nil
    env['rack.errors'].write("#{ex.message}\n")
  }
end

Don't catch all!

##
# Catches only errors specified in 'on' clauses, using defaults as above
#
# Re-raise unrecognized errors
#
use Rack::Robustness do |g|
  g.no_catch_all

  g.on(TypeError){|ex| 400 }
  ...
end