diff --git a/.rubocop.yml b/.rubocop.yml index 0def896e..ddd4a7f7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -49,6 +49,7 @@ Elegant/GoodMethodName: - delete_one - dump_headers - extract_remaining_count + - extract_reset_time - extract_search_remaining_count - faraday_value - github_graph diff --git a/lib/fbe/middleware/rate_limit.rb b/lib/fbe/middleware/rate_limit.rb index 849b1f90..b0ae1e72 100644 --- a/lib/fbe/middleware/rate_limit.rb +++ b/lib/fbe/middleware/rate_limit.rb @@ -12,8 +12,8 @@ # # This middleware intercepts calls to the /rate_limit endpoint and caches # the results locally. It tracks the remaining requests count and decrements -# it for each API call. Every 100 requests, it refreshes the cached data -# by allowing the request to pass through to the GitHub API. +# it for each API call. It refreshes after 100 requests or when a reported +# quota reset passes. # # @example Usage in Faraday middleware stack # connection = Faraday.new do |f| @@ -32,6 +32,8 @@ def initialize(app, tracker = nil) @cached = nil @remaining = nil @searchleft = nil + @core_reset = nil + @search_reset = nil @counter = 0 @lock = Mutex.new @refresh = Mutex.new @@ -63,6 +65,7 @@ def call(env) # @return [Integer, nil] The remaining count, or nil when the resource is absent def remaining(resource = :core) @lock.synchronize do + return nil if expired?(resource) resource == :search ? @searchleft : @remaining end end @@ -74,18 +77,52 @@ def remaining(resource = :core) # @param [Faraday::Env] env The request environment # @return [Faraday::Response] Cached or fresh response def handle_rate_limit_request(env) - stale = @lock.synchronize { @cached.nil? || @counter >= 100 } + stale = @lock.synchronize { @cached.nil? || expired? || @counter >= 100 } return @lock.synchronize { Faraday::Response.new(response_env(env, @cached)) } unless stale response = @app.call(env) @lock.synchronize do @cached = response @remaining = extract_remaining_count(response) @searchleft = extract_search_remaining_count(response) + @core_reset = extract_reset_time(response, :core) + @search_reset = extract_reset_time(response, :search) @counter = 0 end response end + # Whether a quota reset has invalidated the tracked count. + # + # @param [Symbol, nil] resource The quota resource, or nil to check all resources + # @return [Boolean] True when a known reset time has passed + def expired?(resource = nil) + resets = + if resource.nil? + [@core_reset, @search_reset] + else + [resource == :search ? @search_reset : @core_reset] + end + now = Time.now.to_i + resets.any? { |reset| !reset.nil? && now >= reset } + end + + # Reads a resource reset time from a quota response. + # + # @param [Faraday::Response] response The quota response + # @param [Symbol] resource The quota resource + # @return [Integer, nil] Unix reset time, or nil when it is absent + def extract_reset_time(response, resource) + body = response.body + body = JSON.parse(body) if body.is_a?(String) + resets = [] + if resource == :core + resets << response.headers['x-ratelimit-reset'] + resets << body.dig('rate', 'reset') if body.is_a?(Hash) + end + resets << body.dig('resources', resource.to_s, 'reset') if body.is_a?(Hash) + resets.filter_map { |value| Integer(value, exception: false) }.min + end + # Tracks non-rate_limit requests and decrements counter. # # @return [Symbol, nil] The counter the request was taken off, or NIL if none was @@ -138,6 +175,14 @@ def sync(response_env, path = nil) else @remaining = count end + reset = Integer(headers['x-ratelimit-reset'], exception: false) + unless reset.nil? + if path&.start_with?('/search/') + @search_reset = reset + else + @core_reset = reset + end + end end # Extracts the remaining count from the response body. diff --git a/lib/fbe/octo.rb b/lib/fbe/octo.rb index ee0ce178..e7929507 100644 --- a/lib/fbe/octo.rb +++ b/lib/fbe/octo.rb @@ -145,6 +145,7 @@ def Fbe.octo(options: $options, global: $global, loog: $loog) # rubocop:disable o = decoor(o, loog:, trace:, limits:, mutex:) do # rubocop:disable Metrics/BlockLength def print_trace!(all: false, max: 5) + left = @origin.rate_limit!.remaining unless @mutex.synchronize { @trace.empty? } @mutex.synchronize do if @trace.empty? @loog.debug('GitHub API trace is empty') @@ -173,7 +174,7 @@ def print_trace!(all: false, max: 5) @loog.info( "GitHub API trace (#{grouped.count} URLs vs #{shown.count} requests, " \ "#{@trace.count - shown.count} fast ones skipped, " \ - "#{@origin.rate_limit!.remaining} quota left):\n#{message}" + "#{left || 'unknown'} quota left):\n#{message}" ) @trace.clear end diff --git a/test/fbe/middleware/test_rate_limit.rb b/test/fbe/middleware/test_rate_limit.rb index eb932c1d..79931b3b 100644 --- a/test/fbe/middleware/test_rate_limit.rb +++ b/test/fbe/middleware/test_rate_limit.rb @@ -15,8 +15,10 @@ # Copyright:: Copyright (c) 2024-2026 Zerocracy # License:: MIT class RateLimitTest < Fbe::Test + FUTURE_RESET = 4_102_444_800 # 2100-01-01 UTC + def test_caches_payload_on_first_call - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) conn = create_connection @@ -26,7 +28,7 @@ def test_caches_payload_on_first_call end def test_returns_cached_response_on_subsequent_calls - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) .times(1) @@ -38,8 +40,74 @@ def test_returns_cached_response_on_subsequent_calls assert_requested(:get, 'https://api.github.com/rate_limit', times: 1) end + def test_refreshes_after_core_reset_without_ordinary_requests + now = Time.utc(2026, 9, 8) + tracker = {} + stub_request(:get, 'https://api.github.com/rate_limit').to_return( + { + body: { rate: { remaining: 7, reset: now.to_i + 60 } }.to_json, + headers: { 'Content-Type' => 'application/json' } + }, + { + body: { rate: { remaining: 5000, reset: now.to_i + 3600 } }.to_json, + headers: { 'Content-Type' => 'application/json' } + } + ) + conn = create_connection(tracker) + Time.stub(:now, now) { conn.get('/rate_limit') } + Time.stub(:now, now + 59) do + assert_equal(7, conn.get('/rate_limit').body.dig('rate', 'remaining')) + end + assert_requested(:get, 'https://api.github.com/rate_limit', times: 1) + Time.stub(:now, now + 60) do + assert_equal(5000, conn.get('/rate_limit').body.dig('rate', 'remaining')) + end + assert_requested(:get, 'https://api.github.com/rate_limit', times: 2) + end + + def test_search_expiry_does_not_discard_the_core_count + now = Time.utc(2026, 9, 8) + tracker = {} + stub_request(:get, 'https://api.github.com/rate_limit').to_return( + body: { + rate: { remaining: 5000, reset: now.to_i + 3600 }, + resources: { search: { remaining: 1, reset: now.to_i + 60 } } + }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + conn = create_connection(tracker) + Time.stub(:now, now) do + conn.get('/rate_limit') + assert_equal(5000, tracker[:rate_limit].remaining) + assert_equal(1, tracker[:rate_limit].remaining(:search)) + end + Time.stub(:now, now + 60) do + assert_equal(5000, tracker[:rate_limit].remaining) + assert_nil(tracker[:rate_limit].remaining(:search)) + end + end + + def test_expires_core_count_from_reset_header + now = Time.utc(2026, 9, 8) + tracker = {} + stub_request(:get, 'https://api.github.com/rate_limit').to_return( + body: '{}', + headers: { + 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '7', + 'X-RateLimit-Reset' => (now.to_i + 60).to_s + } + ) + conn = create_connection(tracker) + Time.stub(:now, now) do + conn.get('/rate_limit') + assert_equal(7, tracker[:rate_limit].remaining) + end + Time.stub(:now, now + 60) { assert_nil(tracker[:rate_limit].remaining) } + end + def test_decrements_remaining_count_for_non_rate_limit_requests - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, body: payload.to_json, headers: { @@ -74,8 +142,8 @@ def test_updates_remaining_count_from_non_rate_limit_response_header end def test_refreshes_cache_after_hundred_requests - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } - refreshed = { 'rate' => { 'limit' => 5000, 'remaining' => 4950, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } + refreshed = { 'rate' => { 'limit' => 5000, 'remaining' => 4950, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) .then @@ -116,7 +184,7 @@ def test_leaves_remaining_unknown_when_response_has_no_count end def test_handles_zero_remaining_count - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 1, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 1, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) stub_request(:get, 'https://api.github.com/user') @@ -132,10 +200,10 @@ def test_handles_zero_remaining_count def test_decrements_search_remaining_for_search_requests payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -152,10 +220,10 @@ def test_decrements_search_remaining_for_search_requests def test_search_request_does_not_decrement_core_remaining payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -171,10 +239,10 @@ def test_search_request_does_not_decrement_core_remaining def test_non_search_request_does_not_decrement_search_remaining payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -192,10 +260,10 @@ def test_non_search_request_does_not_decrement_search_remaining def test_search_remaining_survives_repeated_search_calls_within_cache_window payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -214,10 +282,10 @@ def test_search_remaining_survives_repeated_search_calls_within_cache_window def test_search_remaining_clamps_at_zero payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 1, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 1, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -234,10 +302,10 @@ def test_search_remaining_clamps_at_zero def test_restores_the_counter_at_zero_after_a_failed_request payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 1, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 1, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 1, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 1, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 1, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 1, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -252,10 +320,10 @@ def test_restores_the_counter_at_zero_after_a_failed_request def test_gives_nothing_back_when_the_quota_is_out payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 0, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 0, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 0, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 0, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 0, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 0, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -270,10 +338,10 @@ def test_gives_nothing_back_when_the_quota_is_out def test_restores_the_search_counter_at_zero payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 1, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 1, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 1, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 1, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 1, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 1, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -287,7 +355,7 @@ def test_restores_the_search_counter_at_zero end def test_search_remaining_absent_when_payload_lacks_resources - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) stub_request(:get, 'https://api.github.com/search/issues?q=z') @@ -302,10 +370,10 @@ def test_search_remaining_absent_when_payload_lacks_resources def test_decrement_applies_when_body_is_a_json_string payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -328,10 +396,10 @@ def test_decrement_applies_when_body_is_a_json_string def test_concurrent_non_rate_limit_requests_do_not_lose_decrements payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 10_000, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 10_000, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 10_000, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 1_000, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 10_000, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 1_000, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -358,7 +426,7 @@ def test_concurrent_non_rate_limit_requests_do_not_lose_decrements end def test_concurrent_rate_limit_requests_refresh_once - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) conn = create_connection @@ -409,7 +477,7 @@ def test_answers_remaining_while_rate_limit_request_is_in_flight end def test_syncs_core_remaining_from_response_header - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) stub_request(:get, 'https://api.github.com/user') @@ -426,7 +494,7 @@ def test_syncs_core_remaining_from_response_header end def test_raises_remaining_after_quota_reset - payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 } } + payload = { 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET } } stub_request(:get, 'https://api.github.com/rate_limit') .to_return(status: 200, body: payload.to_json, headers: { 'Content-Type' => 'application/json' }) stub_request(:get, 'https://api.github.com/user').to_return( @@ -444,10 +512,10 @@ def test_raises_remaining_after_quota_reset def test_syncs_search_remaining_from_response_header payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') @@ -467,10 +535,10 @@ def test_syncs_search_remaining_from_response_header def test_cached_body_is_not_leaked_to_callers payload = { - 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, + 'rate' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, 'resources' => { - 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => 1_672_531_200 }, - 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => 1_672_531_200 } + 'core' => { 'limit' => 5000, 'remaining' => 4999, 'reset' => FUTURE_RESET }, + 'search' => { 'limit' => 30, 'remaining' => 30, 'reset' => FUTURE_RESET } } } stub_request(:get, 'https://api.github.com/rate_limit') diff --git a/test/fbe/test_octo.rb b/test/fbe/test_octo.rb index 9efa8b8a..b05609f2 100644 --- a/test/fbe/test_octo.rb +++ b/test/fbe/test_octo.rb @@ -5,6 +5,7 @@ require 'judges/options' require 'loog' +require 'timeout' require 'webmock/minitest' require_relative '../../lib/fbe/octo' require_relative '../test__helper' @@ -14,6 +15,8 @@ # Copyright:: Copyright (c) 2024-2026 Zerocracy # License:: MIT class TestOcto < Fbe::Test + FUTURE_RESET = 4_102_444_800 # 2100-01-01 UTC + def test_simple_use global = {} options = Judges::Options.new({ 'testing' => true }) @@ -189,6 +192,33 @@ def test_fails_user_request_when_off_quota assert_raises(StandardError) { o.user(42) } end + def test_off_quota_recovers_after_reported_reset_without_ordinary_requests + WebMock.disable_net_connect! + now = Time.utc(2026, 9, 8) + stub_request(:get, 'https://api.github.com/rate_limit').to_return( + { + body: { rate: { remaining: 7, reset: now.to_i + 60 } }.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '7', + 'X-RateLimit-Reset' => (now.to_i + 60).to_s + } + }, + { + body: { rate: { remaining: 5000, reset: now.to_i + 3600 } }.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '5000', + 'X-RateLimit-Reset' => (now.to_i + 3600).to_s + } + } + ) + o = Fbe.octo(loog: Loog::NULL, global: {}, options: Judges::Options.new) + Time.stub(:now, now) { assert_predicate(o, :off_quota?) } + Time.stub(:now, now + 60) { refute_predicate(o, :off_quota?) } + assert_requested(:get, 'https://api.github.com/rate_limit', times: 2) + end + def test_rate_limit_bang_works_when_off_quota WebMock.disable_net_connect! stub_request(:get, 'https://api.github.com/rate_limit').to_return( @@ -945,7 +975,7 @@ def test_with_disable_auto_paginate stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) stub_request(:get, 'https://api.github.com/repos/foo/bar/issues?per_page=100') .to_return( @@ -969,7 +999,7 @@ def test_with_disable_auto_paginate_after_exception_leave_original_value stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) o = Fbe.octo(loog: fake_loog, global: {}, options: Judges::Options.new({})) assert(o.auto_paginate) @@ -1036,6 +1066,37 @@ def test_print_trace assert_operator(repos, :<, users, 'URLs should be sorted by request count (highest first)') end + def test_print_trace_refreshes_an_expired_quota_without_holding_its_mutex + WebMock.disable_net_connect! + now = Time.utc(2026, 9, 8) + stub_request(:get, 'https://api.github.com/rate_limit').to_return( + { + body: { rate: { remaining: 5000, reset: now.to_i + 60 } }.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '5000', + 'X-RateLimit-Reset' => (now.to_i + 60).to_s + } + }, + { + body: { rate: { remaining: 5000, reset: now.to_i + 3600 } }.to_json, + headers: { + 'Content-Type' => 'application/json', + 'X-RateLimit-Remaining' => '5000', + 'X-RateLimit-Reset' => (now.to_i + 3600).to_s + } + } + ) + stub_request(:get, 'https://api.github.com/user/42').to_return( + status: 200, body: '{"id":42,"login":"test"}', + headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '6' } + ) + o = Fbe.octo(loog: Loog::NULL, global: {}, options: Judges::Options.new) + Time.stub(:now, now) { o.user(42) } + Time.stub(:now, now + 60) { Timeout.timeout(5) { o.print_trace!(all: true) } } + assert_requested(:get, 'https://api.github.com/rate_limit', times: 2) + end + def test_prints_only_real_requests WebMock.disable_net_connect! stub_request(:get, 'https://api.github.com/rate_limit').to_return( @@ -1071,7 +1132,7 @@ def test_octo_not_trace_cached_requests stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) stub_request(:get, 'https://api.github.com/repos/zerocracy/baza.rb') .to_return( @@ -1239,15 +1300,15 @@ def test_throttling_request_to_rate_limit stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) .then.to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '4900' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 4900, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 4900, 'reset' => FUTURE_RESET } }.to_json ) .then.to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '4800' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 4800, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 4800, 'reset' => FUTURE_RESET } }.to_json ) .then.to_raise(Fbe::Error, 'no more request to /rate_limit') stub_request(:get, 'https://api.github.com/user/1') @@ -1288,7 +1349,7 @@ def test_octo_http_cache_middleware_located_in_end_of_chain stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) o = Fbe.octo(loog: fake_loog, global: {}, options: Judges::Options.new({})) assert_equal('Faraday::HttpCache', o.middleware.handlers.last.name, <<~MSG.strip.gsub!(/\s+/, ' ')) @@ -1306,7 +1367,7 @@ def test_octo_cache_still_available_on_duration_of_age stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) Dir.mktmpdir do |dir| cache = File.expand_path('t.db', dir) @@ -1352,7 +1413,7 @@ def test_octo_with_set_sqlite_cache_min_age stub_request(:get, 'https://api.github.com/rate_limit') .to_return( status: 200, headers: { 'Content-Type' => 'application/json', 'X-RateLimit-Remaining' => '5000' }, - body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => 1_672_531_200 } }.to_json + body: { 'rate' => { 'limit' => 5000, 'remaining' => 5000, 'reset' => FUTURE_RESET } }.to_json ) Dir.mktmpdir do |dir| cache = File.expand_path('t.db', dir)