rack-ratelimit 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 73b500a4dca2655841490da8c02106a49f69f62b
4
+ data.tar.gz: 265204cbba7c818c0190eee355d55a7147083d1a
5
+ SHA512:
6
+ metadata.gz: da84278754fd180b780687f5819f4847d2e8fcf49378029e023b95706324b3b06eedd5cedf1c3b453e26723ed8b409ea78799e190497076db3411be9f9f55081
7
+ data.tar.gz: 2af1a03a36288b8cbec652185ff1207b3e9052b58d3c0737c7a07159f349eeadaacf42c79acfde8ad2ae8a46f011b61fd04619d21b629117ae1f059ec4e9d7b3
@@ -0,0 +1 @@
1
+ require 'rack/ratelimit'
@@ -0,0 +1,162 @@
1
+ require 'dalli'
2
+ require 'logger'
3
+ require 'time'
4
+
5
+ module Rack
6
+ # = Ratelimit
7
+ #
8
+ # * Run multiple rate limiters in a single app
9
+ # * Scope each rate limit to certain requests: API, files, GET vs POST, etc.
10
+ # * Apply each rate limit by request characteristics: IP, subdomain, OAuth2 token, etc.
11
+ # * Flexible time window to limit burst traffic vs hourly or daily traffic:
12
+ # 100 requests per 10 sec, 500 req/minute, 10000 req/hour, etc.
13
+ # * Fast, low-overhead implementation using memcache counters per time window:
14
+ # timeslice = window * ceiling(current time / window)
15
+ # memcache.incr(counter for timeslice)
16
+ class Ratelimit
17
+ # Takes a block that classifies requests for rate limiting. Given a
18
+ # Rack env, return a string such as IP address, API token, etc. If the
19
+ # block returns nil, the request won't be rate-limited. If a block is
20
+ # not given, all requests get the same limits.
21
+ #
22
+ # Required configuration:
23
+ # rate: an array of [max requests, period in seconds]: [500, 5.minutes]
24
+ # cache: a Dalli::Client instance, or an object that quacks like it.
25
+ #
26
+ # Optional configuration:
27
+ # name: name of the rate limiter. Defaults to 'HTTP'. Used in messages.
28
+ # status: HTTP response code. Defaults to 429.
29
+ # conditions: array of procs that take a rack env, all of which must
30
+ # return true to rate-limit the request.
31
+ # exceptions: array of procs that take a rack env, any of which may
32
+ # return true to exclude the request from rate limiting.
33
+ # logger: responds to #info(message). If provided, the rate limiter
34
+ # logs the first request that hits the rate limit, but none of the
35
+ # subsequently blocked requests.
36
+ # error_message: the message returned in the response body when the rate
37
+ # limit is exceeded. Defaults to "<name> rate limit exceeded. Please
38
+ # wait <period> seconds then retry your request."
39
+ #
40
+ # Example:
41
+ #
42
+ # Rate-limit bursts of POST/PUT/DELETE by IP address, return 503:
43
+ # use(Rack::Ratelimit, name: 'POST',
44
+ # exceptions: ->(env) { env['REQUEST_METHOD'] == 'GET' },
45
+ # rate: [50, 10.seconds],
46
+ # status: 503,
47
+ # cache: Dalli::Client.new,
48
+ # logger: Rails.logger) { |env| Rack::Request.new(env).ip }
49
+ #
50
+ # Rate-limit API traffic by user (set by Rack::Auth::Basic):
51
+ # use(Rack::Ratelimit, name: 'API',
52
+ # conditions: ->(env) { env['REMOTE_USER'] },
53
+ # rate: [1000, 1.hour],
54
+ # cache: Dalli::Client.new,
55
+ # logger: Rails.logger) { |env| env['REMOTE_USER'] }
56
+ def initialize(app, options, &classifier)
57
+ @app, @classifier = app, classifier
58
+ @classifier ||= lambda { |env| :request }
59
+
60
+ @name = options.fetch(:name, 'HTTP')
61
+ @max, @period = options.fetch(:rate)
62
+ @status = options.fetch(:status, 429)
63
+
64
+ @counter = Counter.new(options.fetch(:cache), @name, @period)
65
+
66
+ @logger = options[:logger]
67
+ @error_message = options.fetch(:error_message, "#{@name} rate limit exceeded. Please wait #{@period} seconds then retry your request.")
68
+
69
+ @conditions = Array(options[:conditions])
70
+ @exceptions = Array(options[:exceptions])
71
+ end
72
+
73
+ # Add a condition that must be met before applying the rate limit.
74
+ # Pass a block or a proc argument that takes a Rack env and returns
75
+ # true if the request should be limited.
76
+ def condition(predicate = nil, &block)
77
+ @conditions << predicate if predicate
78
+ @conditions << block if block_given?
79
+ end
80
+
81
+ # Add an exception that excludes requests from the rate limit.
82
+ # Pass a block or a proc argument that takes a Rack env and returns
83
+ # true if the request should be excluded from rate limiting.
84
+ def exception(predicate = nil, &block)
85
+ @exceptions << predicate if predicate
86
+ @exceptions << block if block_given?
87
+ end
88
+
89
+ # Apply the rate limiter if none of the exceptions apply and all the
90
+ # conditions are met.
91
+ def apply_rate_limit?(env)
92
+ @exceptions.none? { |e| e.call(env) } && @conditions.all? { |c| c.call(env) }
93
+ end
94
+
95
+ # Handle a Rack request:
96
+ # * Check whether the rate limit applies to the request.
97
+ # * Classify the request by IP, API token, etc.
98
+ # * Calculate the end of the current time window.
99
+ # * Increment the counter for this classification and time window.
100
+ # * If count exceeds limit, return a 429 response.
101
+ # * If it's the first request that exceeds the limit, log it.
102
+ # * If the count doesn't exceed the limit, pass through the request.
103
+ def call(env)
104
+ if apply_rate_limit?(env) && classification = @classifier.call(env)
105
+
106
+ # Marks the end of the current rate-limiting window.
107
+ timestamp = @period * (Time.now.to_f / @period).ceil
108
+ time = Time.at(timestamp).utc.xmlschema
109
+
110
+ # Increment the request counter.
111
+ count = @counter.increment(classification, timestamp)
112
+ remaining = @max - count + 1
113
+
114
+ json = %({"name":"#{@name}","period":#{@period},"limit":#{@max},"remaining":#{remaining},"until":"#{time}"})
115
+
116
+ # If exceeded, return a 429 Rate Limit Exceeded response.
117
+ if remaining <= 0
118
+ # Only log the first hit that exceeds the limit.
119
+ if @logger && remaining == 0
120
+ @logger.info '%s: %s exceeded %d request limit for %s' % [@name, classification, @max, time]
121
+ end
122
+
123
+ [ @status,
124
+ { 'X-Ratelimit' => json, 'Retry-After' => @period.to_s },
125
+ [@error_message] ]
126
+
127
+ # Otherwise, pass through then add some informational headers.
128
+ else
129
+ @app.call(env).tap do |status, headers, body|
130
+ headers['X-Ratelimit'] = [headers['X-Ratelimit'], json].compact.join("\n")
131
+ end
132
+ end
133
+ else
134
+ @app.call(env)
135
+ end
136
+ end
137
+
138
+ class Counter
139
+ def initialize(cache, name, period)
140
+ @cache, @name, @period = cache, name, period
141
+ end
142
+
143
+ # Increment the request counter and return the current count.
144
+ def increment(classification, timestamp)
145
+ key = 'rack-ratelimit/%s/%s/%i' % [@name, classification, timestamp]
146
+
147
+ # Try to increment the counter if it's present.
148
+ if count = @cache.incr(key, 1)
149
+ count.to_i
150
+
151
+ # If not, add the counter and set expiry.
152
+ elsif @cache.add(key, 1, @period, :raw => true)
153
+ 1
154
+
155
+ # If adding failed, someone else added it concurrently. Increment.
156
+ else
157
+ @cache.incr(key, 1).to_i
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rack-ratelimit
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Jeremy Kemper
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-03-21 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dalli
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: minitest
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 5.3.0
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 5.3.0
69
+ description:
70
+ email: jeremy@bitsweat.net
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - "./lib/rack-ratelimit.rb"
76
+ - "./lib/rack/ratelimit.rb"
77
+ homepage:
78
+ licenses:
79
+ - MIT
80
+ metadata: {}
81
+ post_install_message:
82
+ rdoc_options: []
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '1.8'
90
+ required_rubygems_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ requirements: []
96
+ rubyforge_project:
97
+ rubygems_version: 2.2.0
98
+ signing_key:
99
+ specification_version: 4
100
+ summary: Flexible rate limits for your Rack apps
101
+ test_files: []