rack-ratelimit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: []