excess_flow 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::Configuration
19
+ #
20
+ # Holds configuration for rate limiter with writeable attributes allowing
21
+ # dynamic change of configuration during runtime
22
+ class Configuration
23
+ attr_accessor(
24
+ :connection_pool,
25
+ :connection_timeout,
26
+ :redis_url,
27
+ :sentinels
28
+ )
29
+
30
+ def initialize
31
+ @connection_pool = extract_connection_pool
32
+ @connection_timeout = extract_connection_timeout
33
+ @redis_url = extract_redis_url
34
+ @sentinels = process_sentinels
35
+ end
36
+
37
+ private
38
+
39
+ def extract_connection_pool
40
+ ENV.fetch(
41
+ 'EXCESS_FLOW_CONNECTION_POOL',
42
+ ExcessFlow::DEFAULT_CONNECTION_POOL
43
+ ).to_i
44
+ end
45
+
46
+ def extract_connection_timeout
47
+ ENV.fetch(
48
+ 'EXCESS_FLOW_CONNECTION_TIMEOUT',
49
+ ExcessFlow::DEFAULT_CONNECTION_TIMEOUT
50
+ ).to_i
51
+ end
52
+
53
+ def extract_redis_url
54
+ ENV.fetch(
55
+ 'EXCESS_FLOW_REDIS_URL',
56
+ ExcessFlow::DEFAULT_REDIS_URL
57
+ )
58
+ end
59
+
60
+ def process_sentinels
61
+ ENV.fetch('EXCESS_FLOW_REDIS_SENTINELS', '').split(',').map do |sentinel|
62
+ host, port = sentinel.split(':')
63
+ { host: host, port: port.to_i }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::ConfigurationError
19
+ #
20
+ # Error used in ThrottleConfiguration class
21
+ class ConfigurationError < StandardError; end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ CONFIGURATION_ERROR_MESSAGE = 'Invalid arguments provided. Please refer to README.md'
19
+ COUNTER_PREFIX = 'excess_flow::counter::'
20
+ DEFAULT_CONNECTION_POOL = 100
21
+ DEFAULT_CONNECTION_TIMEOUT = 3
22
+ DEFAULT_REDIS_URL = 'redis://localhost:6379/1'
23
+ LOCK_PREFIX = 'excess_flow::lock::'
24
+ MUTEX_LOCK_TIME = 1
25
+ MUTEX_SLEEP_TIME = 1 / 100_000
26
+ VERSION = '1.0.0'
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ class FailedExecution; end
19
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::FixedWindowStrategy
19
+ #
20
+ # Definition of fixed window rate limiting strategy and it's behaviour
21
+ # implementations. Fixed window allows only N requests for a given key to be
22
+ # done in a O time window where O start is defined at the time of first
23
+ # request. Window expiration starts with the first request for a given key.
24
+ # Once expired it will reset back to 0.
25
+ class FixedWindowStrategy < ExcessFlow::Strategy
26
+ def within_rate_limit?
27
+ ExcessFlow::GlobalMutex.locked(lock_key: configuration.lock_key) do
28
+ if current_requests < configuration.limit
29
+ bump_counter
30
+ start_expiration_window
31
+
32
+ true
33
+ else
34
+ false
35
+ end
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def bump_counter
42
+ ExcessFlow.redis { |r| r.incr(configuration.counter_key) }
43
+ end
44
+
45
+ def current_requests
46
+ @current_requests ||= ExcessFlow.redis { |r| r.get(configuration.counter_key) }.to_i
47
+ end
48
+
49
+ def start_expiration_window
50
+ return if current_requests.zero?
51
+
52
+ ExcessFlow.redis { |r| r.expire(configuration.counter_key, configuration.ttl) }
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::GlobalMutex
19
+ #
20
+ # Attempts to set up exclusive lock to execute a block of code. If another
21
+ # lock is in place then GlobalMutex will wait till lock is available. Always
22
+ # returns result of execution. GlobalMutex does not guarantee order of execution;
23
+ # it will only guarantee that only one thread for a given lock_key is running to
24
+ # avoid race conditions.
25
+ class GlobalMutex
26
+ attr_reader :lock_key
27
+
28
+ def self.locked(lock_key:, &block)
29
+ new(lock_key).locked(&block)
30
+ end
31
+
32
+ def initialize(lock_key)
33
+ @lock_key = lock_key
34
+ end
35
+
36
+ def locked(&block)
37
+ sleep(ExcessFlow::MUTEX_SLEEP_TIME) until lock
38
+ result = block.call
39
+ unlock
40
+
41
+ result
42
+ end
43
+
44
+ private
45
+
46
+ def lock
47
+ ExcessFlow.redis { |r| r.set(lock_key, 1, nx: true, ex: ExcessFlow::MUTEX_LOCK_TIME) }
48
+ end
49
+
50
+ def unlock
51
+ ExcessFlow.redis { |r| r.expire(lock_key, 0) }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::RateLimitedExecutionResult
19
+ #
20
+ # Wrapper containing result of rate limited execution
21
+ class RateLimitedExecutionResult
22
+ attr_reader :result
23
+
24
+ def initialize(result)
25
+ @result = result
26
+ end
27
+
28
+ def success?
29
+ !result.is_a?(FailedExecution)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::RedisConnection
19
+ #
20
+ # Wrapper around ConnectionPool and Redis to provide connectivity
21
+ # to Redis with desired configuration and sane connection pool
22
+ module RedisConnection
23
+ module_function
24
+
25
+ def connection_pool
26
+ @connection_pool = ConnectionPool.new(connection_pool_options) do
27
+ Redis.new(connection_options)
28
+ end
29
+ end
30
+
31
+ def connection_options
32
+ {
33
+ url: redis_url,
34
+ sentinels: sentinels
35
+ }.delete_if { |_k, v| v.nil? || v.empty? }
36
+ end
37
+
38
+ def connection_pool_options
39
+ {
40
+ size: pool_size,
41
+ timeout: connection_timeout
42
+ }
43
+ end
44
+
45
+ def connection_timeout
46
+ ExcessFlow.configuration.connection_timeout
47
+ end
48
+
49
+ def pool_size
50
+ ExcessFlow.configuration.connection_pool
51
+ end
52
+
53
+ def redis_url
54
+ ExcessFlow.configuration.redis_url
55
+ end
56
+
57
+ def sentinels
58
+ ExcessFlow.configuration.sentinels
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::SlidingWindowStrategy
19
+ #
20
+ # Definition of sliging window rate limiting strategy and it's behaviour
21
+ # implementations. Sliding window allows only N requests for a given key to
22
+ # be done in a trailing O time window where O start is defined as `now` -
23
+ # `window_size`. Window expiration starts with the first request for a given
24
+ # key. Once expired it will reset back to 0.
25
+ class SlidingWindowStrategy < ExcessFlow::Strategy
26
+ def within_rate_limit?
27
+ ExcessFlow::GlobalMutex.locked(lock_key: configuration.lock_key) do
28
+ cleanup_stale_counters
29
+
30
+ if current_requests < configuration.limit
31
+ bump_counter
32
+ start_expiration_window
33
+
34
+ true
35
+ else
36
+ false
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def bump_counter
44
+ ExcessFlow.redis do |r|
45
+ r.zadd(
46
+ configuration.counter_key,
47
+ current_timestamp,
48
+ current_timestamp
49
+ )
50
+ end
51
+ end
52
+
53
+ def cleanup_stale_counters
54
+ ExcessFlow.redis do |r|
55
+ r.zremrangebyscore(configuration.counter_key, 0, "(#{window_start}")
56
+ end
57
+ end
58
+
59
+ def current_requests
60
+ @current_requests ||= ExcessFlow.redis { |r| r.zcount(configuration.counter_key, '-inf', '+inf') }
61
+ end
62
+
63
+ def current_timestamp
64
+ @current_timestamp ||= (Time.now.to_f * 100_000).to_i
65
+ end
66
+
67
+ def start_expiration_window
68
+ return if current_requests.zero?
69
+
70
+ ExcessFlow.redis { |r| r.expire(configuration.counter_key, configuration.ttl) }
71
+ end
72
+
73
+ def window_start
74
+ @window_start ||= current_timestamp - (configuration.ttl * 100_000)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::Strategy
19
+ #
20
+ # Base class for implementing different strategies for rate limiting.
21
+ class Strategy
22
+ attr_reader :configuration
23
+
24
+ def self.execute(configuration:, &block)
25
+ new(configuration).execute(&block)
26
+ end
27
+
28
+ def initialize(configuration)
29
+ @configuration = configuration
30
+ end
31
+
32
+ def execute
33
+ result = if within_rate_limit?
34
+ yield
35
+ else
36
+ FailedExecution.new
37
+ end
38
+
39
+ RateLimitedExecutionResult.new(result)
40
+ end
41
+
42
+ def within_rate_limit?
43
+ true
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::ThrottleConfiguration
19
+ #
20
+ # Wrapper class for throttle execution result that does provide some basic
21
+ # transformation of provided values.
22
+ class ThrottleConfiguration
23
+ MANDATORY_KEYS = %i[
24
+ key
25
+ limit
26
+ ttl
27
+ ].freeze
28
+
29
+ OPTIONAL_KEYS = %i[
30
+ strategy
31
+ ].freeze
32
+
33
+ attr_reader :key, :limit, :ttl
34
+
35
+ def initialize(args)
36
+ @raw_args = args
37
+ validate_args
38
+
39
+ args.each do |key, value|
40
+ instance_variable_set("@#{key}", value) unless value.nil?
41
+ end
42
+ end
43
+
44
+ def counter_key
45
+ ExcessFlow::COUNTER_PREFIX + key
46
+ end
47
+
48
+ def lock_key
49
+ ExcessFlow::LOCK_PREFIX + key
50
+ end
51
+
52
+ def strategy
53
+ case @strategy
54
+ when :fixed_window then ExcessFlow::FixedWindowStrategy
55
+ when :sliding_window then ExcessFlow::SlidingWindowStrategy
56
+ else ExcessFlow::FixedWindowStrategy
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def allowed_keys_passed_in?
63
+ (@raw_args.keys - (MANDATORY_KEYS + OPTIONAL_KEYS)).empty?
64
+ end
65
+
66
+ def mandatory_keys_are_present?
67
+ (MANDATORY_KEYS - @raw_args.keys).empty?
68
+ end
69
+
70
+ def validate_args
71
+ return if allowed_keys_passed_in? && mandatory_keys_are_present?
72
+
73
+ raise ExcessFlow::ConfigurationError, CONFIGURATION_ERROR_MESSAGE
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2019 ConvertKit, LLC
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ module ExcessFlow
18
+ # == ExcessFlow::ThrottledExecutor
19
+ #
20
+ # Wrapper service class that will take care of initialization of configuration
21
+ # object and will execute on correct throttling strategy.
22
+ class ThrottledExecutor
23
+ attr_reader :args
24
+
25
+ def self.select_strategy_and_execute(args, &block)
26
+ new(args).select_strategy_and_execute(&block)
27
+ end
28
+
29
+ def initialize(args)
30
+ @args = args
31
+ end
32
+
33
+ def select_strategy_and_execute(&block)
34
+ strategy.execute(configuration: configuration, &block)
35
+ end
36
+
37
+ private
38
+
39
+ def configuration
40
+ @configuration ||= ExcessFlow::ThrottleConfiguration.new(args)
41
+ end
42
+
43
+ def strategy
44
+ configuration.strategy
45
+ end
46
+ end
47
+ end