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