main
  1# frozen_string_literal: true
  2
  3require 'net/hippie/dns_cache'
  4require 'net/hippie/tls_parser'
  5
  6module Net
  7  module Hippie
  8    # HTTP client with connection pooling, DNS caching, and retry logic.
  9    class Client
 10      include TlsParser
 11
 12      DEFAULT_HEADERS = {
 13        'Accept' => 'application/json',
 14        'Content-Type' => 'application/json',
 15        'User-Agent' => "net/hippie #{VERSION}"
 16      }.freeze
 17
 18      JITTER = Random.new.freeze
 19
 20      attr_reader :mapper, :logger, :follow_redirects
 21
 22      def initialize(options = {})
 23        @options = options
 24        @mapper = options.fetch(:mapper, ContentTypeMapper.new)
 25        @logger = options.fetch(:logger, Net::Hippie.logger)
 26        @follow_redirects = options.fetch(:follow_redirects, 0)
 27        @default_headers = options.fetch(:headers, DEFAULT_HEADERS).freeze
 28        configure_pool(options)
 29        configure_tls(options)
 30      end
 31
 32      %i[get post put patch delete].each do |method|
 33        define_method(method) do |uri, headers: {}, body: {}, stream: false, &block|
 34          run(uri, Net::HTTP.const_get(method.capitalize), headers, body, stream, &block)
 35        end
 36      end
 37
 38      %i[head options].each do |method|
 39        define_method(method) do |uri, headers: {}, &block|
 40          run(uri, Net::HTTP.const_get(method.capitalize), headers, {}, false, &block)
 41        end
 42      end
 43
 44      def execute(uri, request, limit: follow_redirects, stream: false, &block)
 45        conn = connection_for(uri)
 46        return conn.run(request) { |res| res.read_body(&block) } if stream && block
 47
 48        return execute_with_block(conn, request, &block) if block
 49
 50        response = conn.run(request)
 51        limit.positive? && response.is_a?(Net::HTTPRedirection) ? follow_redirect(uri, response, limit) : response
 52      end
 53
 54      def with_retry(retries: 3)
 55        retries = [retries.to_i, 0].max
 56        0.upto(retries) do |attempt|
 57          return yield self
 58        rescue *CONNECTION_ERRORS => error
 59          raise if attempt == retries
 60
 61          sleep_with_backoff(attempt, retries, error)
 62        end
 63      end
 64
 65      def close_all
 66        @pool.close_all
 67        @dns_cache.clear
 68      end
 69
 70      private
 71
 72      def configure_pool(options)
 73        @dns_ttl = options.fetch(:dns_ttl, 300)
 74        @dns_cache = DnsCache.new(
 75          timeout: options.fetch(:dns_timeout, 5), ttl: @dns_ttl, logger: @logger
 76        )
 77        @pool = ConnectionPool.new(max_size: options.fetch(:max_connections, 100), dns_ttl: @dns_ttl)
 78      end
 79
 80      def configure_tls(options)
 81        @tls_cert = parse_cert(options[:certificate])
 82        @tls_key = parse_key(options[:key], options[:passphrase])
 83        @continue_timeout = options[:continue_timeout]
 84        @ignore_eof = options.fetch(:ignore_eof, true)
 85      end
 86
 87      def run(uri, method, headers, body, stream, &block)
 88        uri = URI.parse(uri.to_s) unless uri.is_a?(URI::Generic)
 89        execute(uri, build_request(method, uri, headers, body), stream: stream, &block)
 90      end
 91
 92      def build_request(type, uri, headers, body)
 93        merged = headers.empty? ? @default_headers : @default_headers.merge(headers)
 94        path = uri.respond_to?(:request_uri) ? uri.request_uri : uri.path
 95        type.new(path, merged).tap { |req| req.body = @mapper.map_from(merged, body) unless body.empty? }
 96      end
 97
 98      def execute_with_block(conn, request, &block)
 99        block.arity == 2 ? yield(request, conn.run(request)) : conn.run(request, &block)
100      end
101
102      def connection_for(uri)
103        uri = URI.parse(uri.to_s) unless uri.is_a?(URI::Generic)
104        key = [uri.scheme, uri.host, uri.port]
105        @pool.checkout(key) { Connection.new(uri.scheme, uri.host, uri.port, @dns_cache.resolve(uri.host), conn_opts) }
106      end
107
108      def sleep_with_backoff(attempt, max_retries, error)
109        delay = ((2**attempt) * 0.1) + JITTER.rand(0.05)
110        logger&.warn("[Hippie] #{error.class}: #{error.message} | Retry #{attempt + 1}/#{max_retries}")
111        sleep delay
112      end
113
114      def conn_opts
115        @options.merge(
116          tls_cert: @tls_cert, tls_key: @tls_key,
117          continue_timeout: @continue_timeout, ignore_eof: @ignore_eof
118        )
119      end
120
121      def follow_redirect(original_uri, response, limit)
122        redirect_uri = original_uri.merge(response['location'])
123        execute(redirect_uri, build_request(Net::HTTP::Get, redirect_uri, {}, {}), limit: limit - 1)
124      end
125    end
126  end
127end