Skip to content
This repository was archived by the owner on Dec 1, 2023. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ test/version_tmp
tmp
.idea
.DS_Store
.ruby-version
.spec
21 changes: 18 additions & 3 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
language: ruby
rvm:
- 1.9.3
script: "bundle exec rspec"
- 2.3.3
- 2.4.3
- 2.5.0
cache:
- bundler
before_script:
- curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
- chmod +x ./cc-test-reporter
- ./cc-test-reporter before-build
after_script:
- ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
script: bin/spec
services:
- redis-server
notifications:
email:
recipients:
- dev-info@wanelo.com
- kigster@gmail.com
on_success: never
on_failure: always
env:
global:
secure: bkq6cFUhMqn2ppqUPNax5biIivGw7uuOf1+pK4o9EFsi2qkuzLMQMulwmKC+RMLUMsaITkvec3Lp+kHwRYiXr4bxiFaBg+Q278W9o+mRWcBEh6mGnDVCgR13xdMfDZFrafEm44jEnOWJICkBlmdfMkMOriJUTowc8g745jpGEUNEu7ZqIsVFSflb+GcdYXhlouEThhkcwcdmRwkXqfq7pp8AEhiji2V5PNKiVY+Zu/lq9APAqMqFmXDZ0+SAjkeagSSCtLiXYQqc4Z1PU2Jvyov7nfDJ72VYqvfqevSe9+rqitOleR/BvIoIsGO+et7Dq94liK964fzP+spp1ODUMdhbC7tmBuYqYr3lxsK5S6bHZ9/LABHKOMbpKVJefrxmyh/QaQpjA5w3vuSkNZXD/OsZ0ddmHOvya6cv5sTX//muJVmba88IMCcmQSAZUIYK8796ACnnvDhlQ9n/ilOYzP69W+RmkyX09SH2VR9AeMSjwRESCOh0XYMevHNIjfOk24nPnsH5OT317p8dyCfn9Z+dif1iTEujRAqAzGh6AmWQNQQx6HEC8QDQGOMpLmOzlLWeQMgo90KOe78JA4iGGmVfbFvB/qJcZ4ZacG+s8tlLU8ADcq7yj2sYCHZOEatpQZbNAMjJwoZMyqojhCZgtH039LxwDXoD4/u0epk+RuY=
8 changes: 0 additions & 8 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,3 @@ source 'https://rubygems.org'

# Specify your gem's dependencies in pause.gemspec
gemspec

gem 'fakeredis'
gem 'guard-rspec'
gem 'pry-nav'
gem 'rake'
gem 'rb-fsevent'
gem 'rspec'
gem 'timecop'
2 changes: 1 addition & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2012 Wanelo, Inc
Copyright © 2018 Konstantin Gredeskoul, Atasay Gokkaya, Eric Saxby, Paul Henry

MIT License

Expand Down
174 changes: 150 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,124 @@
Pause
======
[![Build Status](https://travis-ci.org/kigster/pause.svg?branch=master)](https://travis-ci.org/kigster/pause)
[![Test Coverage](https://api.codeclimate.com/v1/badges/af443a25cc902e629c8f/test_coverage)](https://codeclimate.com/github/kigster/pause/test_coverage)

[![Gem Version](https://badge.fury.io/rb/pause.png)](http://badge.fury.io/rb/pause)
[![Build status](https://secure.travis-ci.org/wanelo/pause.png)](http://travis-ci.org/wanelo/pause)
[![Gem Version](https://badge.fury.io/rb/pause.svg)](https://badge.fury.io/rb/pause.svg)
[![Maintainability](https://api.codeclimate.com/v1/badges/af443a25cc902e629c8f/maintainability)](https://codeclimate.com/github/kigster/pause/maintainability)

Pause is a flexible Redis-backed rate-limiting client. Use it to track events, with
# Pause

## In a Nutshell


**Pause** is a fast and very flexible Redis-backed rate-limiter. You can use it to track events, with
rules around how often they are allowed to occur within configured time checks.

Because Pause is Redis-based, multiple ruby processes (even distributed across multiple servers) can track and report
events together, and then query whether a particular identifier should be rate limited or not.
Sample applications include:

* throttling notifications sent to a user as to not overwhelm them with too much frequency,
* IP-based blocking based on HTTP request volume (see the related gem [spanx](https://github.com/wanelo/spanx)) that uses Pause,
* ensuring you do not exceed API rate limits when calling external web APIs.
* etc.

Pause currently does not offer a CLI client, and can only be used from within a Ruby application.

Additionally:

* Pause is pure-ruby gem and does not depend on Rails or Rack
* Pause can be used across multiple ruby processes, since it uses a distributed Redis backend
* Pause is currently in use by a web application receiving 6K-10K web requests per second
* Pause will work with a horizontally sharded multi-Redis-backend by using Twitter's [Twemproxy](https://github.com/twitter/twemproxy). This way, millions of concurrent users can be handled with ease.

### Quick Start

This section is meant to give you a rapid introduction, so that you can start using Pause immediately.

Our use case: we want to rate limit notifications sent to users, identified by their `user_id`, to:

* no more than 1 in any 2-hour period
* no more than 3 per day
* no more than 7 per week

Here is how we could set this up using Pause:

#### Configuration

We need to setup Pause with a Redis instance. Here is how we do it:

```ruby
require 'pause'

# First, lets point Pause to a Redis instance
Pause.configure do |config|
# Redis connection parameters
config.redis_host = '127.0.0.1'
config.redis_port = 6379
config.redis_db = 1

config.resolution = 600
config.history = 7 * 86400 # discard events older than 7 days
end
```

> NOTE: **resolution** is an setting that's key to understanding how Pause works. It represents the length of time during which similar events are aggregated into a Hash-like object, where the key is the identifier, and the value is the count within that period.
>
> Because of this,
>
> * _Larger resolution requires less RAM and CPU and are faster to compute_
> * _Smaller resolution is more computationally expensive, but provides higher granularity_.
>
> The resolution setting must set to the smallest rate-limit period across all of your checks. Below it is set to 10 minutes, meaning that you can use Pause to **rate limit any event to no more than N times within a period of 10 minutes or more.**


#### Define Rate Limited "Action"

Next we must define the rate limited action based on the specification above. This is how easy it is:

```ruby
module MyApp
class UserNotificationLimiter < ::Pause::Action
# this is a redis key namespace added to all data in this action
scope 'un'

check period_seconds: 120,
max_allowed: 1,
block_ttl: 240

check period_seconds: 86400,
max_allowed: 3

check period_seconds: 7 *86400,
max_allowed: 7
end
end
```

> NOTE: for each check, `block_ttl` defaults to `period_seconds`, and represents the duration of time the action will consider itself as "rate limited" after a particular check reaches the limit. Note, that all actions will automatically leave the "rate limited" state after `block_ttl` seconds have passed.

#### Perform operation, but only if the user is not rate-limited

Now we simply instantiate this limiter by passing user ID (any unique identifier works). We can then ask the limiter, `ok?` or `rate_limited?`, or we can use two convenient methods that only execute enclosed block if the described condition is satisfied:

```ruby
class NotificationsWorker
def perform(user_id)
MyApp::UserNotificationLimiter.new(user_id) do
unless_rate_limited do
# this block ONLY runs if rate limit is not reached
user = User.find(user_id)
user.send_push_notification!
end

if_rate_limited do |rate_limit_event|
# this block ONLY runs if the action has reached it's rate limit.
Rails.logger.info("user #{user.id} has exceeded rate limit: #{rate_limit_event}")
end
end
end
end
```

That's it! Using these two methods you can pretty much ensure that your rate limits are always in check.

Sample applications include IP-based blocking based on HTTP request volume (see related gem "spanx"),
throttling push notifications as to not overwhelm the user with too much frequency, etc.

## Installation

Expand Down Expand Up @@ -86,47 +193,44 @@ In other words, if your shortest check is 1 minute, you could set resolution to
require 'pause'

class FollowAction < Pause::Action
scope "f"
scope 'fa' # keep those short
check period_seconds: 60, max_allowed: 100, block_ttl: 3600
check period_seconds: 1800, max_allowed: 2000, block_ttl: 3600
end
```

When an event occurs, you increment an instance of your action, optionally with a timestamp and count. This saves
data into a redis store, so it can be checked later by other processes. Timestamps should be in unix epoch format.
When an event occurs, you increment an instance of your action, optionally with a timestamp and count. This saves data into a redis store, so it can be checked later by other processes. Timestamps should be in unix epoch format.

In the example at the top of the README you saw how we used `#unless_rate_limited` and `#if_rate_limited` methods. These are the recommended API methods, but if you must get a finer-grained control over the actions, you can also use methods such as `#ok?`, `#rate_limited?`, `#increment!` to do manually what the block methods do already. Below is an example of this "manual" implementation:

```ruby
class FollowsController < ApplicationController
def create
action = FollowAction.new(user.id)
if action.ok?
# do stuff!
# and track it...
user.follow!
# and don't forget to track the "success"
action.increment!
else
# action is rate limited, either skip
# or show error, depending on the context.
end
end
end

class OtherController < ApplicationController
def index
action = OtherAction.new(params[:thing])
action = OtherAction.new(params[:thing])d
unless action.rate_limited?
# perform business logic
....
# track it
# but in this
action.increment!(params[:count].to_i, Time.now.to_i)
end
end
end
```

If more data is needed about why the action is blocked, the `analyze` can be called
If more data is needed about why the action is blocked, the `analyze` can be called:

```ruby
action = NotifyViaEmailAction.new("thing")
action = NotifyViaEmailAction.new(:thing)

while true
action.increment!
Expand Down Expand Up @@ -222,6 +326,26 @@ tracked identifiers.

The action block list is implemented as a sorted set, so it should still be usable when sharding.

## Testing

By default, `fakeredis` gem is used to emulate Redis in development. However, the same test-suite should be able to run against a real redis — however, be aware that it will flush the current db during spec run. In order to run specs against real redis, make sure you have Redis running locally on the default port, and that you are able to connect to it using `redis-cli`.

Please note that Travis suite, as well as the default rake task, run both.

### Unit Testing with Fakeredis

Fakeredis is the default, and is also run whenever `bundle exec rspec` is executed, or `rake spec` task invoked.

```bash
bundle exec rake spec:unit
```

### Integration Testing with Redis

```bash
bundle exec rake spec:integration
```

## Contributing

Want to make it better? Cool. Here's how:
Expand All @@ -234,8 +358,10 @@ Want to make it better? Cool. Here's how:

## Authors

This gem was written by Eric Saxby, Atasay Gokkaya and Konstantin Gredeskoul at Wanelo, Inc.
* This gem was written by Eric Saxby, Atasay Gokkaya and Konstantin Gredeskoul at Wanelo, Inc.
* It's been updated and refreshed by Konstantin Gredeskoul.


Please see the LICENSE.txt file for further details.
Please see the [LICENSE.txt](LICENSE.txt) file for further details.


45 changes: 43 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
require "bundler/gem_tasks"
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
require 'yard'

RSpec::Core::RakeTask.new(:spec)

task :default => :spec
task :default => %w(spec:unit spec:integration)

namespace :spec do
desc 'Run specs using fakeredis'
task :unit do
ENV['PAUSE_REAL_REDIS'] = nil
Rake::Task['spec'].execute
end
desc 'Run specs against a local Redis server'
task :integration do
ENV['PAUSE_REAL_REDIS'] = 'true'
Rake::Task['spec'].execute
end
end

def shell(*args)
puts "running: #{args.join(' ')}"
system(args.join(' '))
end

task :clean do
shell('rm -rf pkg/ tmp/ coverage/ doc/ ' )
end

task :gem => [:build] do
shell('gem install pkg/*')
end

task :permissions => [ :clean ] do
shell('chmod -v o+r,g+r * */* */*/* */*/*/* */*/*/*/* */*/*/*/*/*')
shell("find . -type d -exec chmod o+x,g+x {} \\;")
end

task :build => :permissions

YARD::Rake::YardocTask.new(:doc) do |t|
t.files = %w(lib/**/*.rb exe/*.rb - README.md LICENSE.txt)
t.options.unshift('--title','"Pause - Redis-backed Rate Limiter"')
t.after = ->() { exec('open doc/index.html') }
end

14 changes: 14 additions & 0 deletions bin/spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
#!/usr/bin/env bash

retry-errors() {
sleep 1
bundle exec rspec --only-failures
}

specs() {
bundle exec rspec && \
PAUSE_REAL_REDIS=true bundle exec rspec
}

specs || retry-errors

7 changes: 4 additions & 3 deletions lib/pause.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require 'redis'
require 'colored2'
require 'pause/version'
require 'pause/configuration'
require 'pause/action'
Expand Down Expand Up @@ -37,11 +38,11 @@ def adapter=(adapter)
end

def configure(&block)
@configuration = Pause::Configuration.new.configure(&block)
@configuration ||= Pause::Configuration.new.configure(&block)
end

def config
@configuration
def config(&block)
configure(&block)
end
end
end
Loading