lib/fbe/middleware/rate_limit.rb
# frozen_string_literal: true # SPDX-FileCopyrightText: Copyright (c) 2024-2025 Zerocracy # SPDX-License-Identifier: MIT require 'faraday' require 'json' require_relative '../../fbe' require_relative '../../fbe/middleware' # Faraday middleware that caches GitHub API rate limit information. # # 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. # # @example Usage in Faraday middleware stack # connection = Faraday.new do |f| # f.use Fbe::Middleware::RateLimit # end # # Author:: Yegor Bugayenko (yegor256@gmail.com) # Copyright:: Copyright (c) 2024-2025 Zerocracy # License:: MIT class Fbe::Middleware::RateLimit < Faraday::Middleware # Initializes the rate limit middleware. # # @param [Object] app The next middleware in the stack def initialize(app) super @cached_response = nil @remaining_count = nil @request_counter = 0 end # Processes the HTTP request and handles rate limit caching. # # @param [Faraday::Env] env The request environment # @return [Faraday::Response] The response from cache or the next middleware def call(env) if env.url.path == '/rate_limit' handle_rate_limit_request(env) else track_request @app.call(env) end end private # Handles requests to the rate_limit endpoint. # # @param [Faraday::Env] env The request environment # @return [Faraday::Response] Cached or fresh response def handle_rate_limit_request(env) if @cached_response.nil? || @request_counter >= 100 response = @app.call(env) @cached_response = response.dup @remaining_count = extract_remaining_count(response) @request_counter = 0 response else response = @cached_response.dup update_remaining_count(response) Faraday::Response.new(response_env(env, response)) end end # Tracks non-rate_limit requests and decrements counter. def track_request return if @remaining_count.nil? @remaining_count -= 1 if @remaining_count.positive? @request_counter += 1 end # Extracts the remaining count from the response body. # # @param [Faraday::Response] response The API response # @return [Integer] The remaining requests count def extract_remaining_count(response) body = response.body if body.is_a?(String) begin body = JSON.parse(body) rescue JSON::ParserError return 0 end end return 0 unless body.is_a?(Hash) body.dig('rate', 'remaining') || 0 end # Updates the remaining count in the response body. # # @param [Faraday::Response] response The cached response to update def update_remaining_count(response) body = response.body original_was_string = body.is_a?(String) if original_was_string begin body = JSON.parse(body) rescue JSON::ParserError return end end return unless body.is_a?(Hash) && body['rate'] body['rate']['remaining'] = @remaining_count return unless original_was_string response.instance_variable_set(:@body, body.to_json) end # Creates a response environment for the cached response. # # @param [Faraday::Env] env The original request environment # @param [Faraday::Response] response The cached response # @return [Hash] Response environment hash def response_env(env, response) headers = response.headers.dup headers['x-ratelimit-remaining'] = @remaining_count.to_s if @remaining_count { method: env.method, url: env.url, request_headers: env.request_headers, request_body: env.request_body, status: response.status, response_headers: headers, body: response.body } end end