lib/lhc/interceptors/throttle.rb
# frozen_string_literal: true require 'active_support/duration' class LHC::Throttle < LHC::Interceptor class OutOfQuota < StandardError end class << self attr_accessor :track end def before_request options = request.options.dig(:throttle) return unless options break_options = options.dig(:break) return unless break_options break_when_quota_reached! if break_options.match?('%') end def after_response options = response.request.options.dig(:throttle) return unless throttle?(options) self.class.track ||= {} self.class.track[options.dig(:provider)] = { limit: limit(options: options[:limit], response: response), remaining: remaining(options: options[:remaining], response: response), expires: expires(options: options[:expires], response: response) } end private def throttle?(options) [options&.dig(:track), response.headers].none?(&:blank?) end def break_when_quota_reached! options = request.options.dig(:throttle) track = (self.class.track || {}).dig(options[:provider]) return if track.blank? || track[:remaining].blank? || track[:limit].blank? || track[:expires].blank? return if Time.zone.now > track[:expires] # avoid floats by multiplying with 100 remaining = track[:remaining] * 100 limit = track[:limit] quota = 100 - options[:break].to_i raise(OutOfQuota, "Reached predefined quota for #{options[:provider]}") if remaining < quota * limit end def limit(options:, response:) @limit ||= if options.is_a?(Proc) options.call(response) elsif options.is_a?(Integer) options elsif options.is_a?(Hash) && options[:header] response.headers[options[:header]]&.to_i end end def remaining(options:, response:) @remaining ||= begin if options.is_a?(Proc) options.call(response) elsif options.is_a?(Hash) && options[:header] response.headers[options[:header]]&.to_i end end end def expires(options:, response:) @expires ||= convert_expires(read_expire_option(options, response)) end def read_expire_option(options, response) (options.is_a?(Hash) && options[:header]) ? response.headers[options[:header]] : options end def convert_expires(value) return if value.blank? return value.call(response) if value.is_a?(Proc) return Time.parse(value) if value.match?(/GMT/) Time.zone.at(value.to_i).to_datetime end end