clock-limiter 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
+ SHA256:
3
+ metadata.gz: 5721871b91f6bb4cfb4c8210ccbb86ce2629891da8b306a7d7432a851a59d6c9
4
+ data.tar.gz: 8f896c8d5d655f2cdd5316dd6e5f94524ffff3aaa74dabe53b7e9e34225fb4dd
5
+ SHA512:
6
+ metadata.gz: 0d4151bdde7cfb130e6476355466ce364725d9a992d1c8c8e452e4ec3f652c83efeebd61c1c0d7490b04ec2abae1723137ae2223ced2a069e09e96f5b9cb509d
7
+ data.tar.gz: 84d0701c8e1ade8f8fa4b4ff81a2f091e4b2a769e3c73cc1ede63505d6a1b06a799d79b31d198408924108e6b3481c9723ae7f7dcb25bbe61a1cc8aa4e9a4b22
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+ pull_request:
7
+ branches: [master]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ strategy:
14
+ matrix:
15
+ ruby-version: ["3.1", "3.0", "2.7", "2.6", "2.5"]
16
+
17
+ steps:
18
+ - uses: actions/checkout@v3
19
+ - name: Set up Ruby
20
+ uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1
21
+ with:
22
+ bundler-cache: true
23
+ ruby-version: ${{ matrix.ruby-version }}
24
+ - name: Install dependencies
25
+ run: bundle install
26
+ - name: Run tests
27
+ run: bundle exec rake test
@@ -0,0 +1,48 @@
1
+ name: Publish gem
2
+
3
+ on:
4
+ # Manually publish
5
+ workflow_dispatch:
6
+ # Alternatively, publish whenever a tag occurs.
7
+ push:
8
+ tags:
9
+ - "*"
10
+
11
+ jobs:
12
+ build:
13
+ name: Build + Publish
14
+ runs-on: ubuntu-latest
15
+ permissions:
16
+ packages: write
17
+ contents: read
18
+
19
+ steps:
20
+ - uses: actions/checkout@v3
21
+ - name: Set up Ruby 2.7
22
+ uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1
23
+ with:
24
+ ruby-version: 2.7
25
+ - run: bundle install
26
+
27
+ - name: Publish to GPR
28
+ run: |
29
+ mkdir -p $HOME/.gem
30
+ touch $HOME/.gem/credentials
31
+ chmod 0600 $HOME/.gem/credentials
32
+ printf -- "---\n:github: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
33
+ gem build *.gemspec
34
+ gem push --KEY github --host https://rubygems.pkg.github.com/${OWNER} *.gem
35
+ env:
36
+ GEM_HOST_API_KEY: "Bearer ${{secrets.GITHUB_TOKEN}}"
37
+ OWNER: ${{ github.repository_owner }}
38
+
39
+ - name: Publish to RubyGems
40
+ run: |
41
+ mkdir -p $HOME/.gem
42
+ touch $HOME/.gem/credentials
43
+ chmod 0600 $HOME/.gem/credentials
44
+ printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials
45
+ gem build *.gemspec
46
+ gem push *.gem
47
+ env:
48
+ GEM_HOST_API_KEY: "${{secrets.RUBYGEMS_AUTH_TOKEN}}"
@@ -0,0 +1,15 @@
1
+ name: Rubocop
2
+
3
+ on: [push]
4
+
5
+ jobs:
6
+ rubocop:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v3
10
+ - uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1
11
+ with:
12
+ ruby-version: 2.7
13
+ - run: gem install rubocop
14
+ - name: Rubocop
15
+ run: rubocop
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ Gemfile.lock
data/.rubocop.yml ADDED
@@ -0,0 +1,10 @@
1
+ Style/Documentation:
2
+ Enabled: false
3
+
4
+ Style/ClassAndModuleChildren:
5
+ Enabled: false
6
+
7
+ # General config to enable every `Pendent` cop
8
+ AllCops:
9
+ NewCops: enable
10
+ TargetRubyVersion: 2.5
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [v1.0.0](https://github.com/leadsimple/clock-limiter/tree/v1.0.0) (2023-07-21)
4
+
5
+ First Release :tada:!
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at rafaeelaudibert@gmail.com. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [https://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: https://contributor-covenant.org
74
+ [version]: https://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ group :development, :test do
6
+ gem 'fakeredis', '~> 0.9'
7
+ gem 'minitest', '~> 5.0'
8
+ gem 'rake', '~> 12.0'
9
+ end
10
+
11
+ # Specify your gem's dependencies in clock-limiter.gemspec
12
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Rafael Baldasso Audibert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # Clock::Limiter
2
+
3
+ Clock-based rate limiter which resets when the clock second/minute/etc. changes.
4
+
5
+ This is useful when you are having to deal with a third-party who implemented rate limits which reset when the minute or hour changes instead of having implemented it as a floating 60-second or 60-minute, respectively, window.
6
+
7
+ ## How it works
8
+
9
+ By setting keys in Redis keeping track of the current second/minute/etc in a carefully crafted key, we can guarantee we are using the current bucket (second, minute, etc.) at any time.
10
+
11
+ These keys are set to automatically expire in the future. They will stick around for at most the amount of time of the category they belong too, i.e. if it's a minute period, then the key will last for at most one minute in Redis. See [Periods](#periods)
12
+
13
+ ## Installation
14
+
15
+ Add this line to your application's Gemfile:
16
+
17
+ ```ruby
18
+ gem 'clock-limiter'
19
+ ```
20
+
21
+ And then execute:
22
+
23
+ $ bundle install
24
+
25
+ Or install it yourself as:
26
+
27
+ $ gem install clock-limiter
28
+
29
+ This gem requires Ruby >= 2.5.
30
+
31
+ ## Usage
32
+
33
+ First, you should configure this in a initializer such as `initializers/clock_limiter.rb`
34
+
35
+ ```ruby
36
+ Clock::Limiter.configure do |config|
37
+ config.redis = Redis.new # Must be something that implements `#get` and `#expire`
38
+ config.time_provider = -> { Time.now } # Must return a Time instance
39
+ end
40
+ ```
41
+
42
+ Then, you can include it in a class like so
43
+
44
+ ```ruby
45
+ class Limited
46
+ include Clock::Limiter
47
+
48
+ add_clock_limit(period: Clock::Limiter::Period::MINUTE, limit: 10)
49
+ add_clock_limit(period: Clock::Limiter::Period::SECOND, limit: 2)
50
+
51
+ on_clock_limit_failure do |limit|
52
+ puts "Limit #{limit} reached!"
53
+ end
54
+
55
+ def call
56
+ with_clock_limiter do
57
+ puts 'CALLED'
58
+ end
59
+ end
60
+ end
61
+ ```
62
+
63
+ You can then try it by calling the `#call` instance method repeatedly in less than a second.
64
+
65
+ ```ruby
66
+ limited = Limited.new
67
+
68
+ limited.call # CALLED
69
+ limited.call # CALLED
70
+ limited.call # Limit Clock::Limiter::Period::SECOND reached!
71
+ ```
72
+
73
+ The limit will be global across the `Limited` class.
74
+
75
+ ### Periods
76
+
77
+ The available periods are
78
+
79
+ - `Clock::Limiter::Period::SECOND`, where the key lasts for at most 1 second
80
+ - `Clock::Limiter::Period::MINUTE`, where the key lasts for at most 1 minute
81
+ - `Clock::Limiter::Period::HOUR`, where the key lasts for at most 1 hour
82
+ - `Clock::Limiter::Period::DAY`, where the key lasts for at most 24 hours
83
+ - `Clock::Limiter::Period::MONTH`, where the key lasts for at most 31 days
84
+ - `Clock::Limiter::Period::YEAR`, where the key lasts for at most 366 days
85
+
86
+ ### Advanced Usage
87
+
88
+ `with_clock_limiter` accepts an optional argument called `custom_key` which can be used if you don't want the rate limit to be global across the class, but want it to be based on a specific key instead - if you have different rate limits, for different accounts, this might be what you need.
89
+
90
+ ```ruby
91
+ class Limited
92
+ include Clock::Limiter
93
+
94
+ add_clock_limit(period: Clock::Limiter::Period::MINUTE, limit: 10)
95
+
96
+ on_clock_limit_failure do |limit, key|
97
+ puts "Limit #{limit} for key #{key} reached!"
98
+ end
99
+
100
+ def call
101
+ with_clock_limiter("KEY_1") do
102
+ puts 'CALLED'
103
+ end
104
+
105
+ with_clock_limiter("KEY_2") do
106
+ puts 'CALLED 2'
107
+ end
108
+ end
109
+ end
110
+ ```
111
+
112
+ ## Development
113
+
114
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
115
+
116
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
117
+
118
+ ## Contributing
119
+
120
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/clock-limiter. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/clock-limiter/blob/master/CODE_OF_CONDUCT.md).
121
+
122
+ ## License
123
+
124
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
125
+
126
+ ## Code of Conduct
127
+
128
+ Everyone interacting in the Clock::Limiter project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/clock-limiter/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb']
10
+ end
11
+
12
+ task default: :test
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+ require 'clock/limiter'
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require 'irb'
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/clock/limiter/version'
4
+
5
+ Gem::Specification.new do |spec| # rubocop:disable Gemspec/RequireMFA because we want to deploy this through the GitHub Actions workflow
6
+ spec.name = 'clock-limiter'
7
+ spec.version = Clock::Limiter::VERSION
8
+ spec.authors = ['Rafael Baldasso Audibert']
9
+ spec.email = ['engineering+clock-limiter@leadsimple.com']
10
+
11
+ spec.summary = 'Clock-based rate limiter which resets when the clock second/minute/etc. changes.'
12
+ spec.homepage = 'https://github.com/leadsimple/clock-limiter'
13
+ spec.license = 'MIT'
14
+ spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0')
15
+
16
+ spec.metadata['homepage_uri'] = spec.homepage
17
+ spec.metadata['source_code_uri'] = 'https://github.com/leadsimple/clock-limiter'
18
+ spec.metadata['changelog_uri'] = 'https://github.com/leadsimple/clock-limiter/blob/master/CHANGELOG.md'
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
24
+ end
25
+ spec.bindir = 'exe'
26
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
+ spec.require_paths = ['lib']
28
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis'
4
+
5
+ module Clock
6
+ module Limiter
7
+ class Configuration
8
+ class ConfigurationError < StandardError; end
9
+
10
+ # We want to guarantee we are only using valid Redis instances.
11
+ # We could restrict to a `::Redis` instance, but that's not always
12
+ # worth it, because we might need to use a Redis client that is not
13
+ # a `::Redis` instance - such as `MockRedis` for example
14
+ def self.valid_redis?(redis)
15
+ redis.respond_to?(:incr) && redis.respond_to?(:expire)
16
+ end
17
+
18
+ # @param redis [::Redis-like]
19
+ def redis=(redis)
20
+ raise ConfigurationError, '`redis` must implement `#incr` and `#expire`' unless self.class.valid_redis?(redis)
21
+
22
+ @redis = redis
23
+ end
24
+
25
+ # @return [::Redis-like]
26
+ def redis
27
+ raise ConfigurationError, 'Redis not configured' if @redis.nil?
28
+
29
+ @redis
30
+ end
31
+
32
+ # @param time_provider [Proc]
33
+ def time_provider=(time_provider)
34
+ raise ConfigurationError, '`time_provider` must be a Proc' unless time_provider.is_a?(Proc)
35
+
36
+ @time_provider = time_provider
37
+ end
38
+
39
+ # @return [Proc]
40
+ def time_provider
41
+ raise ConfigurationError, 'Time provider not configured' if @time_provider.nil?
42
+
43
+ @time_provider
44
+ end
45
+
46
+ # @param fail_with_empty_limits [Boolean]
47
+ def fail_with_empty_limits=(fail_with_empty_limits)
48
+ unless [true, false].include?(fail_with_empty_limits)
49
+ raise ConfigurationError, '`fail_with_empty_limits` must be a boolean'
50
+ end
51
+
52
+ @fail_with_empty_limits = fail_with_empty_limits
53
+ end
54
+
55
+ # @return [Boolean] - true by default
56
+ def fail_with_empty_limits?
57
+ return @fail_with_empty_limits unless @fail_with_empty_limits.nil?
58
+
59
+ true
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clock
4
+ module Limiter
5
+ Limit = Struct.new(:period, :limit, keyword_init: true) do
6
+ def initialize(period:, limit:)
7
+ raise Clock::Limiter::Period::InvalidError, period unless Period::VALID_PERIODS.include?(period)
8
+
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clock
4
+ module Limiter
5
+ class NoLimitsError < StandardError
6
+ def initialize
7
+ super('At least one limit must be set')
8
+ end
9
+ end
10
+
11
+ # Configure the singleton instance of this class.
12
+ class << self
13
+ # Instantiate the Configuration singleton or return it.
14
+ def configuration
15
+ @configuration ||= Configuration.new
16
+ end
17
+
18
+ # This is the configure block definition.
19
+ # The configuration method will return the
20
+ # Configuration singleton, which is then yielded
21
+ # to the configure block.
22
+ def configure
23
+ yield(configuration)
24
+ end
25
+
26
+ def included(base)
27
+ base.extend(ClassMethods)
28
+ end
29
+
30
+ module ClassMethods
31
+ attr_reader :clock_limits, :on_clock_limit_failure_block
32
+
33
+ # Adds a new value to the `clock_limits` array which will be used to check if
34
+ # the limit has been reached or not.
35
+ #
36
+ # @param [Clock::Limiter::Period] period
37
+ # @param [Integer] limit
38
+ def add_clock_limit(period:, limit:)
39
+ @clock_limits ||= []
40
+ @clock_limits << Limit.new(period: period, limit: limit)
41
+ end
42
+
43
+ # Sets the block that will be called when the limit has been reached.
44
+ #
45
+ # @param [Proc] block
46
+ def on_clock_limit_failure(&block)
47
+ @on_clock_limit_failure_block = block
48
+ end
49
+ end
50
+ end
51
+
52
+ # Runs the block if the limit has not been reached. If the limit has been
53
+ # reached, the block set with `on_clock_limit_failure` will be called.
54
+ # If no limits have been set, a `NoLimitsError` will be raised.
55
+ # If the limit has been reached, the block set with `on_clock_limit_failure` will
56
+ # be called.
57
+ # If the limit has not been reached, the block passed to this method will
58
+ # be called.
59
+ #
60
+ # @param [String] custom_key Custom key if this limit is not global to the class
61
+ # @yield
62
+ # @return [Object] The return value of the block passed to this method
63
+ # @raise [NoLimitsError] If no limits have been set
64
+ # @raise [Clock::Limiter::Period::InvalidError] If an invalid period has been set
65
+ def with_clock_limiter(custom_key = self.class.name)
66
+ raise NoLimitsError if fail_with_empty_limits?
67
+
68
+ self.class.clock_limits&.each do |limit|
69
+ next if within_limit?(limit, custom_key)
70
+
71
+ return self.class.on_clock_limit_failure_block&.call(limit, custom_key)
72
+ end
73
+
74
+ yield
75
+ end
76
+
77
+ private
78
+
79
+ def fail_with_empty_limits?
80
+ Clock::Limiter.configuration.fail_with_empty_limits? &&
81
+ (self.class.clock_limits.nil? || self.class.clock_limits.empty?)
82
+ end
83
+
84
+ # Increments the value of the key and returns true if the limit has not
85
+ # been reached. If the limit has been reached, false will be returned.
86
+ #
87
+ # @param [Clock::Limiter::Limit] limit
88
+ # @param [String] custom_key
89
+ def within_limit?(limit, custom_key)
90
+ key, ttl = key_and_ttl(limit, custom_key)
91
+
92
+ value = Clock::Limiter.configuration.redis.incr(key)
93
+ return false if value > limit.limit
94
+
95
+ # If value was first set, then set expiration
96
+ Clock::Limiter.configuration.redis.expire(key, ttl) if value == 1
97
+
98
+ true
99
+ end
100
+
101
+ # Returns the key and ttl for the given limit and group key.
102
+ #
103
+ # @param [Clock::Limiter::Limit] limit
104
+ # @param [String] custom_key
105
+ def key_and_ttl(limit, custom_key) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
106
+ case limit.period
107
+ when Period::SECOND
108
+ [key(limit.period, custom_key), 1]
109
+ when Period::MINUTE
110
+ [key(limit.period, custom_key), 60] # 1 * 60
111
+ when Period::HOUR
112
+ [key(limit.period, custom_key), 3_600] # 1 * 60 * 60
113
+ when Period::DAY
114
+ [key(limit.period, custom_key), 86_400] # 1 * 60 * 60 * 24
115
+ when Period::MONTH
116
+ [key(limit.period, custom_key), 2_678_400] # 1 * 60 * 60 * 24 * 31 (worst case, 31 days)
117
+ when Period::YEAR
118
+ [key(limit.period, custom_key), 31_622_400] # 1 * 60 * 60 * 24 * 366 (worst case, 366 days)
119
+ else
120
+ raise Period::InvalidError(limit.period)
121
+ end
122
+ end
123
+
124
+ # Returns the key for the given period and group key.
125
+ #
126
+ # @param [Clock::Limiter::Period] period
127
+ # @param [String] custom_key
128
+ def key(period, custom_key)
129
+ "clock-limiter:#{custom_key}:#{period}:#{current_period(period)}"
130
+ end
131
+
132
+ # Returns the current period for the given period.
133
+ # i.e. if the period is Period::SECOND, then we'll return the current second
134
+ # according to the [Clock::Limiter::Configuration#time_provider]
135
+ #
136
+ # @param [Clock::Limiter::Period] period
137
+ def current_period(period) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
138
+ case period
139
+ when Period::SECOND
140
+ Clock::Limiter.configuration.time_provider.call.sec
141
+ when Period::MINUTE
142
+ Clock::Limiter.configuration.time_provider.call.min
143
+ when Period::HOUR
144
+ Clock::Limiter.configuration.time_provider.call.hour
145
+ when Period::DAY
146
+ Clock::Limiter.configuration.time_provider.call.day
147
+ when Period::MONTH
148
+ Clock::Limiter.configuration.time_provider.call.month
149
+ when Period::YEAR
150
+ Clock::Limiter.configuration.time_provider.call.year
151
+ else
152
+ raise Period::InvalidError(period)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clock
4
+ module Limiter
5
+ module Period
6
+ SECOND = :second
7
+ MINUTE = :minute
8
+ HOUR = :hour
9
+ DAY = :day
10
+ MONTH = :month
11
+ YEAR = :year
12
+
13
+ VALID_PERIODS = [SECOND, MINUTE, HOUR, DAY, MONTH, YEAR].freeze
14
+
15
+ class InvalidError < StandardError
16
+ attr_reader :period
17
+
18
+ def initialize(period)
19
+ @period = period
20
+ super("Invalid period #{period}. Valid periods: #{VALID_PERIODS}")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clock
4
+ module Limiter
5
+ VERSION = '1.0.0'
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'clock/limiter/version'
4
+ require 'clock/limiter/configuration'
5
+ require 'clock/limiter/period'
6
+ require 'clock/limiter/limit'
7
+ require 'clock/limiter/limiter'
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: clock-limiter
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Rafael Baldasso Audibert
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-07-24 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - engineering+clock-limiter@leadsimple.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - ".github/workflows/ci.yml"
21
+ - ".github/workflows/publish.yml"
22
+ - ".github/workflows/rubocop.yml"
23
+ - ".gitignore"
24
+ - ".rubocop.yml"
25
+ - CHANGELOG.md
26
+ - CODE_OF_CONDUCT.md
27
+ - Gemfile
28
+ - LICENSE.txt
29
+ - README.md
30
+ - Rakefile
31
+ - bin/console
32
+ - bin/setup
33
+ - clock-limiter.gemspec
34
+ - lib/clock/limiter.rb
35
+ - lib/clock/limiter/configuration.rb
36
+ - lib/clock/limiter/limit.rb
37
+ - lib/clock/limiter/limiter.rb
38
+ - lib/clock/limiter/period.rb
39
+ - lib/clock/limiter/version.rb
40
+ homepage: https://github.com/leadsimple/clock-limiter
41
+ licenses:
42
+ - MIT
43
+ metadata:
44
+ homepage_uri: https://github.com/leadsimple/clock-limiter
45
+ source_code_uri: https://github.com/leadsimple/clock-limiter
46
+ changelog_uri: https://github.com/leadsimple/clock-limiter/blob/master/CHANGELOG.md
47
+ post_install_message:
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 2.5.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.1.6
63
+ signing_key:
64
+ specification_version: 4
65
+ summary: Clock-based rate limiter which resets when the clock second/minute/etc. changes.
66
+ test_files: []