Skip to content

Instantly share code, notes, and snippets.

@GrantBirki
Last active July 31, 2025 06:06
Show Gist options
  • Save GrantBirki/450213036f8eed204d45a348d32d3e39 to your computer and use it in GitHub Desktop.
Save GrantBirki/450213036f8eed204d45a348d32d3e39 to your computer and use it in GitHub Desktop.
GitHub App Authentication with Octokit (Ruby)
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEArCtJrQ+P59A1Pjaf12EfJqltszDpqO0ufsk7N0WRXUeoYZKF
AihwLMIaHbTc1Jn/QEX8WmZGPKIcRlJ9pk2MxQbXRqxM35n61Cb8mYMne+6VXNyl
ZILvRTMXLGIVy/OTmszafTP8Lws9w8vKKLKCax4kEzbfjiRoCgspO7YudFUCxQ6Q
UUxlLPd4/yUaw5A8nUdWZrvPa3CYXG2355yhigRpnOyawoOsvAHUvQruh+z3k6NZ
g6f3eMjsrpeID84TPw1kRs+T+XNsSP21ZUFKYs54bdxlLPphT4iPKNkRwoP0SqJm
97W98B8k7+mtFPZllYGgiHrA4Egnasg9ULiXCwIDAQABAoIBACMoZ9AuWF2nN+gv
cW6jB6B2gs9P0rdLT+5WG4CK9UdOJcVfDUhGh7msHXcpgtrrY6N1ZzXyoq8pD4sQ
t1XpijCF2Bo3fy8+G2mNWJHkpYB6VQf0itW+oyvHZhkLIpZWdDLtWESvA/V7Xy6H
hA3Rfi5vpkBCOV6mcpRyeQYXit74UzDvojXSf8idsjuVDoIXuaoIJMPwyxTWJY+A
8sQ92ecHJ3rQLspxSmXlZYpRtHTNaZlAv1qx605DP/JlV/6bAA3IIWQtrd9Z0Cw/
rwNtwcP0vB/w6Yq9o8EmJHn7rQuC4NN6lMi/VkwaNCqcPfnRWW7OCtgOhfGAP+kr
vKU5kEECgYEA4hOWtUC4624E4r9NYNOeYcCj9meR5p3FY8vtPLMvpU9r4m3vPldz
ofq/I/Tpo418gWuy02/c4VpHo/8QUBW33yik0YVfL/XWczZwvhabufSTRL3HtIxb
NY5qbgV7yJea1mUuTkiM2obl4x+bR6haHPnHVu+QI480zOzkroF9P2ECgYEAwvUX
dpKebLxzod/UeQLvZFhFXG/qvvAJ7FwxtDRu22znQJR5YVdqd3XDZkfd+64PjDLf
46WMktqu2DclO9eFbKyuLfD0F1OO5z5IHd5dim39/QYo0sJ+y6y6edEM7Of2IxLH
5PkSJLVKAju5t2PJMXDBZBa4HVhNdW+lDHKlCesCgYEAzDvQCVw38g/JACK8P33N
dhe2x9IWv1TGTnqajhx+LYQLPVn9KL+OKcXBSTVmoCcgVDa8LUDANSD+2UuCLCcC
neo0w0cOj+Ax5JFI1qDL+/jT1eTwdc3aVA6dXVk80yEKcyai53upK310TnNuLxUK
m2SWzZXMDCPCGmLj0DYQtOECgYEAs3oIsKsH19ihpxs1MnZGRp2QtSl+9Wpr6EFz
rI88oxqdxfEp0Tg1lmY+jbGJpYI3Y/0N6jfksulJX1ldGLsvZL2P2FFjlPniq/XF
VGH6wU7DLSV3fZd6PSz1uuF+QbbF/MH0blHxpwOSb33mWfMuLCq+jtLvimxZWsx+
KHh+gSMCgYEA22osS9G3tGpG+x3nZRLXc0lOdKQ51sAo955U1BFscZusP/qQ+b12
KTHhoUd264C6Nb42XXs9qIkiKWaVblglJPhui61/iWw4s0k4Q9Yf/0KxREmlLbL0
mm1m2wenfA97PYoheO1esGsatSXNtxPL+C8ywebu38A6hOClQi1BHd4=
-----END RSA PRIVATE KEY-----
# frozen_string_literal: true
# This class provides a comprehensive wrapper around the Octokit client for GitHub App authentication.
# It handles token generation and refreshing, built-in retry logic, rate limiting, and delegates method calls to the Octokit client.
# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token
# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating...
# with octokit.rb.
# Most importantly, this class will handle automatic token refreshing, retries, and rate limiting for you out-of-the-box.
# Simply provide the correct environment variables, call `GitHub.new`, and then use the returned object as you would an Octokit client.
# Note: Environment variables have the `GH_` prefix because in GitHub Actions, you cannot use `GITHUB_` for secrets
require "octokit"
require "jwt"
require "redacting_logger"
class GitHub
TOKEN_EXPIRATION_TIME = 2700 # 45 minutes
JWT_EXPIRATION_TIME = 600 # 10 minutes
def initialize(log: nil, app_id: nil, installation_id: nil, app_key: nil, app_algo: nil)
@log = log || create_default_logger
# app ids are found on the App's settings page
@app_id = app_id || fetch_env_var("GH_APP_ID").to_i
# installation ids look like this:
# https://github.com/organizations/<org>/settings/installations/<8_digit_id>
@installation_id = installation_id || fetch_env_var("GH_APP_INSTALLATION_ID").to_i
# app keys are found on the App's settings page and can be downloaded
# format: "-----BEGIN...key\n...END-----\n"
# make sure this key in your env is a single line string with newlines as "\n"
@app_key = resolve_app_key(app_key)
@app_algo = app_algo || ENV.fetch("GH_APP_ALGO", "RS256")
@client = nil
@token_refresh_time = nil
@rate_limit_all = nil
setup_retry_config!
end
# A helper method to check the client's current rate limit status before making a request
# NOTE: This method will sleep for the remaining time until the rate limit resets if the rate limit is hit
# :param: type [Symbol] the type of rate limit to check (core, search, graphql, etc) - default: :core
# :return: nil (nothing) - this method will block until the rate limit is reset for the given type
def wait_for_rate_limit!(type = :core)
@log.debug("checking rate limit status for type: #{type}")
# make a request to get the comprehensive rate limit status
# note: checking the rate limit status does not count against the rate limit in any way
fetch_rate_limit if @rate_limit_all.nil?
details = rate_limit_details(type)
rate_limit = details[:rate_limit]
resets_at = details[:resets_at]
@log.debug(
"rate_limit remaining: #{rate_limit[:remaining]} - " \
"used: #{rate_limit[:used]} - " \
"resets_at: #{resets_at} - " \
"current time: #{Time.now}"
)
# exit early if the rate limit is not hit (we have remaining requests)
unless rate_limit[:remaining].zero?
update_rate_limit(type)
return
end
# if we make it here, we (probably) have hit the rate limit
# fetch the rate limit again if we are at zero or if the rate limit reset time is in the past
fetch_rate_limit if rate_limit[:remaining].zero? || rate_limit[:remaining] < 0 || resets_at < Time.now
details = rate_limit_details(type)
rate_limit = details[:rate_limit]
resets_at = details[:resets_at]
# exit early if the rate limit is not actually hit (we have remaining requests)
unless rate_limit[:remaining].zero?
@log.debug("rate_limit not hit - remaining: #{rate_limit[:remaining]}")
update_rate_limit(type)
return
end
# calculate the sleep duration - ex: reset time - current time
sleep_duration = resets_at - Time.now
@log.debug("sleep_duration: #{sleep_duration}")
sleep_duration = [sleep_duration, 0].max # ensure sleep duration is not negative
sleep_duration_and_a_little_more = sleep_duration.ceil + 2 # sleep a little more than the rate limit reset time
# log the sleep duration and begin the blocking sleep call
@log.info("github rate_limit hit: sleeping for: #{sleep_duration_and_a_little_more} seconds")
sleep(sleep_duration_and_a_little_more)
@log.info("github rate_limit sleep complete - Time.now: #{Time.now}")
end
private
# Creates a default logger if none is provided
# @return [RedactingLogger] A new logger instance
def create_default_logger
RedactingLogger.new($stdout, level: ENV.fetch("GH_APP_LOG_LEVEL", "INFO").upcase)
end
# Sets up retry configuration for handling API errors
# Should the number of retries be reached without success, the last exception will be raised
def setup_retry_config!
@retry_sleep = ENV.fetch("GH_APP_SLEEP", 3).to_i
@retry_tries = ENV.fetch("GH_APP_RETRIES", 10).to_i
@retry_exponential_backoff = ENV.fetch("GH_APP_EXPONENTIAL_BACKOFF", "false").downcase == "true"
end
# Custom retry logic with optional exponential backoff and logging
# @param retries [Integer] Number of retries to attempt
# @param sleep_time [Integer] Base sleep time between retries
# @param block [Proc] The block to execute with retry logic
# @return [Object] The result of the block execution
# When exponential backoff is enabled (default is disabled):
# 1st retry: 3 seconds
# 2nd retry: 6 seconds
# 3rd retry: 12 seconds
# 4th retry: 24 seconds
# When exponential backoff is disabled:
# All retries: 3 seconds (fixed rate)
def retry_request(retries: @retry_tries, sleep_time: @retry_sleep, &block)
attempt = 0
begin
attempt += 1
yield
rescue StandardError => e
if attempt < retries
if @retry_exponential_backoff
backoff_time = sleep_time * (2**(attempt - 1)) # Exponential backoff
else
backoff_time = sleep_time # Fixed rate
end
@log.debug("[retry ##{attempt}] #{e.class}: #{e.message} - sleeping #{backoff_time}s before retry")
sleep(backoff_time)
retry
else
@log.debug("[retry ##{attempt}] #{e.class}: #{e.message} - max retries exceeded")
raise e
end
end
end
def fetch_rate_limit
@rate_limit_all = retry_request do
client.get("rate_limit")
end
end
# Update the in-memory "cached" rate limit value for the given rate limit type
def update_rate_limit(type)
@rate_limit_all[:resources][type][:remaining] -= 1
end
def rate_limit_details(type)
# fetch the provided rate limit type
# rate_limit resulting structure: {:limit=>5000, :used=>15, :remaining=>4985, :reset=>1713897293}
rate_limit = @rate_limit_all[:resources][type]
# calculate the time the rate limit will reset
resets_at = Time.at(rate_limit[:reset]).utc
return {
rate_limit: rate_limit,
resets_at: resets_at,
}
end
private
# Fetches the value of an environment variable and raises an error if it is not set.
# @param key [String] The name of the environment variable.
# @return [String] The value of the environment variable.
def fetch_env_var(key)
ENV.fetch(key) { raise "environment variable #{key} is not set" }
end
# Resolves the app key from various sources
# @param app_key [String, nil] The app key parameter
# @return [String] The resolved app key content
def resolve_app_key(app_key)
# If app_key is provided as a parameter
if app_key
# Check if it's a file path (ends with .pem)
if app_key.end_with?(".pem")
unless File.exist?(app_key)
raise "App key file not found: #{app_key}"
end
@log.debug("Loading app key from file: #{app_key}")
key_content = File.read(app_key)
if key_content.strip.empty?
raise "App key file is empty: #{app_key}"
end
@log.debug("Successfully loaded app key from file (#{key_content.length} characters)")
return key_content
else
# It's a key string, process escape sequences
@log.debug("Using provided app key string")
return normalize_key_string(app_key)
end
end
# Fall back to environment variable
@log.debug("Loading app key from environment variable")
env_key = fetch_env_var("GH_APP_KEY")
normalize_key_string(env_key)
end
# Normalizes escape sequences in key strings safely
# @param key_string [String] The key string to normalize
# @return [String] The normalized key string
def normalize_key_string(key_string)
# Use simple string replacement to avoid ReDoS vulnerability
# This handles both single \n and multiple consecutive \\n sequences
key_string.gsub('\\n', "\n")
end
# Caches the octokit client if it is not nil and the token has not expired
# If it is nil or the token has expired, it creates a new client
# @return [Octokit::Client] The octokit client
def client
if @client.nil? || token_expired?
@client = create_client
end
@client
end
# A helper method for generating a JWT token for the GitHub App
# @return [String] The JWT token
def jwt_token
private_key = OpenSSL::PKey::RSA.new(@app_key)
payload = {}.tap do |opts|
opts[:iat] = Time.now.to_i - 60 # issued at time, 60 seconds in the past to allow for clock drift
opts[:exp] = opts[:iat] + JWT_EXPIRATION_TIME # JWT expiration time (10 minute maximum)
opts[:iss] = @app_id # GitHub App ID
end
JWT.encode(payload, private_key, @app_algo)
end
# Creates a new octokit client and fetches a new installation access token
# @return [Octokit::Client] The octokit client
def create_client
client = ::Octokit::Client.new(bearer_token: jwt_token)
access_token = client.create_app_installation_access_token(@installation_id)[:token]
client = ::Octokit::Client.new(access_token:)
client.auto_paginate = true
client.per_page = 100
@token_refresh_time = Time.now
client
end
# GitHub App installation access tokens expire after 1h
# This method checks if the token has expired and returns true if it has
# It is very cautious and expires tokens at 45 minutes to account for clock drift
# @return [Boolean] True if the token has expired, false otherwise
def token_expired?
@token_refresh_time.nil? || (Time.now - @token_refresh_time) > TOKEN_EXPIRATION_TIME
end
# This method is called when a method is called on the GitHub class that does not exist.
# It delegates the method call to the Octokit client with built-in retry logic and rate limiting.
# @param method [Symbol] The name of the method being called.
# @param args [Array] The arguments passed to the method.
# @param block [Proc] An optional block passed to the method.
# @return [Object] The result of the method call on the Octokit client.
def method_missing(method, *args, **kwargs, &block)
# Check if retry is explicitly disabled for this call
disable_retry = kwargs.delete(:disable_retry) || false
# Determine the rate limit type based on the method name and arguments
rate_limit_type = case method.to_s
when /search_/
:search
when /graphql/
# :nocov:
:graphql # I don't actually know of any endpoints that match this method sig yet
# :nocov:
else
# Check if this is a GraphQL call via POST
if method.to_s == "post" && args.first&.include?("/graphql")
:graphql
else
:core
end
end
# Handle special case for search_issues which can hit secondary rate limits
if method.to_s == "search_issues"
request_proc = proc do
wait_for_rate_limit!(rate_limit_type)
client.send(method, *args, **kwargs, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
end
begin
if disable_retry
request_proc.call
else
retry_request(&request_proc)
end
rescue StandardError => e
# re-raise the error but if its a secondary rate limit error, just sleep for a minute
if e.message.include?("exceeded a secondary rate limit")
@log.warn("GitHub secondary rate limit hit, sleeping for 60 seconds")
sleep(60)
end
raise e
end
else
# For all other methods, use standard retry and rate limiting
request_proc = proc do
wait_for_rate_limit!(rate_limit_type)
client.send(method, *args, **kwargs, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
end
if disable_retry
request_proc.call
else
retry_request(&request_proc)
end
end
end
# This method is called to check if the GitHub class responds to a method.
# It checks if the Octokit client responds to the method.
# @param method [Symbol] The name of the method being checked.
# @param include_private [Boolean] Whether to include private methods in the check.
# @return [Boolean] True if the Octokit client responds to the method, false otherwise.
def respond_to_missing?(method, include_private = false)
client.respond_to?(method, include_private) || super
end
end
# frozen_string_literal: true
# Here is a complete rspec unit test file for github.rb
# It will give you 100% test coverage!
# Just make sure to change the require_relative path to point to your actual github.rb file
# You will also need to commit a fake private key for your unit tests and one can be found in this Gist.
require "spec_helper"
require_relative "path/to/file/github"
describe GitHub do
let(:app_id) { 123 }
let(:installation_id) { 456 }
let(:app_key) { File.read("spec/fixtures/fake_private_key.pem") }
let(:jwt_token) { "mocked_jwt_token" }
let(:access_token) { "mocked_access_token" }
let(:mock_client) { instance_double(Octokit::Client) }
let(:mock_logger) { instance_double(RedactingLogger) }
let(:default_rate_limit_response) do
{
resources: {
core: { remaining: 5000, used: 0, limit: 5000, reset: (Time.now + 3600).to_i },
search: { remaining: 30, used: 0, limit: 30, reset: (Time.now + 3600).to_i },
graphql: { remaining: 5000, used: 0, limit: 5000, reset: (Time.now + 3600).to_i }
}
}
end
let(:rate_limit_hit_response) do
{
resources: {
core: { remaining: 0, used: 5000, limit: 5000, reset: (Time.now + 10).to_i }
}
}
end
def stub_environment_vars
allow(ENV).to receive(:fetch).and_call_original
%w[GH_APP_ID GH_APP_INSTALLATION_ID GH_APP_KEY GH_APP_LOG_LEVEL
GH_APP_SLEEP GH_APP_RETRIES GH_APP_EXPONENTIAL_BACKOFF GH_APP_ALGO].each do |var|
default_value = case var
when "GH_APP_ID" then app_id.to_s
when "GH_APP_INSTALLATION_ID" then installation_id.to_s
when "GH_APP_KEY" then app_key
when "GH_APP_LOG_LEVEL" then "INFO"
when "GH_APP_SLEEP" then "3"
when "GH_APP_RETRIES" then "10"
when "GH_APP_EXPONENTIAL_BACKOFF" then "false"
when "GH_APP_ALGO" then "RS256"
end
allow(ENV).to receive(:fetch).with(var, anything).and_return(default_value)
allow(ENV).to receive(:fetch).with(var).and_return(default_value)
end
end
def stub_dependencies
# Stub logger
[:debug, :info, :warn, :error].each { |method| allow(mock_logger).to receive(method) }
allow(RedactingLogger).to receive(:new).and_return(mock_logger)
# Stub Octokit client
allow(Octokit::Client).to receive(:new).and_return(mock_client)
allow(mock_client).to receive(:auto_paginate=).with(true)
allow(mock_client).to receive(:per_page=).with(100)
allow(mock_client).to receive(:create_app_installation_access_token)
.with(installation_id).and_return(token: access_token)
# Stub JWT and Time
allow(JWT).to receive(:encode).and_return(jwt_token)
allow(Time).to receive(:now).and_return(Time.at(1640995200))
end
before do
stub_environment_vars
stub_dependencies
end
describe "#initialize" do
it "initializes with environment variables" do
github = GitHub.new
expect(github.instance_variable_get(:@app_id)).to eq(app_id)
expect(github.instance_variable_get(:@installation_id)).to eq(installation_id)
expect(github.instance_variable_get(:@app_key)).to eq(app_key.gsub('\\n', "\n"))
expect(github.instance_variable_get(:@app_algo)).to eq("RS256")
end
it "initializes with provided parameters" do
custom_logger = instance_double(RedactingLogger)
allow(custom_logger).to receive(:debug)
github = GitHub.new(log: custom_logger, app_id: 999, installation_id: 888,
app_key: app_key, app_algo: "RS512")
expect(github.instance_variable_get(:@log)).to eq(custom_logger)
expect(github.instance_variable_get(:@app_id)).to eq(999)
expect(github.instance_variable_get(:@installation_id)).to eq(888)
expect(github.instance_variable_get(:@app_key)).to eq(app_key.gsub('\\n', "\n"))
expect(github.instance_variable_get(:@app_algo)).to eq("RS512")
end
it "loads app key from .pem file path" do
pem_file_path = "spec/fixtures/fake_private_key.pem"
github = GitHub.new(app_id: 999, installation_id: 888, app_key: pem_file_path)
expected_key = File.read(pem_file_path)
expect(github.instance_variable_get(:@app_key)).to eq(expected_key)
end
context "error handling" do
it "raises error when app key file doesn't exist" do
expect {
GitHub.new(app_id: 999, installation_id: 888, app_key: "nonexistent_file.pem")
}.to raise_error("App key file not found: nonexistent_file.pem")
end
it "raises error when app key file is empty" do
empty_file_path = "spec/fixtures/empty_key.pem"
File.write(empty_file_path, "")
begin
expect {
GitHub.new(app_id: 999, installation_id: 888, app_key: empty_file_path)
}.to raise_error("App key file is empty: #{empty_file_path}")
ensure
File.delete(empty_file_path) if File.exist?(empty_file_path)
end
end
it "raises error when environment variable is missing" do
allow(ENV).to receive(:fetch).with("GH_APP_KEY") { raise "environment variable GH_APP_KEY is not set" }
expect {
GitHub.new(app_id: 999, installation_id: 888)
}.to raise_error(/environment variable GH_APP_KEY is not set/)
end
end
it "processes escape sequences in app key string" do
key_with_escapes = "-----BEGIN RSA PRIVATE KEY-----\\nsome\\nkey\\ndata\\n-----END RSA PRIVATE KEY-----"
github = GitHub.new(app_id: 999, installation_id: 888, app_key: key_with_escapes)
expected_key = key_with_escapes.gsub('\\n', "\n")
expect(github.instance_variable_get(:@app_key)).to eq(expected_key)
end
it "falls back to environment variables when parameters are not provided" do
github = GitHub.new(app_id: 999, installation_id: 888)
expect(github.instance_variable_get(:@app_key)).to eq(app_key.gsub('\\n', "\n"))
end
it "creates default logger when none provided" do
github = GitHub.new
expect(github.instance_variable_get(:@log)).to eq(mock_logger)
end
end
describe "private methods" do
let(:github) { GitHub.new }
describe "#client" do
it "creates a new client when client is nil" do
expect(github.send(:client)).to eq(mock_client)
expect(mock_client).to have_received(:create_app_installation_access_token).with(installation_id)
end
it "creates a new client when token is expired" do
github.instance_variable_set(:@token_refresh_time, Time.now - GitHub::TOKEN_EXPIRATION_TIME - 1)
expect(github.send(:client)).to eq(mock_client)
end
it "returns cached client when token is not expired" do
github.instance_variable_set(:@client, mock_client)
github.instance_variable_set(:@token_refresh_time, Time.now)
expect(Octokit::Client).not_to receive(:new)
expect(github.send(:client)).to eq(mock_client)
end
end
describe "#jwt_token" do
it "generates a JWT token with correct payload" do
expect(JWT).to receive(:encode).with(
hash_including(iat: kind_of(Integer), exp: kind_of(Integer), iss: app_id),
kind_of(OpenSSL::PKey::RSA), "RS256"
).and_return(jwt_token)
result = github.send(:jwt_token)
expect(result).to eq(jwt_token)
end
it "raises OpenSSL error for invalid RSA private key" do
invalid_github = GitHub.new(app_id: 123, app_key: "invalid-key-content")
allow(JWT).to receive(:encode).and_call_original
expect { invalid_github.send(:jwt_token) }.to raise_error(OpenSSL::PKey::RSAError)
end
end
describe "#token_expired?" do
it "returns true when token_refresh_time is nil" do
github.instance_variable_set(:@token_refresh_time, nil)
expect(github.send(:token_expired?)).to be true
end
it "returns true when token has expired" do
github.instance_variable_set(:@token_refresh_time, Time.now - GitHub::TOKEN_EXPIRATION_TIME - 1)
expect(github.send(:token_expired?)).to be true
end
it "returns false when token has not expired" do
github.instance_variable_set(:@token_refresh_time, Time.now)
expect(github.send(:token_expired?)).to be false
end
end
end
describe "#wait_for_rate_limit!" do
let(:github) { GitHub.new }
before do
allow(github).to receive(:client).and_return(mock_client)
end
it "fetches rate limit when not cached" do
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response)
github.wait_for_rate_limit!(:core)
expect(mock_client).to have_received(:get).with("rate_limit")
end
it "exits early when rate limit is not hit" do
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response)
expect(github).not_to receive(:sleep)
github.wait_for_rate_limit!(:core)
end
it "sleeps when rate limit is hit" do
allow(mock_client).to receive(:get).with("rate_limit").and_return(rate_limit_hit_response)
allow(github).to receive(:sleep)
github.wait_for_rate_limit!(:core)
expect(github).to have_received(:sleep)
expect(mock_logger).to have_received(:info).with(/github rate_limit hit: sleeping for:/)
end
it "handles rate limit that resets after refresh" do
first_response = {
resources: { core: { remaining: 0, used: 5000, limit: 5000, reset: (Time.now - 10).to_i } }
}
second_response = {
resources: { core: { remaining: 5000, used: 0, limit: 5000, reset: (Time.now + 3600).to_i } }
}
allow(mock_client).to receive(:get).with("rate_limit").and_return(first_response, second_response)
expect { github.wait_for_rate_limit!(:core) }.not_to raise_error
end
it "updates rate limit count for different types" do
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response)
github.wait_for_rate_limit!(:search)
rate_limit_all = github.instance_variable_get(:@rate_limit_all)
expect(rate_limit_all[:resources][:search][:remaining]).to eq(29) # 30 - 1
end
end
describe "#method_missing" do
let(:github) { GitHub.new }
before do
allow(github).to receive(:client).and_return(mock_client)
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response)
end
it "delegates method calls to the Octokit client with appropriate rate limit types" do
# Core rate limit
allow(mock_client).to receive(:rate_limit).and_return("mocked_response")
result = github.rate_limit
expect(result).to eq("mocked_response")
# Search rate limit
allow(mock_client).to receive(:search_users).with("test").and_return("search_result")
result = github.search_users("test")
expect(result).to eq("search_result")
# GraphQL rate limit
allow(mock_client).to receive(:post).with("/graphql", { query: "test" }).and_return("graphql_result")
result = github.post("/graphql", { query: "test" })
expect(result).to eq("graphql_result")
end
it "handles POST with various arguments" do
allow(mock_client).to receive(:post).with("/some/endpoint", { data: "test" }).and_return("post_result")
result = github.post("/some/endpoint", { data: "test" })
expect(result).to eq("post_result")
allow(mock_client).to receive(:post).with(nil).and_return("nil_post_result")
result = github.post(nil)
expect(result).to eq("nil_post_result")
end
it "handles search_issues with secondary rate limit error" do
secondary_rate_limit_error = StandardError.new("You have exceeded a secondary rate limit")
allow(mock_client).to receive(:search_issues).with("test").and_raise(secondary_rate_limit_error)
allow(github).to receive(:sleep)
expect {
github.search_issues("test")
}.to raise_error(StandardError, /exceeded a secondary rate limit/)
expect(mock_logger).to have_received(:warn).with(/GitHub secondary rate limit hit, sleeping for 60 seconds/)
expect(github).to have_received(:sleep).with(60)
end
it "handles search_issues successful call" do
allow(mock_client).to receive(:search_issues).with("test").and_return("search_issues_result")
result = github.search_issues("test")
expect(result).to eq("search_issues_result")
end
context "retry behavior" do
it "retries failed requests" do
allow(mock_client).to receive(:user).and_raise(StandardError.new("Network error")).once
allow(mock_client).to receive(:user).and_return("user_data")
result = github.user
expect(result).to eq("user_data")
end
it "retries with fixed rate (default behavior)" do
error_count = 0
allow(mock_client).to receive(:repositories) do
error_count += 1
if error_count < 3
raise StandardError.new("Temporary error")
else
"repos_data"
end
end
allow(github).to receive(:sleep)
result = github.repositories
expect(result).to eq("repos_data")
expect(github).to have_received(:sleep).twice
end
it "retries with exponential backoff when enabled" do
allow(ENV).to receive(:fetch).with("GH_APP_EXPONENTIAL_BACKOFF", "false").and_return("true")
github_exponential = GitHub.new
allow(github_exponential).to receive(:client).and_return(mock_client)
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response)
error_count = 0
allow(mock_client).to receive(:organizations) do
error_count += 1
error_count < 3 ? raise(StandardError.new("Temporary error")) : "orgs_data"
end
allow(github_exponential).to receive(:sleep)
result = github_exponential.organizations
expect(result).to eq("orgs_data")
expect(github_exponential).to have_received(:sleep).twice
end
it "gives up after max retries" do
allow(mock_client).to receive(:organizations).and_raise(StandardError.new("Persistent error"))
allow(github).to receive(:sleep)
expect {
github.organizations
}.to raise_error(StandardError, "Persistent error")
expect(github).to have_received(:sleep).exactly(9).times
end
it "bypasses retry logic when disable_retry is true for search_issues" do
allow(mock_client).to receive(:search_issues).with("test").and_raise(StandardError.new("Network error"))
allow(github).to receive(:sleep)
expect {
github.search_issues("test", disable_retry: true)
}.to raise_error(StandardError, "Network error")
expect(github).not_to have_received(:sleep)
end
it "bypasses retry logic when disable_retry is true for other methods" do
allow(mock_client).to receive(:user).and_raise(StandardError.new("Network error"))
allow(github).to receive(:sleep)
expect {
github.user(disable_retry: true)
}.to raise_error(StandardError, "Network error")
expect(github).not_to have_received(:sleep)
end
end
end
describe "#respond_to_missing?" do
let(:github) { GitHub.new }
before do
allow(github).to receive(:client).and_return(mock_client)
end
it "returns true when Octokit client responds to method" do
allow(mock_client).to receive(:respond_to?).with(:rate_limit, false).and_return(true)
expect(github.respond_to?(:rate_limit)).to be true
end
it "returns false when Octokit client does not respond to method" do
allow(mock_client).to receive(:respond_to?).with(:nonexistent_method, false).and_return(false)
expect(github.respond_to?(:nonexistent_method)).to be false
end
it "includes private methods when specified" do
allow(mock_client).to receive(:respond_to?).with(:private_method, true).and_return(true)
expect(github.respond_to?(:private_method, true)).to be true
end
end
describe "integration scenarios" do
let(:github) { GitHub.new }
before do
allow(github).to receive(:client).and_return(mock_client)
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response)
end
it "handles complete workflow: rate limit check, API call, response" do
allow(mock_client).to receive(:user).and_return({ login: "testuser" })
result = github.user
expect(result).to eq({ login: "testuser" })
expect(mock_client).to have_received(:get).with("rate_limit")
expect(mock_client).to have_received(:user)
end
it "handles authentication failure gracefully" do
auth_error = StandardError.new("Authentication failed")
allow(mock_client).to receive(:user).and_raise(auth_error)
expect { github.user }.to raise_error(StandardError, "Authentication failed")
end
it "properly initializes retry configuration" do
retry_sleep = github.instance_variable_get(:@retry_sleep)
retry_tries = github.instance_variable_get(:@retry_tries)
retry_exponential_backoff = github.instance_variable_get(:@retry_exponential_backoff)
expect(retry_sleep).to eq(3)
expect(retry_tries).to eq(10)
expect(retry_exponential_backoff).to be false
end
it "allows enabling exponential backoff" do
allow(ENV).to receive(:fetch).with("GH_APP_EXPONENTIAL_BACKOFF", "false").and_return("true")
github = GitHub.new
retry_exponential_backoff = github.instance_variable_get(:@retry_exponential_backoff)
expect(retry_exponential_backoff).to be true
end
end
end
# frozen_string_literal: true
# Here is a snippet of what your spec_helper might look like when including this github.rb code in your project
# The key bits to note is really just around '# Globally capture all sleep calls' since the github.rb client
# calls out to sleep methods when doing retry logic
require "simplecov"
require "rspec"
require "simplecov-erb"
TIME_MOCK = "2025-01-01T00:00:00Z"
COV_DIR = File.expand_path("../coverage", File.dirname(__FILE__))
SimpleCov.root File.expand_path("..", File.dirname(__FILE__))
SimpleCov.coverage_dir COV_DIR
SimpleCov.formatters = [
SimpleCov::Formatter::HTMLFormatter,
SimpleCov::Formatter::ERBFormatter
]
SimpleCov.minimum_coverage 100
SimpleCov.at_exit do
File.write("#{COV_DIR}/total-coverage.txt", SimpleCov.result.covered_percent)
SimpleCov.result.format!
end
SimpleCov.start do
add_filter "spec/"
add_filter "vendor/gems/"
end
# Globally capture all sleep calls
RSpec.configure do |config|
config.before(:each) do
fake_time = Time.parse(TIME_MOCK)
allow(Time).to receive(:now).and_return(fake_time)
allow(Time).to receive(:new).and_return(fake_time)
allow(Time).to receive(:iso8601).and_return(fake_time)
allow(fake_time).to receive(:utc).and_return(fake_time)
allow(fake_time).to receive(:iso8601).and_return(TIME_MOCK)
allow(Kernel).to receive(:sleep)
allow_any_instance_of(Kernel).to receive(:sleep)
allow_any_instance_of(Object).to receive(:sleep)
end
end
@GrantBirki
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment