summaryrefslogtreecommitdiff
path: root/lib/net/hippie/client.rb
blob: d6e072e1ff173af32854b38f97071fdc955e7f9e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# frozen_string_literal: true

require 'net/hippie/dns_cache'
require 'net/hippie/tls_parser'

module Net
  module Hippie
    # HTTP client with connection pooling, DNS caching, and retry logic.
    class Client
      include TlsParser

      DEFAULT_HEADERS = {
        'Accept' => 'application/json',
        'Content-Type' => 'application/json',
        'User-Agent' => "net/hippie #{VERSION}"
      }.freeze

      JITTER = Random.new.freeze

      attr_reader :mapper, :logger, :follow_redirects

      def initialize(options = {})
        @options = options
        @mapper = options.fetch(:mapper, ContentTypeMapper.new)
        @logger = options.fetch(:logger, Net::Hippie.logger)
        @follow_redirects = options.fetch(:follow_redirects, 0)
        @default_headers = options.fetch(:headers, DEFAULT_HEADERS).freeze
        configure_pool(options)
        configure_tls(options)
      end

      %i[get post put patch delete].each do |method|
        define_method(method) do |uri, headers: {}, body: {}, stream: false, &block|
          run(uri, Net::HTTP.const_get(method.capitalize), headers, body, stream, &block)
        end
      end

      %i[head options].each do |method|
        define_method(method) do |uri, headers: {}, &block|
          run(uri, Net::HTTP.const_get(method.capitalize), headers, {}, false, &block)
        end
      end

      def execute(uri, request, limit: follow_redirects, stream: false, &block)
        conn = connection_for(uri)
        return conn.run(request) { |res| res.read_body(&block) } if stream && block

        return execute_with_block(conn, request, &block) if block

        response = conn.run(request)
        limit.positive? && response.is_a?(Net::HTTPRedirection) ? follow_redirect(uri, response, limit) : response
      end

      def with_retry(retries: 3)
        retries = [retries.to_i, 0].max
        0.upto(retries) do |attempt|
          return yield self
        rescue *CONNECTION_ERRORS => error
          raise if attempt == retries

          sleep_with_backoff(attempt, retries, error)
        end
      end

      def close_all
        @pool.close_all
        @dns_cache.clear
      end

      private

      def configure_pool(options)
        @dns_ttl = options.fetch(:dns_ttl, 300)
        @dns_cache = DnsCache.new(
          timeout: options.fetch(:dns_timeout, 5), ttl: @dns_ttl, logger: @logger
        )
        @pool = ConnectionPool.new(max_size: options.fetch(:max_connections, 100), dns_ttl: @dns_ttl)
      end

      def configure_tls(options)
        @tls_cert = parse_cert(options[:certificate])
        @tls_key = parse_key(options[:key], options[:passphrase])
        @continue_timeout = options[:continue_timeout]
        @ignore_eof = options.fetch(:ignore_eof, true)
      end

      def run(uri, method, headers, body, stream, &block)
        uri = URI.parse(uri.to_s) unless uri.is_a?(URI::Generic)
        execute(uri, build_request(method, uri, headers, body), stream: stream, &block)
      end

      def build_request(type, uri, headers, body)
        merged = headers.empty? ? @default_headers : @default_headers.merge(headers)
        path = uri.respond_to?(:request_uri) ? uri.request_uri : uri.path
        type.new(path, merged).tap { |req| req.body = @mapper.map_from(merged, body) unless body.empty? }
      end

      def execute_with_block(conn, request, &block)
        block.arity == 2 ? yield(request, conn.run(request)) : conn.run(request, &block)
      end

      def connection_for(uri)
        uri = URI.parse(uri.to_s) unless uri.is_a?(URI::Generic)
        key = [uri.scheme, uri.host, uri.port]
        @pool.checkout(key) { Connection.new(uri.scheme, uri.host, uri.port, @dns_cache.resolve(uri.host), conn_opts) }
      end

      def sleep_with_backoff(attempt, max_retries, error)
        delay = ((2**attempt) * 0.1) + JITTER.rand(0.05)
        logger&.warn("[Hippie] #{error.class}: #{error.message} | Retry #{attempt + 1}/#{max_retries}")
        sleep delay
      end

      def conn_opts
        @options.merge(
          tls_cert: @tls_cert, tls_key: @tls_key,
          continue_timeout: @continue_timeout, ignore_eof: @ignore_eof
        )
      end

      def follow_redirect(original_uri, response, limit)
        redirect_uri = original_uri.merge(response['location'])
        execute(redirect_uri, build_request(Net::HTTP::Get, redirect_uri, {}, {}), limit: limit - 1)
      end
    end
  end
end