diff options
Diffstat (limited to 'spec/dispatch/adapter/rate_limiter_spec.rb')
| -rw-r--r-- | spec/dispatch/adapter/rate_limiter_spec.rb | 407 |
1 files changed, 407 insertions, 0 deletions
diff --git a/spec/dispatch/adapter/rate_limiter_spec.rb b/spec/dispatch/adapter/rate_limiter_spec.rb new file mode 100644 index 0000000..5fcf92f --- /dev/null +++ b/spec/dispatch/adapter/rate_limiter_spec.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +require "fileutils" +require "json" +require "tempfile" + +RSpec.describe Dispatch::Adapter::RateLimiter do + let(:tmpdir) { Dir.mktmpdir("rate_limiter_test") } + let(:rate_limit_path) { File.join(tmpdir, "copilot_rate_limit") } + + after { FileUtils.rm_rf(tmpdir) } + + describe "#initialize" do + it "accepts valid min_request_interval and nil rate_limit" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 3.0, + rate_limit: nil + ) + expect(limiter).to be_a(described_class) + end + + it "accepts nil min_request_interval" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: nil + ) + expect(limiter).to be_a(described_class) + end + + it "accepts zero min_request_interval" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 0, + rate_limit: nil + ) + expect(limiter).to be_a(described_class) + end + + it "accepts valid rate_limit hash" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10, period: 60 } + ) + expect(limiter).to be_a(described_class) + end + + it "accepts both min_request_interval and rate_limit" do + limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 2.0, + rate_limit: { requests: 5, period: 30 } + ) + expect(limiter).to be_a(described_class) + end + + it "raises ArgumentError for negative min_request_interval" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: -1, + rate_limit: nil + ) + end.to raise_error(ArgumentError, /min_request_interval/) + end + + it "raises ArgumentError for non-numeric min_request_interval" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: "fast", + rate_limit: nil + ) + end.to raise_error(ArgumentError, /min_request_interval/) + end + + it "raises ArgumentError when rate_limit is missing requests key" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { period: 60 } + ) + end.to raise_error(ArgumentError, /requests/) + end + + it "raises ArgumentError when rate_limit is missing period key" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10 } + ) + end.to raise_error(ArgumentError, /period/) + end + + it "raises ArgumentError when rate_limit requests is zero" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 0, period: 60 } + ) + end.to raise_error(ArgumentError, /requests/) + end + + it "raises ArgumentError when rate_limit requests is negative" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: -1, period: 60 } + ) + end.to raise_error(ArgumentError, /requests/) + end + + it "raises ArgumentError when rate_limit period is zero" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10, period: 0 } + ) + end.to raise_error(ArgumentError, /period/) + end + + it "raises ArgumentError when rate_limit period is negative" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 10, period: -5 } + ) + end.to raise_error(ArgumentError, /period/) + end + + it "raises ArgumentError when rate_limit is not a Hash" do + expect do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: "10/60" + ) + end.to raise_error(ArgumentError) + end + end + + describe "#wait!" do + context "with both mechanisms disabled" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: nil + ) + end + + it "returns immediately without sleeping" do + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "does not create a rate limit file" do + limiter.wait! + expect(File.exist?(rate_limit_path)).to be(false) + end + end + + context "with per-request cooldown only" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "does not sleep on the first request" do + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "creates the rate limit file on first request" do + limiter.wait! + expect(File.exist?(rate_limit_path)).to be(true) + end + + it "sets the rate limit file permissions to 0600" do + limiter.wait! + mode = File.stat(rate_limit_path).mode & 0o777 + expect(mode).to eq(0o600) + end + + it "records last_request_at in the state file" do + before = Time.now.to_f + limiter.wait! + after = Time.now.to_f + + state = JSON.parse(File.read(rate_limit_path)) + expect(state["last_request_at"]).to be_between(before, after) + end + + it "sleeps for the remaining cooldown on a rapid second request" do + limiter.wait! + + # Simulate that almost no time has passed + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "does not sleep when enough time has elapsed between requests" do + limiter.wait! + + # Write a past timestamp to simulate time passing + state = { "last_request_at" => Time.now.to_f - 2.0, "request_log" => [] } + File.write(rate_limit_path, JSON.generate(state)) + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + end + + context "with sliding window only" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: nil, + rate_limit: { requests: 3, period: 10 } + ) + end + + it "allows requests up to the window limit without sleeping" do + expect(limiter).not_to receive(:sleep) + 3.times { limiter.wait! } + end + + it "sleeps when the window limit is reached" do + now = Time.now.to_f + state = { + "last_request_at" => now, + "request_log" => [now - 2.0, now - 1.0, now] + } + File.write(rate_limit_path, JSON.generate(state)) + + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "does not sleep when oldest entries have expired from the window" do + now = Time.now.to_f + state = { + "last_request_at" => now - 5.0, + "request_log" => [now - 15.0, now - 12.0, now - 5.0] + } + File.write(rate_limit_path, JSON.generate(state)) + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "prunes expired entries from the request_log on write" do + now = Time.now.to_f + state = { + "last_request_at" => now - 5.0, + "request_log" => [now - 20.0, now - 15.0, now - 5.0] + } + File.write(rate_limit_path, JSON.generate(state)) + + limiter.wait! + + updated_state = JSON.parse(File.read(rate_limit_path)) + # Old entries (20s and 15s ago) should be pruned (window is 10s) + # Only the 5s-ago entry and the new entry should remain + expect(updated_state["request_log"].size).to be <= 2 + end + end + + context "with both mechanisms enabled" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: { requests: 3, period: 10 } + ) + end + + it "uses the longer wait time when cooldown is the bottleneck" do + limiter.wait! + + # Second request immediately — cooldown should be the bottleneck + # (only 1 of 3 window slots used, but cooldown not elapsed) + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "uses the longer wait time when window limit is the bottleneck" do + now = Time.now.to_f + state = { + "last_request_at" => now - 2.0, # cooldown elapsed + "request_log" => [now - 3.0, now - 2.5, now - 2.0] # window full + } + File.write(rate_limit_path, JSON.generate(state)) + + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + end + + context "with a missing or corrupt state file" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "treats a non-existent file as fresh state" do + expect(File.exist?(rate_limit_path)).to be(false) + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "treats an empty file as fresh state" do + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, "") + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "treats a corrupt JSON file as fresh state" do + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, "not valid json{{{") + + expect(limiter).not_to receive(:sleep) + limiter.wait! + end + + it "overwrites corrupt state with valid state after a request" do + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, "garbage") + + limiter.wait! + + state = JSON.parse(File.read(rate_limit_path)) + expect(state).to have_key("last_request_at") + expect(state["last_request_at"]).to be_a(Float) + end + end + + context "with a missing parent directory" do + let(:nested_path) { File.join(tmpdir, "sub", "dir", "copilot_rate_limit") } + + let(:limiter) do + described_class.new( + rate_limit_path: nested_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "creates parent directories" do + limiter.wait! + expect(File.exist?(nested_path)).to be(true) + end + end + + context "cross-process coordination" do + let(:limiter) do + described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + end + + it "reads state written by another process" do + # Simulate another process having made a request just now + now = Time.now.to_f + state = { "last_request_at" => now, "request_log" => [now] } + FileUtils.mkdir_p(File.dirname(rate_limit_path)) + File.write(rate_limit_path, JSON.generate(state)) + + # Our limiter should see this and wait + allow(limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + limiter.wait! + end + + it "writes state that another process can read" do + limiter.wait! + + # Another RateLimiter instance (simulating another process) reads the file + other_limiter = described_class.new( + rate_limit_path: rate_limit_path, + min_request_interval: 1.0, + rate_limit: nil + ) + + allow(other_limiter).to receive(:sleep) { |duration| expect(duration).to be > 0 } + other_limiter.wait! + end + end + end +end |
