From 70e825a014e0aa812c0caddf42c9bb33a7a79b38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:22:07 +0000 Subject: [PATCH 01/14] Initial plan From f15ee02ef9124df9cef09669b04a7002d7bece53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 20:35:06 +0000 Subject: [PATCH 02/14] Implement API request throttling to limit requests to 5000/hour Co-authored-by: thedave42 <50186003+thedave42@users.noreply.github.com> --- .../find_inactive_members.rb | 72 ++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index 4bc249f3e..d08cd1cc7 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -3,6 +3,69 @@ require 'optparse' require 'optparse/date' +# Custom Faraday middleware for API request throttling +class ThrottleMiddleware < Faraday::Middleware + # Throttle to 5000 requests per hour (approximately 1.39 requests per second) + MAX_REQUESTS_PER_HOUR = 5000 + MIN_DELAY_SECONDS = 3600.0 / MAX_REQUESTS_PER_HOUR # 0.72 seconds + + def initialize(app, options = {}) + super(app) + @request_count = 0 + @hour_start_time = Time.now + @last_request_time = Time.now + @mutex = Mutex.new + end + + def call(env) + @mutex.synchronize do + throttle_request + log_throttle_status + end + + @app.call(env) + end + + private + + def throttle_request + current_time = Time.now + + # Reset counter if we've moved to a new hour (sliding window) + if current_time - @hour_start_time >= 3600 + @request_count = 0 + @hour_start_time = current_time + @last_request_time = current_time + end + + # Ensure minimum delay between requests to maintain steady rate under 5000/hour + time_since_last = current_time - @last_request_time + if time_since_last < MIN_DELAY_SECONDS + sleep_time = MIN_DELAY_SECONDS - time_since_last + if sleep_time > 0 + sleep(sleep_time) + end + end + + @request_count += 1 + @last_request_time = Time.now + + # Log warning if we're approaching the limit + if @request_count % 1000 == 0 + elapsed_hour = @last_request_time - @hour_start_time + current_rate = elapsed_hour > 0 ? (@request_count / elapsed_hour * 3600).round(1) : 0 + $stderr.print "Throttling status: #{@request_count} requests in #{elapsed_hour.round(1)}s (#{current_rate}/hour rate)\n" + end + end + + def log_throttle_status + # This method can be called for detailed debugging if needed + elapsed_hour = Time.now - @hour_start_time + rate_per_hour = elapsed_hour > 0 ? (@request_count / elapsed_hour * 3600).round(1) : 0 + $stderr.print "Throttle debug: #{@request_count} requests in last #{elapsed_hour.round(1)}s (#{rate_per_hour}/hour rate)\n" if ENV['THROTTLE_DEBUG'] + end +end + class InactiveMemberSearch attr_accessor :organization, :members, :repositories, :date, :unrecognized_authors @@ -42,7 +105,10 @@ def check_scopes end def check_rate_limit - info "Rate limit: #{@client.rate_limit.remaining}/#{@client.rate_limit.limit}\n" + rate_limit = @client.rate_limit + info "Rate limit: #{rate_limit.remaining}/#{rate_limit.limit}\n" + info "Rate limit resets at: #{rate_limit.resets_at}\n" + info "Throttling: Limited to #{ThrottleMiddleware::MAX_REQUESTS_PER_HOUR} requests/hour (#{ThrottleMiddleware::MIN_DELAY_SECONDS.round(2)}s min delay)\n" end def env_help @@ -195,7 +261,8 @@ def member_activity # for each repo @repositories.each do |repo| - info "rate limit remaining: #{@client.rate_limit.remaining} " + rate_limit = @client.rate_limit + info "rate limit remaining: #{rate_limit.remaining}/#{rate_limit.limit} " info "analyzing #{repo}" commit_activity(repo) @@ -268,6 +335,7 @@ def member_activity end.parse! stack = Faraday::RackBuilder.new do |builder| + builder.use ThrottleMiddleware builder.use Octokit::Middleware::FollowRedirects builder.use Octokit::Response::RaiseError builder.use Octokit::Response::FeedParser From a7f5548350aef8f277d04936d36594adf5d53eb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 21:07:21 +0000 Subject: [PATCH 03/14] Update README to include Faraday gem dependency for throttling middleware Co-authored-by: thedave42 <50186003+thedave42@users.noreply.github.com> --- api/ruby/find-inactive-members/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ruby/find-inactive-members/README.md b/api/ruby/find-inactive-members/README.md index 57b37727e..190d2f6f1 100644 --- a/api/ruby/find-inactive-members/README.md +++ b/api/ruby/find-inactive-members/README.md @@ -24,7 +24,7 @@ cd platform-samples/api/ruby/find-inactive-members ### Install dependencies ```shell -gem install octokit +gem install octokit faraday ``` ### Configure Octokit From 622f4e2243b6bd0b59a4c35cfa7bb2637404181a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 Aug 2025 21:13:26 +0000 Subject: [PATCH 04/14] Fix gem dependency: change faraday to faraday-retry in README Co-authored-by: thedave42 <50186003+thedave42@users.noreply.github.com> --- api/ruby/find-inactive-members/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ruby/find-inactive-members/README.md b/api/ruby/find-inactive-members/README.md index 190d2f6f1..ebe12a5c8 100644 --- a/api/ruby/find-inactive-members/README.md +++ b/api/ruby/find-inactive-members/README.md @@ -24,7 +24,7 @@ cd platform-samples/api/ruby/find-inactive-members ### Install dependencies ```shell -gem install octokit faraday +gem install octokit faraday-retry ``` ### Configure Octokit From 38787e442d9a7cf611058e8f841cc194e0e25e93 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Fri, 22 Aug 2025 15:07:15 +0000 Subject: [PATCH 05/14] Update README to correct Faraday gem installation command --- api/ruby/find-inactive-members/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/ruby/find-inactive-members/README.md b/api/ruby/find-inactive-members/README.md index ebe12a5c8..190d2f6f1 100644 --- a/api/ruby/find-inactive-members/README.md +++ b/api/ruby/find-inactive-members/README.md @@ -24,7 +24,7 @@ cd platform-samples/api/ruby/find-inactive-members ### Install dependencies ```shell -gem install octokit faraday-retry +gem install octokit faraday ``` ### Configure Octokit From fb55e2ed12aff279b0b970c94b70b82cf6a2274f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 22 Aug 2025 16:19:27 +0000 Subject: [PATCH 06/14] Optimize debug check by caching environment variable lookup Co-authored-by: thedave42 <50186003+thedave42@users.noreply.github.com> --- api/ruby/find-inactive-members/find_inactive_members.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index d08cd1cc7..c72f90eef 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -15,6 +15,7 @@ def initialize(app, options = {}) @hour_start_time = Time.now @last_request_time = Time.now @mutex = Mutex.new + @debug_enabled = !ENV['THROTTLE_DEBUG'].nil? && !ENV['THROTTLE_DEBUG'].empty? end def call(env) @@ -60,9 +61,11 @@ def throttle_request def log_throttle_status # This method can be called for detailed debugging if needed + return unless @debug_enabled + elapsed_hour = Time.now - @hour_start_time rate_per_hour = elapsed_hour > 0 ? (@request_count / elapsed_hour * 3600).round(1) : 0 - $stderr.print "Throttle debug: #{@request_count} requests in last #{elapsed_hour.round(1)}s (#{rate_per_hour}/hour rate)\n" if ENV['THROTTLE_DEBUG'] + $stderr.print "Throttle debug: #{@request_count} requests in last #{elapsed_hour.round(1)}s (#{rate_per_hour}/hour rate)\n" end end From 790ee9afdd4ae72a9b2735bc82f58b30937dcb5d Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:25:10 +0000 Subject: [PATCH 07/14] Enhance ThrottleMiddleware to dynamically manage GitHub API rate limits --- .../find_inactive_members.rb | 72 ++++++++++++++++--- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index c72f90eef..6bcec0322 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -16,6 +16,8 @@ def initialize(app, options = {}) @last_request_time = Time.now @mutex = Mutex.new @debug_enabled = !ENV['THROTTLE_DEBUG'].nil? && !ENV['THROTTLE_DEBUG'].empty? + @github_rate_limit_remaining = nil + @github_rate_limit_reset = nil end def call(env) @@ -24,11 +26,43 @@ def call(env) log_throttle_status end - @app.call(env) + response = @app.call(env) + + # Update GitHub rate limit info from response headers + @mutex.synchronize do + update_github_rate_limit(response) + end + + response end private + def update_github_rate_limit(response) + if response.headers['x-ratelimit-remaining'] + @github_rate_limit_remaining = response.headers['x-ratelimit-remaining'].to_i + @github_rate_limit_reset = response.headers['x-ratelimit-reset'].to_i if response.headers['x-ratelimit-reset'] + end + end + + def calculate_dynamic_delay + return MIN_DELAY_SECONDS unless @github_rate_limit_remaining && @github_rate_limit_reset + + # Calculate time until rate limit resets + current_time = Time.now.to_i + time_until_reset = [@github_rate_limit_reset - current_time, 1].max + + # Calculate required delay to not exceed remaining requests + if @github_rate_limit_remaining > 0 + required_delay = time_until_reset.to_f / @github_rate_limit_remaining + # Use the more conservative delay (either our standard delay or the calculated one) + [MIN_DELAY_SECONDS, required_delay].max + else + # No requests remaining, wait until reset + time_until_reset + end + end + def throttle_request current_time = Time.now @@ -39,11 +73,16 @@ def throttle_request @last_request_time = current_time end - # Ensure minimum delay between requests to maintain steady rate under 5000/hour + # Use dynamic delay based on actual GitHub rate limit if available + required_delay = @github_rate_limit_remaining ? calculate_dynamic_delay : MIN_DELAY_SECONDS + + # Ensure minimum delay between requests time_since_last = current_time - @last_request_time - if time_since_last < MIN_DELAY_SECONDS - sleep_time = MIN_DELAY_SECONDS - time_since_last + if time_since_last < required_delay + sleep_time = required_delay - time_since_last if sleep_time > 0 + #delay_reason = @github_rate_limit_remaining ? "dynamic" : "standard" + #$stderr.print "Throttling: waiting #{sleep_time.round(2)}s (#{delay_reason} delay)\n" sleep(sleep_time) end end @@ -55,7 +94,8 @@ def throttle_request if @request_count % 1000 == 0 elapsed_hour = @last_request_time - @hour_start_time current_rate = elapsed_hour > 0 ? (@request_count / elapsed_hour * 3600).round(1) : 0 - $stderr.print "Throttling status: #{@request_count} requests in #{elapsed_hour.round(1)}s (#{current_rate}/hour rate)\n" + github_info = @github_rate_limit_remaining ? " GitHub: #{@github_rate_limit_remaining} remaining" : "" + $stderr.print "Throttling status: #{@request_count} requests in #{elapsed_hour.round(1)}s (#{current_rate}/hour rate)#{github_info}\n" end end @@ -264,8 +304,22 @@ def member_activity # for each repo @repositories.each do |repo| - rate_limit = @client.rate_limit - info "rate limit remaining: #{rate_limit.remaining}/#{rate_limit.limit} " + # Show rate limit from last response headers (more efficient than API call) + if @client.last_response + remaining = @client.last_response.headers['x-ratelimit-remaining'] + limit = @client.last_response.headers['x-ratelimit-limit'] + if remaining && limit + reset_time = @client.last_response.headers['x-ratelimit-reset'] + if reset_time + minutes_until_reset = [(reset_time.to_i - Time.now.to_i) / 60.0, 0].max.round(1) + reset_info = " (resets in #{minutes_until_reset}min)" + else + reset_info = "" + end + info "#{remaining} requests remaining#{reset_info} " + end + end + info "analyzing #{repo}" commit_activity(repo) @@ -342,13 +396,13 @@ def member_activity builder.use Octokit::Middleware::FollowRedirects builder.use Octokit::Response::RaiseError builder.use Octokit::Response::FeedParser - builder.response :logger + builder.response :logger if @debug builder.adapter Faraday.default_adapter end Octokit.configure do |kit| kit.auto_paginate = true - kit.middleware = stack if @debug + kit.middleware = stack end options[:client] = Octokit::Client.new From 49953583eb866153c9a6bb19570f59f5f99c4714 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:31:52 +0000 Subject: [PATCH 08/14] Implement paginated_request helper for API calls and update organization member/repo methods to use it --- .../find_inactive_members.rb | 49 ++++++++++++++++--- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index 6bcec0322..ad104da10 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -183,6 +183,35 @@ def info(message) $stdout.print message end + # Helper method to manually paginate requests with proper throttling + def paginated_request(method, *args, **kwargs) + results = [] + page = 1 + per_page = 100 + + loop do + # Merge pagination parameters with existing kwargs + page_kwargs = kwargs.merge(page: page, per_page: per_page) + + # Handle different method signatures + if args.empty? + response = @client.send(method, page_kwargs) + else + response = @client.send(method, *args, page_kwargs) + end + + break if response.empty? + + results.concat(response) + page += 1 + + # Break if we got less than a full page (indicates last page) + break if response.length < per_page + end + + results + end + def member_email(login) @email ? @client.user(login)[:email] : "" end @@ -190,7 +219,8 @@ def member_email(login) def organization_members # get all organization members and place into an array of hashes info "Finding #{@organization} members " - @members = @client.organization_members(@organization).collect do |m| + members_data = paginated_request(:organization_members, @organization) + @members = members_data.collect do |m| email = { login: m["login"], @@ -204,7 +234,8 @@ def organization_members def organization_repositories info "Gathering a list of repositories..." # get all repos in the organizaton and place into a hash - @repositories = @client.organization_repositories(@organization).collect do |repo| + repos_data = paginated_request(:organization_repositories, @organization) + @repositories = repos_data.collect do |repo| repo["full_name"] end info "#{@repositories.length} repositories discovered\n" @@ -224,7 +255,8 @@ def commit_activity(repo) # get all commits after specified date and iterate info "...commits" begin - @client.commits_since(repo, @date).each do |commit| + commits = paginated_request(:commits_since, repo, @date) + commits.each do |commit| # if commmitter is a member of the org and not active, make active if commit["author"].nil? add_unrecognized_author(commit[:commit][:author]) @@ -246,7 +278,8 @@ def issue_activity(repo, date=@date) # get all issues after specified date and iterate info "...Issues" begin - @client.list_issues(repo, { :since => date }).each do |issue| + issues = paginated_request(:list_issues, repo, since: date) + issues.each do |issue| # if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION if issue["user"].nil? next @@ -266,7 +299,8 @@ def issue_comment_activity(repo, date=@date) # get all issue comments after specified date and iterate info "...Issue comments" begin - @client.issues_comments(repo, { :since => date }).each do |comment| + comments = paginated_request(:issues_comments, repo, since: date) + comments.each do |comment| # if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION if comment["user"].nil? next @@ -285,7 +319,8 @@ def issue_comment_activity(repo, date=@date) def pr_activity(repo, date=@date) # get all pull request comments comments after specified date and iterate info "...Pull Request comments" - @client.pull_requests_comments(repo, { :since => date }).each do |comment| + comments = paginated_request(:pull_requests_comments, repo, since: date) + comments.each do |comment| # if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION if comment["user"].nil? next @@ -401,7 +436,7 @@ def member_activity end Octokit.configure do |kit| - kit.auto_paginate = true + kit.auto_paginate = false # Disable auto-pagination to ensure throttling on each request kit.middleware = stack end From 75b9426755ab123986fbbf43bc18d9d87b7d12e6 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:46:35 +0000 Subject: [PATCH 09/14] Add dynamic rate limit handling in ThrottleMiddleware to pause on low limits --- .../find_inactive_members.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index ad104da10..e494ec5b3 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -66,6 +66,23 @@ def calculate_dynamic_delay def throttle_request current_time = Time.now + # Check if rate limit is critically low and pause until reset if needed + if @github_rate_limit_remaining && @github_rate_limit_remaining < 50 && @github_rate_limit_reset + time_until_reset = [@github_rate_limit_reset - current_time.to_i, 0].max + if time_until_reset > 0 + pause_time = time_until_reset + 5 # Add 5 second buffer + minutes = (pause_time / 60.0).round(1) + $stderr.print "\n⚠️ RATE LIMIT LOW: Only #{@github_rate_limit_remaining} requests remaining!\n" + $stderr.print "⏸️ Pausing for #{minutes} minutes until rate limit resets (#{pause_time} seconds total)\n" + $stderr.print "⏰ Will resume at approximately #{(Time.now + pause_time).strftime('%H:%M:%S')}\n\n" + sleep(pause_time) + + # Reset our tracking after the pause + @github_rate_limit_remaining = nil # Will be updated on next response + @github_rate_limit_reset = nil + end + end + # Reset counter if we've moved to a new hour (sliding window) if current_time - @hour_start_time >= 3600 @request_count = 0 From 1b37b16e3bcd743839049571aa0eccc2a042e084 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:49:20 +0000 Subject: [PATCH 10/14] Implement retry logic for handling 403 errors in InactiveMemberSearch methods --- .../find_inactive_members.rb | 60 ++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index e494ec5b3..c8f0c10ea 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -161,11 +161,16 @@ def check_app end def check_scopes - info "Scopes: #{@client.scopes.join ','}\n" + scopes = retry_on_403("checking scopes") do + @client.scopes + end + info "Scopes: #{scopes.join ','}\n" end def check_rate_limit - rate_limit = @client.rate_limit + rate_limit = retry_on_403("checking rate limit") do + @client.rate_limit + end info "Rate limit: #{rate_limit.remaining}/#{rate_limit.limit}\n" info "Rate limit resets at: #{rate_limit.resets_at}\n" info "Throttling: Limited to #{ThrottleMiddleware::MAX_REQUESTS_PER_HOUR} requests/hour (#{ThrottleMiddleware::MIN_DELAY_SECONDS.round(2)}s min delay)\n" @@ -200,6 +205,39 @@ def info(message) $stdout.print message end + # Helper method to handle 403 errors with retry logic + def retry_on_403(description, max_retries = 3) + retries = 0 + + loop do + begin + return yield + rescue Octokit::Forbidden => e + retries += 1 + info "⚠️ 403 Forbidden error occurred while #{description}\n" + + if retries <= max_retries + info "🔄 Waiting 5 seconds before retry #{retries}/#{max_retries}...\n" + sleep(5) + next + else + info "❌ Failed after #{max_retries} retries for #{description}\n" + print "🤔 Do you want to continue retrying? (Y/N): " + response = gets.chomp.upcase + + if response == 'Y' + info "🔄 Continuing with another #{max_retries} retry attempts...\n" + retries = 0 # Reset retry counter + next + else + info "🛑 User chose to exit. Stopping application.\n" + exit(1) + end + end + end + end + end + # Helper method to manually paginate requests with proper throttling def paginated_request(method, *args, **kwargs) results = [] @@ -210,11 +248,13 @@ def paginated_request(method, *args, **kwargs) # Merge pagination parameters with existing kwargs page_kwargs = kwargs.merge(page: page, per_page: per_page) - # Handle different method signatures - if args.empty? - response = @client.send(method, page_kwargs) - else - response = @client.send(method, *args, page_kwargs) + # Handle different method signatures with 403 retry logic + response = retry_on_403("fetching #{method} page #{page}") do + if args.empty? + @client.send(method, page_kwargs) + else + @client.send(method, *args, page_kwargs) + end end break if response.empty? @@ -230,7 +270,11 @@ def paginated_request(method, *args, **kwargs) end def member_email(login) - @email ? @client.user(login)[:email] : "" + return "" unless @email + + retry_on_403("fetching email for user #{login}") do + @client.user(login)[:email] + end end def organization_members From 13f786193ea9de4064056f807fcc037a5c0d6f6b Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:42:39 +0000 Subject: [PATCH 11/14] Add support for disabling API throttling and implement smart request handling --- .../find_inactive_members.rb | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index c8f0c10ea..3a42866c3 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -133,6 +133,14 @@ class InactiveMemberSearch def initialize(options={}) @client = options[:client] + @options = options # Store options for later use + + # Warn if throttling is disabled + if options[:no_throttle] + $stderr.print "⚠️ WARNING: API throttling is DISABLED! This may cause rate limit errors.\n" + $stderr.print "🚨 Use this option only for testing or with GitHub Enterprise instances with higher limits.\n\n" + end + if options[:check] check_app check_scopes @@ -269,6 +277,23 @@ def paginated_request(method, *args, **kwargs) results end + # Smart request method that uses auto-pagination when throttling is disabled + def smart_request(method, *args, **kwargs) + if @options[:no_throttle] + # Use auto-pagination (simpler, faster, but no throttling) + retry_on_403("fetching #{method}") do + if args.empty? + @client.send(method, kwargs) + else + @client.send(method, *args, kwargs) + end + end + else + # Use manual pagination with throttling + paginated_request(method, *args, **kwargs) + end + end + def member_email(login) return "" unless @email @@ -280,7 +305,7 @@ def member_email(login) def organization_members # get all organization members and place into an array of hashes info "Finding #{@organization} members " - members_data = paginated_request(:organization_members, @organization) + members_data = smart_request(:organization_members, @organization) @members = members_data.collect do |m| email = { @@ -295,7 +320,7 @@ def organization_members def organization_repositories info "Gathering a list of repositories..." # get all repos in the organizaton and place into a hash - repos_data = paginated_request(:organization_repositories, @organization) + repos_data = smart_request(:organization_repositories, @organization) @repositories = repos_data.collect do |repo| repo["full_name"] end @@ -316,7 +341,7 @@ def commit_activity(repo) # get all commits after specified date and iterate info "...commits" begin - commits = paginated_request(:commits_since, repo, @date) + commits = smart_request(:commits_since, repo, @date) commits.each do |commit| # if commmitter is a member of the org and not active, make active if commit["author"].nil? @@ -339,7 +364,7 @@ def issue_activity(repo, date=@date) # get all issues after specified date and iterate info "...Issues" begin - issues = paginated_request(:list_issues, repo, since: date) + issues = smart_request(:list_issues, repo, since: date) issues.each do |issue| # if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION if issue["user"].nil? @@ -360,7 +385,7 @@ def issue_comment_activity(repo, date=@date) # get all issue comments after specified date and iterate info "...Issue comments" begin - comments = paginated_request(:issues_comments, repo, since: date) + comments = smart_request(:issues_comments, repo, since: date) comments.each do |comment| # if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION if comment["user"].nil? @@ -380,7 +405,7 @@ def issue_comment_activity(repo, date=@date) def pr_activity(repo, date=@date) # get all pull request comments comments after specified date and iterate info "...Pull Request comments" - comments = paginated_request(:pull_requests_comments, repo, since: date) + comments = smart_request(:pull_requests_comments, repo, since: date) comments.each do |comment| # if there's no user (ghost user?) then skip this // THIS NEEDS BETTER VALIDATION if comment["user"].nil? @@ -481,14 +506,24 @@ def member_activity options[:verbose] = v end + opts.on('-t', '--no-throttle', "Disable API request throttling (use with caution)") do |t| + puts "DEBUG: -t flag was triggered, t value = #{t.inspect}" + options[:no_throttle] = true + end + opts.on('-h', '--help', "Display this help") do |h| puts opts exit 0 end end.parse! +# Debug: Check if no_throttle option is set +if options[:no_throttle] + puts "DEBUG: no_throttle option is set to: #{options[:no_throttle]}" +end + stack = Faraday::RackBuilder.new do |builder| - builder.use ThrottleMiddleware + builder.use ThrottleMiddleware unless options[:no_throttle] builder.use Octokit::Middleware::FollowRedirects builder.use Octokit::Response::RaiseError builder.use Octokit::Response::FeedParser @@ -496,8 +531,9 @@ def member_activity builder.adapter Faraday.default_adapter end +# Conditionally enable auto-pagination when throttling is disabled Octokit.configure do |kit| - kit.auto_paginate = false # Disable auto-pagination to ensure throttling on each request + kit.auto_paginate = options[:no_throttle] ? true : false kit.middleware = stack end From 7959c1c79bd0df87b8cdcaff713683262ce3db35 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Mon, 25 Aug 2025 23:46:49 +0000 Subject: [PATCH 12/14] Update README to clarify usage of --no-throttle option for API requests --- api/ruby/find-inactive-members/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/api/ruby/find-inactive-members/README.md b/api/ruby/find-inactive-members/README.md index 190d2f6f1..89155ce33 100644 --- a/api/ruby/find-inactive-members/README.md +++ b/api/ruby/find-inactive-members/README.md @@ -7,6 +7,7 @@ find_inactive_members.rb - Find and output inactive members in an organization -e, --email Fetch the user email (can make the script take longer) -o, --organization MANDATORY Organization to scan for inactive users -v, --verbose More output to STDERR + -t, --no-throttle Disable API request throttling (use with caution) -h, --help Display this help ``` From 8a82cfa60504622c9c21a12d09ee09093d08f0e6 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:52:59 +0000 Subject: [PATCH 13/14] Enhance README and code comments for clarity on rate limits and throttling behavior --- api/ruby/find-inactive-members/README.md | 18 +++++++++++++++++- .../find_inactive_members.rb | 9 ++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/api/ruby/find-inactive-members/README.md b/api/ruby/find-inactive-members/README.md index 89155ce33..f829894c3 100644 --- a/api/ruby/find-inactive-members/README.md +++ b/api/ruby/find-inactive-members/README.md @@ -32,8 +32,10 @@ gem install octokit faraday The `OCTOKIT_ACCESS_TOKEN` is required in order to see activities on private repositories. Also note that GitHub.com has an rate limit of 60 unauthenticated requests per hour, which this tool can easily exceed. Access tokens can be generated at https://github.com/settings/tokens. The `OCTOKIT_API_ENDPOINT` isn't required if connecting to GitHub.com, but is required if connecting to a GitHub Enterprise instance. +`OCTOKIT_ACCESS_TOKEN` needs the scopes `read:org`, `read:user`, `repo`, and `user:email`. + ```shell -export OCTOKIT_ACCESS_TOKEN=00000000000000000000000 # Required if looking for activity in private repositories. +export OCTOKIT_ACCESS_TOKEN=00000000000000000000000 # Required if looking for activity in private repositories. export OCTOKIT_API_ENDPOINT="https:///api/v3" # Not required if connecting to GitHub.com. ``` @@ -55,3 +57,17 @@ Members are defined as inactive if they **have not performed** any of the follow - Merged or pushed commits into the default branch - Opened an Issue or Pull Request - Commented on an Issue or Pull Request + +## Rate Limit + +The script will use the following rate limit headers returned by the API to throttle requests in order to stay within the rate limit. You can disable throttling using the `-t` option. + +| Header name | Description | +| --- | --- | +| `x-ratelimit-limit` | The maximum number of requests that you can make per hour | +| `x-ratelimit-remaining` | The number of requests remaining in the current rate limit window | +| `x-ratelimit-used` | The number of requests you have made in the current rate limit window | +| `x-ratelimit-reset` | The time at which the current rate limit window resets, in UTC epoch seconds | +| `x-ratelimit-resource` | The rate limit resource that the request counted against. | + +For more information about the different resources, see [REST API endpoints for rate limits](https://docs.github.com/en/rest/rate-limit/rate-limit#get-rate-limit-status-for-the-authenticated-user). \ No newline at end of file diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index 3a42866c3..a27028a31 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -112,7 +112,7 @@ def throttle_request elapsed_hour = @last_request_time - @hour_start_time current_rate = elapsed_hour > 0 ? (@request_count / elapsed_hour * 3600).round(1) : 0 github_info = @github_rate_limit_remaining ? " GitHub: #{@github_rate_limit_remaining} remaining" : "" - $stderr.print "Throttling status: #{@request_count} requests in #{elapsed_hour.round(1)}s (#{current_rate}/hour rate)#{github_info}\n" + $stderr.print "\nThrottling status: #{@request_count} requests in #{elapsed_hour.round(1)}s (#{current_rate}/hour rate)#{github_info}\n" end end @@ -122,7 +122,7 @@ def log_throttle_status elapsed_hour = Time.now - @hour_start_time rate_per_hour = elapsed_hour > 0 ? (@request_count / elapsed_hour * 3600).round(1) : 0 - $stderr.print "Throttle debug: #{@request_count} requests in last #{elapsed_hour.round(1)}s (#{rate_per_hour}/hour rate)\n" + $stderr.print "\nThrottle debug: #{@request_count} requests in last #{elapsed_hour.round(1)}s (#{rate_per_hour}/hour rate)\n" end end @@ -179,7 +179,7 @@ def check_rate_limit rate_limit = retry_on_403("checking rate limit") do @client.rate_limit end - info "Rate limit: #{rate_limit.remaining}/#{rate_limit.limit}\n" + info "\nRate limit: #{rate_limit.remaining}/#{rate_limit.limit}\n" info "Rate limit resets at: #{rate_limit.resets_at}\n" info "Throttling: Limited to #{ThrottleMiddleware::MAX_REQUESTS_PER_HOUR} requests/hour (#{ThrottleMiddleware::MIN_DELAY_SECONDS.round(2)}s min delay)\n" end @@ -222,7 +222,7 @@ def retry_on_403(description, max_retries = 3) return yield rescue Octokit::Forbidden => e retries += 1 - info "⚠️ 403 Forbidden error occurred while #{description}\n" + info "\n⚠️ 403 Forbidden error occurred while #{description}\n" if retries <= max_retries info "🔄 Waiting 5 seconds before retry #{retries}/#{max_retries}...\n" @@ -507,7 +507,6 @@ def member_activity end opts.on('-t', '--no-throttle', "Disable API request throttling (use with caution)") do |t| - puts "DEBUG: -t flag was triggered, t value = #{t.inspect}" options[:no_throttle] = true end From d0032463dc02ccfdf70bcd4608ed4350a4d20b00 Mon Sep 17 00:00:00 2001 From: Dave <50186003+thedave42@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:54:23 +0000 Subject: [PATCH 14/14] Improve debug logging in ThrottleMiddleware and update warning message for no_throttle option --- .../find-inactive-members/find_inactive_members.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/ruby/find-inactive-members/find_inactive_members.rb b/api/ruby/find-inactive-members/find_inactive_members.rb index a27028a31..803e6c2f0 100644 --- a/api/ruby/find-inactive-members/find_inactive_members.rb +++ b/api/ruby/find-inactive-members/find_inactive_members.rb @@ -15,7 +15,9 @@ def initialize(app, options = {}) @hour_start_time = Time.now @last_request_time = Time.now @mutex = Mutex.new - @debug_enabled = !ENV['THROTTLE_DEBUG'].nil? && !ENV['THROTTLE_DEBUG'].empty? + # Set @debug_enabled to true if the THROTTLE_DEBUG environment variable is set and not empty. + # When enabled, ThrottleMiddleware will output debug information to help diagnose throttling behavior. + # Usage: export THROTTLE_DEBUG=1 @debug_enabled = !ENV['THROTTLE_DEBUG'].nil? && !ENV['THROTTLE_DEBUG'].empty? @github_rate_limit_remaining = nil @github_rate_limit_reset = nil end @@ -98,8 +100,6 @@ def throttle_request if time_since_last < required_delay sleep_time = required_delay - time_since_last if sleep_time > 0 - #delay_reason = @github_rate_limit_remaining ? "dynamic" : "standard" - #$stderr.print "Throttling: waiting #{sleep_time.round(2)}s (#{delay_reason} delay)\n" sleep(sleep_time) end end @@ -307,7 +307,6 @@ def organization_members info "Finding #{@organization} members " members_data = smart_request(:organization_members, @organization) @members = members_data.collect do |m| - email = { login: m["login"], email: member_email(m[:login]), @@ -516,9 +515,9 @@ def member_activity end end.parse! -# Debug: Check if no_throttle option is set +# Check if no_throttle option is set and warn user if options[:no_throttle] - puts "DEBUG: no_throttle option is set to: #{options[:no_throttle]}" + puts "WARNING: no_throttle option is set to: #{options[:no_throttle]}" end stack = Faraday::RackBuilder.new do |builder|