Jonathan Baudanza

San Francisco, CA

Handling requests asynchronously in Rails

Vote on HN

It is generally considered bad practice to block a web request handler on a network request to a third party service. If that service should become slow or unavailable, this can clog up all your web processes.

For example, say for some reason you have a Rails action that queries Facebook for a user's full name.

class FacebookNamesController < ApplicationController
  def show
    uri = URI.parse("http://graph.facebook.com/" + param[:facebook_uid])
    response = Net::HTTP.get(uri)
    session[:name] = JSON.parse(response)['name']
    render :text => "Hello #{session[:name]}"
  end
end

If you are using the Thin webserver, you can rewrite this code asynchronously.

class FacebookNamesController < ApplicationController
  def show
    uri = "http://graph.facebook.com/" + param[:facebook_uid]
    http = EM::HttpRequest.new(uri).get(uri)

    # This informs thin that the request will be handled asynchronously
    self.response_body = ''
    self.status = -1

    # Set a callback to finish the request when facebook returns the query.
    # In the meantime, this process is free to handle other requests.
    http.callback do
      # Oops.. this line won't have the effect we want because the Session
      # middleware has already run its course
      session[:name] = JSON.parse(response)['name']

      # We'd like to use something like `render :text` here, but we
      # can't because we are limited to the raw Rack API.
      env['async.callback'].call('200', {}, "Hello #{session[:name]}")
    end
  end
end

The disadvantage with this code is that we are interfacing directly with thin's asychronous rack layer. Because of this, we are losing out on all the middleware that Rails provides. Since the Rail's session is handled by middleware, we are unable to store the user's name in it.

To get back the full Rail's functionality, we need to construct a new rack application that is bundled with all the Rail's middleware.

I've included that functionality into the following mixin module.

module AsyncController
  # This is the rack endpoint that will be invoked asyncronously. It will be
  # wrapped in all the middleware that a normal Rails endpoint would have.
  class RackEndpoint
    attr_accessor :action

    def call(env)
      @action.call(env)
    end
  end

  @@endpoint = RackEndpoint.new

  def self.included(mod)
    # LocalCache isn't able to be instantiated twice, so it must be removed
    # from the new middleware stack.
    middlewares = Rails.application.middleware.middlewares.reject do |m|
      m.klass.name == "ActiveSupport::Cache::Strategy::LocalCache"
    end

    @@wrapped_endpoint = middlewares.reverse.inject(@@endpoint) do |a, e|
      e.build(a)
    end
  end

  # Called to finish an asynchronous request. Can be invoked with a block
  # or with the symbol of an action name.
  def finish_request(action_name=nil, &proc)
    async_callback = request.env.delete('async.callback')
    env = request.env.clone

    if !action_name
      env['async_controller.proc'] = proc
      action_name = :_async_action
    end

    @@endpoint.action = self.class.action(action_name)

    async_callback.call(@@wrapped_endpoint.call(env))
  end

  def _async_action
    instance_eval(&request.env['async_controller.proc'])
  end
end

With this mixin, our controller can be written like so:

class FacebookNamesController < ApplicationController
  include AsyncController

  def show
    uri = "http://graph.facebook.com/" + param[:facebook_uid]
    http = EM::HttpRequest.new(uri).get(uri)

    http.callback do
      finish_async_request do
        session[:name] = JSON.parse(response)['name']
        render :text => "Hello #{session[:name]}"
      end
    end

    self.response_body = ''
    self.status = -1
  end
end

This give us access to the full functionality of Rails without having to block waiting on an external service.