hanikamu-rate-limit 0.1.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
+ SHA256:
3
+ metadata.gz: af12b9492738472c4e8bd8431a20b053bde92ec6af556dcd6db9af75c9529e70
4
+ data.tar.gz: '0187f0d7f9d9edde19f5b095368b19feb90c73a51d25301e356161930392ccaf'
5
+ SHA512:
6
+ metadata.gz: b77fb01c48f98d938bdbd4954672be5a3f2fbbed522d4c662349d4b3e6c98327893d7849bc8b8c4936cf33e13af7e5487805f4da6b7451ff4274471d0e42363b
7
+ data.tar.gz: 2bbe7515db8c173aab88c2ee198d0b9a4fb1015091d74706bb0045a31c37166cb1073a29806937db4057e279183c51fe5fd0e7e804a258d4463e29d2fca0ba04
data/.DS_Store ADDED
Binary file
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-02-04
4
+
5
+ - Initial release of Hanikamu::RateLimit.
data/Dockerfile ADDED
@@ -0,0 +1,14 @@
1
+ # Base image
2
+ FROM ruby:4.0
3
+
4
+ WORKDIR "/app"
5
+
6
+ # Add our Gemfile and install gems
7
+ ADD Gemfile* ./
8
+ ADD hanikamu-rate-limit.gemspec ./
9
+ ADD lib ./lib
10
+
11
+ RUN bundle install
12
+
13
+ # Copy the rest of the application
14
+ ADD . .
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hanikamu
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/Makefile ADDED
@@ -0,0 +1,25 @@
1
+ .PHONY: shell
2
+ shell: ## access to the system console
3
+ docker-compose run --rm app bash
4
+
5
+ .PHONY: build
6
+ build: ## build the image
7
+ docker-compose build
8
+
9
+ .PHONY: bundle
10
+ bundle: ## install gems and rebuild image
11
+ - docker-compose run --rm app bundle install
12
+ - ${MAKE} build
13
+
14
+ .PHONY: console
15
+ console: ## build the image
16
+ docker-compose run --rm app bash -c "bin/console"
17
+
18
+ .PHONY: rspec
19
+ rspec: ## build the image
20
+ docker-compose run --rm app bash -c "bundle exec rspec"
21
+
22
+
23
+ .PHONY: cops
24
+ cops: ## build the image
25
+ docker-compose run --rm app sh -c "bundle exec rubocop -A"
data/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # Hanikamu::RateLimit
2
+
3
+ [![ci](https://github.com/Hanikamu/hanikamu-rate-limit/actions/workflows/ci.yml/badge.svg)](https://github.com/Hanikamu/hanikamu-rate-limit/actions/workflows/ci.yml)
4
+
5
+ Distributed, Redis-backed rate limiting with a sliding window algorithm. Works across processes and threads by coordinating through Redis.
6
+
7
+ ## Table of Contents
8
+
9
+ 1. [Why Hanikamu::RateLimit?](#why-hanikamurate-limit)
10
+ 2. [Quick Start](#quick-start)
11
+ 3. [Configuration](#configuration)
12
+ 4. [Usage](#usage)
13
+ 5. [Error Handling](#error-handling)
14
+ 6. [Testing](#testing)
15
+ 7. [Development](#development)
16
+ 8. [License](#license)
17
+
18
+ ## Why Hanikamu::RateLimit?
19
+
20
+ - **Use case**: You run 40 Sidekiq workers that all hit the same external marketing API capped at 20 requests per second. Without coordination, they’ll burst and trigger throttling. With a shared limit, every worker routes through the same Redis-backed window so aggregate throughput stays at 20 req/s across the whole fleet.
21
+ - **Distributed by design**: Limits are enforced through Redis so multiple app instances share a single limit.
22
+ - **Sliding window**: Limits are based on the most recent interval window, not fixed buckets.
23
+ - **Backoff with polling**: When a limit is hit, the limiter sleeps in short intervals until a slot opens.
24
+ - **Bounded waiting**: Callers can set a max wait time to avoid waiting indefinitely.
25
+ - **Minimal surface area**: A single mixin and a compact queue implementation.
26
+
27
+ ## Quick Start
28
+
29
+ **1. Install the gem**
30
+
31
+ Requires Ruby 4.0 or later.
32
+
33
+ ```ruby
34
+ # Gemfile
35
+ gem "hanikamu-rate-limit", "~> 0.1.0"
36
+ ```
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ **2. Configure Redis**
43
+
44
+ ```ruby
45
+ Hanikamu::RateLimit.configure do |config|
46
+ config.redis_url = ENV.fetch("REDIS_URL")
47
+ config.check_interval = 0.25
48
+ config.max_wait_time = 1.5
49
+
50
+ config.register_limit(:external_api, rate: 5, interval: 0.5, check_interval: 0.5, max_wait_time: 5)
51
+ end
52
+ ```
53
+
54
+ **3. Limit a method**
55
+
56
+ ```ruby
57
+ class MyService
58
+ extend Hanikamu::RateLimit::Mixin
59
+
60
+ limit_method :execute, rate: 5, interval: 1.0
61
+
62
+ def execute
63
+ # work
64
+ end
65
+ end
66
+ ```
67
+
68
+ **4. Call it**
69
+
70
+ ```ruby
71
+ MyService.new.execute
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ Available settings:
77
+
78
+ - `redis_url`: Redis connection URL (required).
79
+ - `check_interval`: default sleep interval between retries (default: 0.5 seconds).
80
+ - `max_wait_time`: max time to wait before raising (default: 2.0 seconds).
81
+ - `register_limit`: define a named limit shared across classes.
82
+
83
+ Registered limit options:
84
+
85
+ - `rate` and `interval` (required).
86
+ - `check_interval`, `max_wait_time` (optional).
87
+ - `key_prefix` (optional) to force a shared Redis key; defaults to a registry-based prefix.
88
+
89
+ ## Usage
90
+
91
+ Optional per-method overrides:
92
+
93
+ ```ruby
94
+ limit_method :execute, rate: 5, interval: 1.0, check_interval: 0.1, max_wait_time: 3.0
95
+ ```
96
+
97
+ Use a registered limit shared across classes:
98
+
99
+ ```ruby
100
+ class ExternalApiClient
101
+ extend Hanikamu::RateLimit::Mixin
102
+
103
+ limit_with :execute, registry: :external_api
104
+
105
+ def execute
106
+ # work
107
+ end
108
+ end
109
+ ```
110
+
111
+ Registry precedence (highest to lowest):
112
+
113
+ 1. Per-method overrides passed to `limit_with`.
114
+ 2. Registered limit options.
115
+ 3. Global defaults from `Hanikamu::RateLimit.configure`.
116
+
117
+ Reset method is generated automatically:
118
+
119
+ ```ruby
120
+ MyService.reset_execute_limit!
121
+ ```
122
+
123
+ ## Error Handling
124
+
125
+ If Redis is unavailable, `RateQueue#shift` logs a warning and returns `nil`.
126
+
127
+ ## Testing
128
+
129
+ ```bash
130
+ bundle exec rspec
131
+ ```
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ bundle exec rake
137
+ ```
138
+
139
+ ## License
140
+
141
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,22 @@
1
+ version: "3"
2
+ networks:
3
+ docker-compose-example-tier:
4
+ driver: bridge
5
+ services:
6
+ redis:
7
+ image: redis:7-alpine
8
+ networks:
9
+ - docker-compose-example-tier
10
+ app:
11
+ build:
12
+ context: .
13
+ dockerfile: Dockerfile
14
+ environment:
15
+ HISTFILE: /app/tmp/ash_history
16
+ REDIS_URL: redis://redis:6379/15
17
+ volumes:
18
+ - .:/app
19
+ networks:
20
+ - docker-compose-example-tier
21
+ depends_on:
22
+ - redis
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanikamu
4
+ module RateLimit
5
+ class RateLimitError < StandardError; end
6
+ end
7
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanikamu
4
+ module RateLimit
5
+ module Mixin
6
+ def limit_method(method, rate:, interval: 60, **options, &)
7
+ queue = build_queue(rate, interval, method, options, &)
8
+ install_rate_limited_method(method, queue)
9
+ end
10
+
11
+ def limit_with(method, registry:, **overrides, &)
12
+ registry_config = Hanikamu::RateLimit.fetch_limit(registry)
13
+ merged = registry_config.merge(overrides.compact)
14
+ rate = merged.fetch(:rate)
15
+ interval = merged.fetch(:interval)
16
+ options = merged.slice(:check_interval, :max_wait_time, :key_prefix)
17
+ queue = build_queue(rate, interval, method, options, &)
18
+ install_rate_limited_method(method, queue)
19
+ end
20
+
21
+ private
22
+
23
+ def build_queue(rate, interval, method, options, &)
24
+ Hanikamu::RateLimit::RateQueue.new(
25
+ rate,
26
+ interval: interval,
27
+ klass_name: name,
28
+ method: method,
29
+ key_prefix: options[:key_prefix],
30
+ check_interval: options.fetch(:check_interval, Hanikamu::RateLimit.config.check_interval),
31
+ max_wait_time: options.fetch(:max_wait_time, Hanikamu::RateLimit.config.max_wait_time),
32
+ &
33
+ )
34
+ end
35
+
36
+ def install_rate_limited_method(method, queue)
37
+ mixin = Module.new do
38
+ rate_queue = queue
39
+
40
+ define_method(method) do |*args, **options, &blk|
41
+ rate_queue.shift
42
+ options.empty? ? super(*args, &blk) : super(*args, **options, &blk)
43
+ end
44
+ end
45
+
46
+ define_singleton_method("reset_#{method}_limit!") { queue.reset }
47
+ prepend(mixin)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "redis"
5
+
6
+ module Hanikamu
7
+ module RateLimit
8
+ class RateQueue
9
+ KEY_PREFIX = "hanikamu:rate_limit:rate_queue"
10
+ LUA_SCRIPT = <<~LUA
11
+ local key = KEYS[1]
12
+ local now = tonumber(ARGV[1])
13
+ local interval = tonumber(ARGV[2])
14
+ local rate = tonumber(ARGV[3])
15
+ local member = ARGV[4]
16
+
17
+ redis.call("ZREMRANGEBYSCORE", key, 0, now - interval)
18
+ local count = redis.call("ZCARD", key)
19
+
20
+ if count < rate then
21
+ redis.call("ZADD", key, now, member)
22
+ redis.call("EXPIRE", key, math.ceil(interval) + 1)
23
+ return {1, 0}
24
+ end
25
+
26
+ local oldest = redis.call("ZRANGE", key, 0, 0, "WITHSCORES")
27
+ if oldest and oldest[2] then
28
+ local sleep_for = tonumber(oldest[2]) + interval - now
29
+ if sleep_for < 0 then sleep_for = 0 end
30
+ return {0, sleep_for}
31
+ end
32
+
33
+ return {0, interval}
34
+ LUA
35
+
36
+ def initialize(rate, klass_name:, method:, interval: 60, **options, &block)
37
+ @rate = rate
38
+ @interval = interval.to_f
39
+ @klass_name = klass_name
40
+ @method = method
41
+ @key_prefix = options[:key_prefix]
42
+ @check_interval = options.fetch(:check_interval, Hanikamu::RateLimit.config.check_interval)
43
+ @max_wait_time = options.fetch(:max_wait_time, Hanikamu::RateLimit.config.max_wait_time)
44
+ @block = block
45
+ end
46
+
47
+ def shift
48
+ start_time = current_time
49
+
50
+ loop do
51
+ allowed, sleep_time = attempt_shift(start_time)
52
+
53
+ return if allowed == 1
54
+
55
+ handle_sleep(sleep_time)
56
+ end
57
+ rescue Redis::BaseError => e
58
+ warn "[Hanikamu::RateLimit] Redis error: #{e.class} - #{e.message}"
59
+ nil
60
+ end
61
+
62
+ def reset
63
+ redis.del(redis_key)
64
+ end
65
+
66
+ private
67
+
68
+ def redis_key
69
+ @redis_key ||= begin
70
+ prefix = @key_prefix || "#{KEY_PREFIX}:#{@klass_name}:#{@method}"
71
+ "#{prefix}:#{@rate}:#{@interval}"
72
+ end
73
+ end
74
+
75
+ def attempt_shift(start_time)
76
+ now = current_time
77
+ elapsed = now - start_time
78
+ if @max_wait_time && elapsed > @max_wait_time
79
+ raise Hanikamu::RateLimit::RateLimitError, "Max wait time exceeded"
80
+ end
81
+
82
+ member = "#{now}-#{SecureRandom.uuid}"
83
+ eval_script(now, member)
84
+ end
85
+
86
+ def eval_script(now, member)
87
+ redis.evalsha(
88
+ lua_sha,
89
+ keys: [redis_key],
90
+ argv: [now, @interval, @rate, member]
91
+ )
92
+ rescue Redis::CommandError => e
93
+ return reload_script_and_retry(now, member) if e.message.include?("NOSCRIPT")
94
+
95
+ raise
96
+ end
97
+
98
+ def reload_script_and_retry(now, member)
99
+ @lua_sha = redis.script(:load, LUA_SCRIPT)
100
+ redis.evalsha(
101
+ lua_sha,
102
+ keys: [redis_key],
103
+ argv: [now, @interval, @rate, member]
104
+ )
105
+ end
106
+
107
+ def handle_sleep(sleep_time)
108
+ @block&.call(sleep_time)
109
+ actual_sleep = @check_interval ? [@check_interval, sleep_time].min : sleep_time
110
+ sleep(actual_sleep) if actual_sleep.to_f.positive?
111
+ end
112
+
113
+ def redis
114
+ @redis ||= Redis.new(url: Hanikamu::RateLimit.config.redis_url)
115
+ end
116
+
117
+ def lua_sha
118
+ @lua_sha ||= redis.script(:load, LUA_SCRIPT)
119
+ end
120
+
121
+ def current_time
122
+ Time.now.to_f
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanikamu
4
+ module RateLimit
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+ require "dry/container"
5
+ require "hanikamu/rate_limit/errors"
6
+ require "hanikamu/rate_limit/mixin"
7
+ require "hanikamu/rate_limit/rate_queue"
8
+ require "hanikamu/rate_limit/version"
9
+
10
+ module Hanikamu
11
+ module RateLimit
12
+ extend Dry::Configurable
13
+
14
+ setting :redis_url
15
+ setting :max_wait_time, default: 2.0
16
+ setting :check_interval, default: 0.5
17
+
18
+ class << self
19
+ def configure(&block)
20
+ super do |config|
21
+ config.define_singleton_method(:register_limit) do |name, **options|
22
+ Hanikamu::RateLimit.register_limit(name, **options)
23
+ end
24
+ block&.call(config)
25
+ end
26
+ end
27
+
28
+ def register_limit(name, **options)
29
+ registry.register(normalize_name(name), normalize_registry_options(name, options))
30
+ end
31
+
32
+ def fetch_limit(name)
33
+ registry.resolve(normalize_name(name))
34
+ rescue Dry::Container::Error
35
+ raise ArgumentError, "Unknown registered limit: #{name}"
36
+ end
37
+
38
+ def reset_registry!
39
+ @registry = Dry::Container.new
40
+ end
41
+
42
+ def registry
43
+ @registry ||= Dry::Container.new
44
+ end
45
+
46
+ private
47
+
48
+ def normalize_name(name)
49
+ name.to_sym
50
+ end
51
+
52
+ def normalize_registry_options(name, options)
53
+ rate = options.fetch(:rate)
54
+ interval = options.fetch(:interval)
55
+ key_prefix = options[:key_prefix] || "#{RateQueue::KEY_PREFIX}:registry:#{name}"
56
+ registry_options = { rate: rate, interval: interval, key_prefix: key_prefix }
57
+ registry_options[:check_interval] = options[:check_interval] unless options[:check_interval].nil?
58
+ registry_options[:max_wait_time] = options[:max_wait_time] unless options[:max_wait_time].nil?
59
+ registry_options
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanikamu/rate_limit"
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: hanikamu-rate-limit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nicolai Seerup
8
+ - Alejandro Jimenez
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-configurable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: dry-container
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.11'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.11'
41
+ - !ruby/object:Gem::Dependency
42
+ name: redis
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '5.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '5.0'
55
+ description: |
56
+ Ruby gem for distributed rate limiting backed by Redis. Provides a sliding-window limiter
57
+ with configurable polling and maximum wait time, suitable for multi-process and multi-thread
58
+ workloads.
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - ".DS_Store"
64
+ - CHANGELOG.md
65
+ - Dockerfile
66
+ - LICENSE
67
+ - Makefile
68
+ - README.md
69
+ - Rakefile
70
+ - docker-compose.yml
71
+ - lib/hanikamu-rate-limit.rb
72
+ - lib/hanikamu/rate_limit.rb
73
+ - lib/hanikamu/rate_limit/errors.rb
74
+ - lib/hanikamu/rate_limit/mixin.rb
75
+ - lib/hanikamu/rate_limit/rate_queue.rb
76
+ - lib/hanikamu/rate_limit/version.rb
77
+ homepage: https://github.com/Hanikamu/hanikamu-rate-limit
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/Hanikamu/hanikamu-rate-limit
82
+ source_code_uri: https://github.com/Hanikamu/hanikamu-rate-limit
83
+ changelog_uri: https://github.com/Hanikamu/hanikamu-rate-limit/blob/main/CHANGELOG.md
84
+ rubygems_mfa_required: 'true'
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '4.0'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 4.0.3
100
+ specification_version: 4
101
+ summary: Distributed Redis-backed rate limiting
102
+ test_files: []