require 'connection_pool' module Scrivito class ConnectionManager DEFAULT_TIMEOUT = 10.freeze attr_reader :uri def self.user_agent @user_agent ||= "rubysdk/#{Scrivito::GemInfo.version}" end def self.request(request, timeout: nil, uri: nil) connection_pool_for(uri || Configuration.endpoint_uri).with do |connection| connection.request(request, timeout) end end # For test purpose only. def self.clear_cache! @connection_pools = nil @cert_store = nil end def self.cert_store @cert_store ||= OpenSSL::X509::Store.new.tap do |store| store.set_default_paths store.add_file(Configuration.ca_file) end end def self.minimum_open_timeout @minimum_open_timeout || 0.5 end def self.minimum_open_timeout=(value) @minimum_open_timeout = value end def self.minimum_ssl_timeout @minimum_ssl_timeout || 1.0 end def self.minimum_ssl_timeout=(value) @minimum_ssl_timeout = value end def self.minimum_read_timeout @minimum_read_timeout || 0.5 end def self.minimum_read_timeout=(value) @minimum_read_timeout = value end def self.connection_pool_for(uri) endpoint_url = "#{uri.scheme}://#{uri.host}:#{uri.port}" connection_pools.fetch(endpoint_url) do connection_pools[endpoint_url] = ConnectionPool.new(size: 100, timeout: 0) do new(URI.parse(endpoint_url)) end end end def self.connection_pools @connection_pools ||= {} end def initialize(uri) @uri = uri end def request(request, timeout = nil) timeout ||= DEFAULT_TIMEOUT request['User-Agent'] = ConnectionManager.user_agent ensure_started(timeout) # This should never happen! raise InternalError, 'Connection is already in use!' if @in_use @in_use = true connection.request(request) rescue => e ensure_finished raise NetworkError.from_socket_error(e) ensure @in_use = false end # for testing purposes def connect_count @connect_count || 0 end # For test purpose only. def in_use? !!@in_use end private attr_accessor :connection def ensure_started(timeout) cleanup_dead_connection if @connection && @connection.started? configure_timeout(@connection, timeout) else conn = Net::HTTP.new(uri.host, uri.port) if uri.scheme == 'https' conn.use_ssl = true conn.verify_mode = OpenSSL::SSL::VERIFY_PEER conn.cert_store = self.class.cert_store end configure_timeout(conn, timeout) # keep_alive_timeout is not a timeout as in "wait no longer than X", # but specifies how long a connection will be reused if it was idle. # therefore the 'timeout' parameter is not applied here. # instead we use a large value to maximize connection reuse. # (ELB uses a keep alive timeout of 60 seconds). conn.keep_alive_timeout = 59 retry_twice_on_socket_error do conn.start end increase_connect_count @connection = conn end end def increase_connect_count @connect_count ||= 0 @connect_count += 1 end def cleanup_dead_connection ensure_finished if connection_error? end # test if the http server closed the connection # (i.e. due to a server-side keep alive timeout) # # if the server closes a connection, the socket of the connection # is at EOF (End-Of-File). Using the connection would cause an error. # Unfortunatly net/http does not detect this. # # Running into an error would be okay-ish for idempotent http requests, # since retry is possible, but for non-idempotent requests (POST) the error # would raise to the user. # # So we have to hack our way around net/http to access the socket directly. # In order to do that, internals of net/http are accessed. # If these internals change in future ruby versions, this method should # simply returns `nil` (i.e. connection error are not detected) # but not raise errors. def connection_error? return unless @connection # compare: http://git.io/vltBR socket = @connection.instance_variable_get("@socket") return unless socket # compare: http://git.io/vltRu io = socket.try(:io) return unless io && io.respond_to?(:read_nonblock) begin io.read_nonblock(1) # if read_nonblock returns, there is unexpected data in the socket # this indicates a connection problem, since http servers should never # send data unless the client requests it. true rescue IO::WaitReadable, IO::WaitWritable # these "errors" indicate the connection is still usable false rescue # other errors indicate the connection has a problem true end end def ensure_finished @connection.finish if @connection && @connection.started? @connection = nil end def retry_twice_on_socket_error attempt = 0 begin yield rescue => e raise NetworkError.from_socket_error(e) if attempt == 2 attempt += 1 retry end end def configure_timeout(connection, timeout) connection.open_timeout = [ConnectionManager.minimum_open_timeout, timeout].max connection.read_timeout = [ConnectionManager.minimum_read_timeout, timeout].max connection.ssl_timeout = [ConnectionManager.minimum_ssl_timeout, timeout].max end end end