Care and Feeding of Timeout.timeout

Eric Hodel | Tue, 11 Apr 2006 17:04:00 GMT

Posted in

If you’re not careful when using Timeout.timeout you can end up with some hard to find bugs.

The first is that nesting timeouts without different timeout exception classes is very bad. Let’s say you have a process that can connect to multiple servers, but you want to give up and try the next server if it takes too long. You’d probably write something like this:

require 'timeout'

servers = [1, 2]
current_server = 0

begin
  Timeout.timeout 2 do
    puts "Connecting to server #{servers[current_server]}" 
    sleep # simulate work
  end
rescue Timeout::Error
  puts "Failed" 
  current_server += 1
  retry unless current_server == servers.length
end

This is sensible code, if the work takes to long you’ll fail and move on to the next server.

But now its been a few months and you’ve added servers, but you want your application to only try for so many seconds then give up completely. Your real app would be properly factored (of course) so the bug with the simple solution wouldn’t necessarily be obvious:

require 'timeout'

servers = [1, 2]
current_server = 0

Timeout.timeout 5 do
  puts 'Setting up some stuff'
  sleep 4
  begin
    Timeout.timeout 2 do
      puts "Connecting to server #{servers[current_server]}" 
      sleep
    end
  rescue Timeout::Error
    puts "Failed." 
    current_server += 1
    retry unless current_server == servers.length
  end
end

When we run this code we run longer than we were supposed to:

<samp>$ time ruby t.rb
Setting up some stuff
Connecting to server 1
Failed.
Connecting to server 2
Failed.

real    0m7.044s
user    0m0.014s
sys     0m0.011s

Why seven seconds instead of five? The inner timeout block caught the outer timeout block’s exception and continued doing what it was doing. This isn’t what we want, but Timeout.timeout allows you to change the raised exception:

require 'timeout'

servers = [1, 2]
current_server = 0

class ServerTimeout < Timeout::Error; end
class AppTimeout < Timeout::Error; end

Timeout.timeout 5, AppTimeout do
  puts 'Setting up some stuff'
  sleep 4
  begin
    Timeout.timeout 2, ServerTimeout do
      puts "Connecting to server #{servers[current_server]}" 
      sleep
    end
  rescue ServerTimeout
    puts "Failed." 
    current_server += 1
    retry unless current_server == servers.length
  end
end

So now the outer timeout can stop execution even from inside the inner timeout:

<samp>$ time ruby t.rb
Setting up some stuff
Connecting to server 1
/usr/local/lib/ruby/1.8/timeout.rb:54: execution expired (AppTimeout)
        from /usr/local/lib/ruby/1.8/timeout.rb:56:in `timeout'
        from t.rb:13
        from /usr/local/lib/ruby/1.8/timeout.rb:56:in `timeout'
        from t.rb:9

real    0m5.069s
user    0m0.022s
sys     0m0.015s</samp>

The second to watch out for is Timeout killing your rescue or ensure blocks. A timeout raised inside an ensure block will stop execution, so for critical ensure blocks you should wrap them in their own begin/end block:

require 'timeout'

Timeout.timeout 2 do
  timeout = nil
  begin
    puts "Allocating the thingy..." 
    sleep 1
    raise RuntimeError, 'Oh no! Something went wrong!'
  ensure
    # Since we might time out, hold onto the timeout we caught
    # so we can re-raise it when we're done cleaning up.
    begin # we really need to clean up
      puts "Cleaning up after the thingy..." 
      sleep 2
      puts "Cleaned up after the thingy!" 
    rescue Timeout::Error => e
      puts "Timed out! Trying again!" 
      timeout = e # save that timeout then retry
      retry
    end
    # Raise the timeout so we time out all the way to the top.
    raise timeout unless timeout.nil?
  end
end
no comments

Comments RSS FEED

Comments are disabled