excess_flow 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.
@@ -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