0.01
No release in over a year
Throttling mechanism for safely dealing with external resources so that latency does not take down your application.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Development

Runtime

 Project Readme

Double Restraint

Continuous Integration Regression Test Ruby Style Guide Gem Version

This gem implements a pattern for interacting with external services in a way that prevents performance issues with those services from taking down your application. It builds atop the restrainer gem which requires a Redis server to coordinate processes.

Usage

Suppose you have a web application that calls a web service for something, and at some point that web service starts to have latency issues and requests take several seconds to return. Eventually most your application threads will be be waiting on the web service and your application will be completely unresponsive.

If the external service uses timeouts, you could mitigate the issue of locking up your application by setting a low timeout so that requests to the sevice fail fast. However, if some requests to the service take just a little longer even in a health system, then you will be artificially preventing these requests from succeeding.

With the restrainer gem you can throttle the number of concurrent requests to a service so that, if there is a problem with that service, only a limited number of application threads would be affected:

restrainer = Restrainer.new("MyWebService", limit: 10)
begin
  restrainer.throttle do
    MyWebService.new.call(arguments)
  end
rescue Restrainer::ThrottledError
  puts "Too many concurrent calls to MyWebService"
end

However, this can lead to problems if you set the limit too low. You could end up in a situation where peak traffic sends more requests than the limit you set. This will end up artificially limiting the external calls and returning errors to users.

This gem combines both solutions and lets you set two levels of timeouts and a limit on how many concurrent requests can use the longer timeout. You can be more aggressive with both your fail fast timeout and the limit on concurrent processes without affecting requests in a health system.

restraint = DoubleRestraint.new("MyWebService", timeout: 0.5, long_running_timeout: 5.0, long_running_limit: 5)
begin
  restraint.execute do |timeout|
    MyWebService.new(timeout: timeout).call(arguments)
  end
rescue Restrainer::ThrottledError
  puts "Too many concurrent calls to MyWebService"
end
  • The timeout value should be set to a low value that works for most requests in a healthy system.
  • The long_running_timeout value should be set to a higher value that works for all requests in a health system.
  • The long_running_limit value is the maximum number of concurrent requests that are allowed using the higher timeout.

The execute call will call the block with the timeout value. If the block raises a timeout error, then it will be called again with the long_running_timeout value inside a Restrainer. If there are too many concurrent requests, then a Restrainer::ThrottledError will be raised.

The effect of this is that if there are latency issues in MyWebService, then the requests will fail fast. Only a handful of requests will be allowed to execute with the higher timeout value so the impact on the overall system will be very limited. On a healthy system, you shouldn't seen any artificially generated errors as long as your timeout is set properly.

The execute block must be idempotent since it can be run twice by one call to execute.

You can also set a restraint on the initial execution with the lower timeout by specifying the limit parameter.

restraint = DoubleRestraint.new("MyWebService", limit: 50, timeout: 0.5, long_running_timeout: 5.0, long_running_limit: 5)

By default, a timeout is identified by any error that inherits from Timeout::Error. You may need to specify what constitutes a timeout error in your block of code, though. For instance, if you code uses Faraday to make an HTTP requests, then you would need to specify that timeouts are identified by Faraday::TimeoutError.

restraint = DoubleRestraint.new("MyWebService", timeout_errors: [Faraday::TimeoutError], timeout: 0.5, long_running_timeout: 5.0, long_running_limit: 5)

Finally, you need to specify the Redis instance to use. By default this uses the value specified for the restrainer gem.

# set the global Redis instance
Restrainer.redis = Redis.new(url: redis_url)

# or use a block to specify a value that is yielded at runtime
Restrainer.redis{ connection_pool.redis }

However, you can also specify the Redis instance directly on the DoubleRestraint instance.

restraint = DoubleRestraint.new("MyWebService", redis: Redis.new(url: redis_url)), timeout: 0.5, long_running_timeout: 5.0, long_running_limit: 5)

You can peek at the current pool sizes as well if you want:

# Number of process currently using a slot in the default pool
restraint.default_pool_size

# Number of process currently using a slot in the long running pool
restraint.long_running_pool_size

# Get the percentage capacity being used as a whole
total_pool_used = restraint.pool_size + restraint.long_running_pool_size
total_pool_capacity = restraint.limit + restraint.long_running_limit
total_pool_used.to_f / total_pool_capacity

Installation

Add this line to your application's Gemfile:

gem "double_restraint"

And then execute:

$ bundle

Or install it yourself as:

$ gem install double_restraint

Contributing

Open a pull request on GitHub.

Please use the standardrb syntax and lint your code with standardrb --fix before submitting.

License

The gem is available as open source under the terms of the MIT License.