# frozen_string_literal: true require "webmock/rspec" require "tmpdir" require "fileutils" RSpec.describe Dispatch::Adapter::Claude, "rate limiting" do let(:model_id) { "claude-sonnet-4-6" } let(:api_key) { "sk-ant-api03-test" } let(:base_url) { "https://api.anthropic.com" } let(:tmpdir) { Dir.mktmpdir("claude_rate_limit_test") } let(:store_path) { File.join(tmpdir, "claude_oauth.json") } let(:store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) } after { FileUtils.rm_rf(tmpdir) } before { WebMock.disable_net_connect! } after { WebMock.reset! } def make_adapter(min_request_interval: 0, rate_limit: nil, **opts) described_class.new( model: model_id, api_key: api_key, base_url: base_url, token_store: store, min_request_interval: min_request_interval, rate_limit: rate_limit, **opts ) end let(:text_response) do { "id" => "msg_01", "type" => "message", "role" => "assistant", "model" => model_id, "stop_reason" => "end_turn", "content" => [{ "type" => "text", "text" => "Hello" }], "usage" => { "input_tokens" => 10, "output_tokens" => 5 } } end let(:messages) do [Dispatch::Adapter::Message.new( role: "user", content: [Dispatch::Adapter::TextBlock.new(text: "Hi")] )] end # ── Constructor accepts rate limit parameters ───────────────────────────── describe "constructor rate limit parameters" do it "accepts default min_request_interval of 1.0" do adapter = described_class.new( model: model_id, api_key: api_key, base_url: base_url, token_store: store ) expect(adapter).to be_a(described_class) end it "accepts custom min_request_interval" do adapter = make_adapter(min_request_interval: 2.0) expect(adapter).to be_a(described_class) end it "accepts min_request_interval: 0 to disable cooldown" do adapter = make_adapter(min_request_interval: 0) expect(adapter).to be_a(described_class) end it "accepts rate_limit hash for sliding window" do adapter = make_adapter(rate_limit: { requests: 10, period: 60 }) expect(adapter).to be_a(described_class) end it "raises ArgumentError for negative min_request_interval" do expect do make_adapter(min_request_interval: -1) end.to raise_error(ArgumentError) end it "raises ArgumentError for invalid rate_limit hash" do expect do make_adapter(rate_limit: { requests: 0, period: 60 }) end.to raise_error(ArgumentError) end end # ── wait! is called before each request type ────────────────────────────── describe "#chat calls @rate_limiter.wait!" do before do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 200, body: JSON.generate(text_response), headers: { "Content-Type" => "application/json" } ) end it "calls wait! once per chat request" do rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) allow(rate_limiter).to receive(:wait!) adapter = make_adapter adapter.chat(messages) expect(rate_limiter).to have_received(:wait!).once end it "calls wait! on each of multiple chat requests" do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 200, body: JSON.generate(text_response), headers: { "Content-Type" => "application/json" } ) rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) allow(rate_limiter).to receive(:wait!) adapter = make_adapter adapter.chat(messages) adapter.chat(messages) expect(rate_limiter).to have_received(:wait!).exactly(2).times end end describe "#count_tokens calls @rate_limiter.wait!" do before do stub_request(:post, "#{base_url}/v1/messages/count_tokens") .to_return( status: 200, body: JSON.generate({ "input_tokens" => 42 }), headers: { "Content-Type" => "application/json" } ) end it "calls wait! before counting tokens" do rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) allow(rate_limiter).to receive(:wait!) adapter = make_adapter adapter.count_tokens(messages) expect(rate_limiter).to have_received(:wait!).once end end describe "#usage_report calls @rate_limiter.wait!" do before do stub_request(:get, "#{base_url}/api/oauth/usage") .to_return( status: 200, body: JSON.generate({ "five_hour" => { "utilization" => 50.0 } }), headers: { "Content-Type" => "application/json" } ) stub_request(:get, "#{base_url}/api/oauth/profile") .to_return( status: 200, body: JSON.generate({ "email" => "u@example.com", "account_id" => "acct" }), headers: { "Content-Type" => "application/json" } ) end it "calls wait! before fetching usage (OAuth)" do rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) allow(rate_limiter).to receive(:wait!) oauth_adapter = described_class.new( model: model_id, api_key: "sk-ant-oat-test", base_url: base_url, token_store: store, is_oauth: true, min_request_interval: 0 ) oauth_adapter.usage_report expect(rate_limiter).to have_received(:wait!).once end end describe "#list_models calls @rate_limiter.wait!" do before do stub_request(:get, "#{base_url}/v1/models?limit=200") .to_return( status: 200, body: JSON.generate({ "data" => [{ "id" => model_id, "display_name" => "Sonnet" }] }), headers: { "Content-Type" => "application/json" } ) end it "calls wait! on list_models (API fetch)" do rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) allow(rate_limiter).to receive(:wait!) adapter = make_adapter adapter.list_models expect(rate_limiter).to have_received(:wait!).once end it "does not call wait! on a cached list_models call" do rate_limiter = instance_double(Dispatch::Adapter::RateLimiter) allow(Dispatch::Adapter::RateLimiter).to receive(:new).and_return(rate_limiter) allow(rate_limiter).to receive(:wait!) adapter = make_adapter adapter.list_models # first call: fetches from API adapter.list_models # second call: from cache # Only 1 wait! for the first call; the cached call is free expect(rate_limiter).to have_received(:wait!).once end end # ── Two consecutive chat calls with real rate limiter ───────────────────── describe "two consecutive chat calls with min_request_interval: 1.0" do subject(:adapter) do described_class.new( model: model_id, api_key: api_key, base_url: base_url, token_store: store, min_request_interval: 1.0 ) end before do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 200, body: JSON.generate(text_response), headers: { "Content-Type" => "application/json" } ).times(2) end it "total elapsed for two calls is >= 1.0 s" do t0 = Time.now.to_f adapter.chat(messages) adapter.chat(messages) elapsed = Time.now.to_f - t0 expect(elapsed).to be >= 1.0 end end # ── min_request_interval: 0 disables throttling ─────────────────────────── describe "min_request_interval: 0 disables throttling" do subject(:adapter) { make_adapter(min_request_interval: 0) } before do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 200, body: JSON.generate(text_response), headers: { "Content-Type" => "application/json" } ).times(2) end it "two consecutive calls complete in under 0.5 s" do t0 = Time.now.to_f adapter.chat(messages) adapter.chat(messages) elapsed = Time.now.to_f - t0 expect(elapsed).to be < 0.5 end end # ── Rate limit file location ─────────────────────────────────────────────── describe "rate limit file location" do it "stores the rate limit file in the same directory as the token store" do stub_request(:post, "#{base_url}/v1/messages") .to_return( status: 200, body: JSON.generate(text_response), headers: { "Content-Type" => "application/json" } ) adapter = make_adapter(min_request_interval: 1.0) adapter.chat(messages) rate_limit_path = File.join(tmpdir, "claude_rate_limit") expect(File.exist?(rate_limit_path)).to be true end end # ── DEFAULT_MIN_REQUEST_INTERVAL constant ───────────────────────────────── describe "DEFAULT_MIN_REQUEST_INTERVAL" do it "is 1.0" do expect(described_class::DEFAULT_MIN_REQUEST_INTERVAL).to eq(1.0) end end end