module RailsConnector class ContentService DEFAULT_PROTOCOL = 'https'.freeze DEFAULT_TIMEOUT = 10.freeze SOCKET_ERRORS = [ EOFError, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EINVAL, Errno::EPIPE, Errno::ETIMEDOUT, IOError, SocketError, Timeout::Error, ].freeze class RateLimitExceeded < StandardError def initialize(retry_after) @retry_after = retry_after end attr_reader :retry_after end class MaxSleepTimeReached < StandardError end class Delay def initialize @sleep_count = 0 @total_sleep_time = 0 end def next_sleep_time(min_sleep = 0) sleep_time = [2 ** @sleep_count * 0.5, min_sleep.to_f].max @total_sleep_time += sleep_time raise MaxSleepTimeReached if @total_sleep_time > 40 @sleep_count += 1 sleep_time end end class << self @next_request_not_before = nil def query(path, payload, options={}) timeout = options.fetch(:timeout, DEFAULT_TIMEOUT) retry_once_on_socket_error do retry_until_timeout_on_rate_limit_exceeded do ConnectionManager.ensure_started(uri, timeout) request = build_request(path, payload) if @next_request_not_before && @next_request_not_before > (now = Time.now) sleep @next_request_not_before - now end handle_response(ConnectionManager.connection(timeout).request(request)) end end end # for tests only def forget_retry_after @next_request_not_before = nil end private def retry_once_on_socket_error retried = false begin yield rescue *SOCKET_ERRORS => e raise BackendNotAvailable.from_socket_error(e) if retried ConnectionManager.ensure_finished retried = true retry end end def retry_until_timeout_on_rate_limit_exceeded delay = Delay.new begin yield rescue RateLimitExceeded => e begin sleep delay.next_sleep_time(e.retry_after) rescue MaxSleepTimeReached raise BackendNotAvailable.new('rate limit exceeded', 429) end retry end end def build_request(path, payload) request = Net::HTTP::Post.new("#{uri.path}/#{path}".squeeze('/'), request_headers) request.basic_auth(config['login'], config['api_key']) request.body = MultiJson.dump(payload) request end def handle_response(response) if response.code.first == '2' retry_after = response["Retry-After"] @next_request_not_before = retry_after ? Time.now + retry_after.to_f : nil MultiJson.load(response.body) elsif response.code == "429" raise RateLimitExceeded.new(response["Retry-After"]) else raise BackendNotAvailable.new("Server responded with status code #{response.code}", response.code) end end def uri url = config['url'] url = "#{DEFAULT_PROTOCOL}://#{url}" unless url.match /^http/ URI.parse(url) end def request_headers headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => user_agent, } if http_host = config['http_host'] headers['Host'] = http_host end headers end def config RailsConnector::Configuration.content_service end def user_agent @user_agent ||= ( gem_info = Gem.loaded_specs["infopark_cloud_connector"] "#{gem_info.name}-#{gem_info.version}" ) end end module ConnectionManager def self.connection(timeout=DEFAULT_TIMEOUT) configure_timeout(@connection, timeout) if @connection @connection end def self.connection=(conn) @connection = conn end def self.ensure_started(uri, timeout=DEFAULT_TIMEOUT) return if @connection && @connection.started? conn = Net::HTTP.new(uri.host, uri.port) if uri.scheme == 'https' conn.use_ssl = true conn.verify_mode = OpenSSL::SSL::VERIFY_PEER conn.ca_file = RailsConnector::Configuration.ca_file end configure_timeout(conn, timeout) retry_twice_on_socket_error do conn.start end @connection = conn end def self.ensure_finished @connection.finish if @connection && @connection.started? @connection = nil end class << self private def retry_twice_on_socket_error attempt = 0 begin yield rescue *ContentService::SOCKET_ERRORS => e raise BackendNotAvailable.from_socket_error(e) if attempt == 2 attempt += 1 retry end end def configure_timeout(connection, timeout) connection.open_timeout = timeout connection.read_timeout = timeout connection.ssl_timeout = timeout end end end end end