summaryrefslogtreecommitdiff
path: root/lib/net/hippie/client.rb
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2026-01-31 23:57:00 -0700
committermo khan <mo@mokhan.ca>2026-01-31 23:57:00 -0700
commit43fe420b419dee4e760288761a45ba47eb28ab2e (patch)
tree8278476993599682af7489193d6b514056917775 /lib/net/hippie/client.rb
parentb8c1171c332a574c7c0a68538471daf82c386867 (diff)
feat: add connection pooling and DNS caching for performance
- Persistent HTTP sessions avoid Connection: close overhead - DNS pre-resolution with timeout prevents indefinite hangs - Thread-safe connection pool with LRU eviction - TLS certificates parsed once at init, not per-request - Extract TlsParser, DnsCache, ConnectionPool for SRP Bump to v1.5.0
Diffstat (limited to 'lib/net/hippie/client.rb')
-rw-r--r--lib/net/hippie/client.rb142
1 files changed, 75 insertions, 67 deletions
diff --git a/lib/net/hippie/client.rb b/lib/net/hippie/client.rb
index eebb8b7..d6e072e 100644
--- a/lib/net/hippie/client.rb
+++ b/lib/net/hippie/client.rb
@@ -1,15 +1,22 @@
# frozen_string_literal: true
+require 'net/hippie/dns_cache'
+require 'net/hippie/tls_parser'
+
module Net
module Hippie
- # A simple client for connecting with http resources.
+ # 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 #{Net::Hippie::VERSION}"
+ 'User-Agent' => "net/hippie #{VERSION}"
}.freeze
+ JITTER = Random.new.freeze
+
attr_reader :mapper, :logger, :follow_redirects
def initialize(options = {})
@@ -17,102 +24,103 @@ module Net
@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)
- @connections = Hash.new do |hash, key|
- scheme, host, port = key
- hash[key] = Connection.new(scheme, host, port, options)
- end
- end
-
- def execute(uri, request, limit: follow_redirects, &block)
- connection = connection_for(uri)
- return execute_with_block(connection, request, &block) if block_given?
-
- response = connection.run(request)
- follow_redirect?(response, limit) ? follow_redirect(connection, response, limit) : response
+ @default_headers = options.fetch(:headers, DEFAULT_HEADERS).freeze
+ configure_pool(options)
+ configure_tls(options)
end
- def get(uri, headers: {}, body: {}, &block)
- run(uri, Net::HTTP::Get, headers, body, &block)
+ %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
- def patch(uri, headers: {}, body: {}, &block)
- run(uri, Net::HTTP::Patch, headers, body, &block)
+ %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 post(uri, headers: {}, body: {}, &block)
- run(uri, Net::HTTP::Post, headers, body, &block)
- 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
- def put(uri, headers: {}, body: {}, &block)
- run(uri, Net::HTTP::Put, headers, body, &block)
- end
+ return execute_with_block(conn, request, &block) if block
- def delete(uri, headers: {}, body: {}, &block)
- run(uri, Net::HTTP::Delete, headers, body, &block)
+ response = conn.run(request)
+ limit.positive? && response.is_a?(Net::HTTPRedirection) ? follow_redirect(uri, response, limit) : response
end
- # attempt 1 -> delay 0.1 second
- # attempt 2 -> delay 0.2 second
- # attempt 3 -> delay 0.4 second
- # attempt 4 -> delay 0.8 second
- # attempt 5 -> delay 1.6 second
- # attempt 6 -> delay 3.2 second
- # attempt 7 -> delay 6.4 second
- # attempt 8 -> delay 12.8 second
def with_retry(retries: 3)
- retries = 0 if retries.nil? || retries.negative?
+ retries = [retries.to_i, 0].max
+ 0.upto(retries) do |attempt|
+ return yield self
+ rescue *CONNECTION_ERRORS => error
+ raise if attempt == retries
- 0.upto(retries) do |n|
- attempt(n, retries) do
- return yield self
- end
+ sleep_with_backoff(attempt, retries, error)
end
end
+ def close_all
+ @pool.close_all
+ @dns_cache.clear
+ end
+
private
- attr_reader :default_headers
+ 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 execute_with_block(connection, request, &block)
- block.arity == 2 ? yield(request, connection.run(request)) : connection.run(request, &block)
+ 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 follow_redirect?(response, limit)
- limit.positive? && response.is_a?(Net::HTTPRedirection)
+ 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 follow_redirect(connection, response, limit)
- url = connection.build_url_for(response['location'])
- request = request_for(Net::HTTP::Get, url)
- execute(url, request, limit: limit - 1)
+ 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 attempt(attempt, max)
- yield
- rescue *CONNECTION_ERRORS => error
- raise error if attempt == max
+ def execute_with_block(conn, request, &block)
+ block.arity == 2 ? yield(request, conn.run(request)) : conn.run(request, &block)
+ end
- delay = ((2**attempt) * 0.1) + Random.rand(0.05) # delay + jitter
- logger&.warn("`#{error.message}` #{attempt + 1}/#{max} Delay: #{delay}s")
- sleep delay
+ 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 request_for(type, uri, headers: {}, body: {})
- final_headers = default_headers.merge(headers)
- type.new(URI.parse(uri.to_s), final_headers).tap do |x|
- x.body = mapper.map_from(final_headers, body) unless body.empty?
- 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 run(uri, http_method, headers, body, &block)
- request = request_for(http_method, uri, headers: headers, body: body)
- execute(uri, request, &block)
+ def conn_opts
+ @options.merge(
+ tls_cert: @tls_cert, tls_key: @tls_key,
+ continue_timeout: @continue_timeout, ignore_eof: @ignore_eof
+ )
end
- def connection_for(uri)
- uri = URI.parse(uri.to_s)
- @connections[[uri.scheme, uri.host, uri.port]]
+ 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