diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e9e661..b13164c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ["3.0", "3.1", "3.2", "3.3"] + ruby: ["3.1", "3.1", "3.2", "3.4"] name: ${{ matrix.ruby }} diff --git a/.gitignore b/.gitignore index f469582..a5436aa 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /spec/reports/ /tmp/ .rspec_status +/benchmark_results/ diff --git a/.rubocop.yml b/.rubocop.yml index 827d668..3777a0c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -5,7 +5,7 @@ inherit_gem: AllCops: DisplayCopNames: true - TargetRubyVersion: 3.0 + TargetRubyVersion: 3.1 Include: - bin/console - Gemfile diff --git a/Gemfile b/Gemfile index a188b05..da3bb16 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,10 @@ source "https://rubygems.org" gemspec +gem "benchmark-ips" gem "bundler" gem "coveralls" +gem "memory_profiler" gem "pry" gem "rake" gem "rspec" diff --git a/Gemfile.lock b/Gemfile.lock index d2615cf..27aa1c4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - ezclient (1.7.2) + ezclient (1.7.3) http (>= 4) GEM @@ -21,6 +21,7 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.2) base64 (0.2.0) + benchmark-ips (2.14.0) bigdecimal (3.1.8) coderay (1.1.3) concurrent-ruby (1.3.4) @@ -59,6 +60,7 @@ GEM llhttp-ffi (0.5.0) ffi-compiler (~> 1.0) rake (~> 13.0) + memory_profiler (1.1.0) method_source (1.1.0) mime-types (3.5.2) mime-types-data (~> 3.2015) @@ -155,9 +157,11 @@ PLATFORMS ruby DEPENDENCIES + benchmark-ips bundler coveralls ezclient! + memory_profiler pry rake rspec diff --git a/README.md b/README.md index ab02d5f..7cb60c7 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Valid client options are: - `api_auth` – arguments for `ApiAuth.sign!` (see https://github.com/mgomes/api_auth) - `basic_auth` – arguments for basic authentication (either a hash with `:user` and `:pass` keys or a two-element array) +- `cleanup_interval` – interval for cleaning up timed out connections in seconds (default: 60) - `cookies` – a hash of cookies (or `HTTP::CookieJar` object) for requests - `headers` – a hash of headers for requests - `keep_alive` – timeout for persistent connection in seconds @@ -80,10 +81,25 @@ module MyApp end ``` -Alose note that, as of now, EzClient will +Also note that, as of now, EzClient will automatically retry the request on any `HTTP::ConnectionError` exception in this case which may possibly result in two requests received by a server (see https://github.com/httprb/http/issues/459). +### Closing persistent connections + +If you need to close all persistent connections (e.g., when forking), you can use the `truncate!` method: + +```ruby +client = EzClient.new(keep_alive: 100) + +# ... make some requests ... + +# Close all persistent connections and clear caches +client.truncate! +``` + +This is particularly useful in forking scenarios (e.g., when using Pitchfork or Puma in fork mode). + ## Callbacks and retrying You can provide `on_complete`, `on_error` and `on_retry` callbacks like this: diff --git a/bin/benchmark b/bin/benchmark new file mode 100755 index 0000000..7c597c4 --- /dev/null +++ b/bin/benchmark @@ -0,0 +1,317 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "bundler/setup" +require "benchmark/ips" +require "memory_profiler" +require "webmock" +require "json" +require "fileutils" +require "webmock/rspec" + +require_relative "../lib/ezclient" + +def make_ssl_context(cert, key) + OpenSSL::SSL::SSLContext.new.tap do |ctx| + ctx.cert = OpenSSL::X509::Certificate.new(cert) + ctx.key = OpenSSL::PKey.read(key) + end +end + +def read_cert_file(path) + File.read(File.join(__dir__, "../spec/files/#{path}")) +end + +WebMock.enable! +WebMock.disable_net_connect! +RESULTS_DIR = Pathname.getwd.join("benchmark_results").freeze +RESULTS_DIR.mkpath +FileUtils.mkdir_p(RESULTS_DIR) + +CURRENT_BRANCH = `git branch --show-current`.strip.freeze +RESULTS_FILE = RESULTS_DIR.join("#{CURRENT_BRANCH.tr('/', '_')}.json") + +CERT1 = read_cert_file("cert1/cert.pem") +KEY1 = read_cert_file("cert1/key.pem") +CERT2 = read_cert_file("cert2/cert.pem") +KEY2 = read_cert_file("cert2/key.pem") + +TEST_URLS = [ + "https://api.example.com/users/1", + "https://api.example.com/users/2", + "https://api.example.com/posts/1", + "https://different.com/data", + "https://another.org/info", +].freeze + +WebMock.stub_request(:any, /.*/).to_return( + status: 200, + body: "OK", + headers: { "Content-Type" => "text/plain" }, +) + +module Benchmarks + extend self + + def run_all + Printer.run_header(CURRENT_BRANCH) + + { + ssl_certificate_requests: SSLBenchmark.run, + multiple_url_requests: URLBenchmark.run, + cleanup_performance: CleanupBenchmark.run, + memory_usage: MemoryBenchmark.run, + } + end +end + +module BenchmarkRunner + private + + def run_benchmark + result = Benchmark.ips do |x| + x.config(time: 2, warmup: 1) + self.benchmark_runner = x + yield + end + result.entries.to_h { |entry| [entry.label, entry.ips] } + end + + attr_accessor :benchmark_runner +end + +module SSLBenchmark + extend self + extend BenchmarkRunner + + def run + Printer.benchmark_title(1, "SSL Certificate Requests") + + ssl_context1 = make_ssl_context(CERT1, KEY1) + ssl_context2 = make_ssl_context(CERT2, KEY2) + + run_benchmark do + benchmark_runner.report("requests with same SSL cert") do + client = EzClient.new(keep_alive: 30) + 30.times do + client.perform!(:get, "https://example.com", ssl_context: ssl_context1) + end + end + + benchmark_runner.report("requests with different SSL certs") do + client = EzClient.new(keep_alive: 30) + 30.times do + client.perform!(:get, "https://example.com", ssl_context: ssl_context1) + client.perform!(:get, "https://example.com", ssl_context: ssl_context2) + end + end + end + end +end + +module URLBenchmark + extend self + extend BenchmarkRunner + + def run + Printer.benchmark_title(2, "Multiple URL Requests") + + run_benchmark do + benchmark_runner.report("requests to same origin") do + client = EzClient.new(keep_alive: 30) + 30.times do |i| + client.perform!(:get, "https://api.example.com/endpoint/#{i}") + end + end + + benchmark_runner.report("requests to different origins") do + client = EzClient.new(keep_alive: 30) + 10.times do + TEST_URLS.each do |url| + client.perform!(:get, url) + end + end + end + end + end +end + +module CleanupBenchmark + extend self + extend BenchmarkRunner + + def run + Printer.benchmark_title(3, "Cleanup Performance") + + run_benchmark do + benchmark_runner.report("frequent requests") do + client = EzClient.new(keep_alive: 0.5) + 20.times do + client.perform!(:get, "https://example.com") + end + end + end + end +end + +module MemoryBenchmark + extend self + + def run + Printer.benchmark_title(4, "Memory Usage Analysis") + + ssl_context = make_ssl_context(CERT1, KEY1) + + report = MemoryProfiler.report do + client = EzClient.new(keep_alive: 30) + + 50.times do |i| + url = "https://example#{i % 5}.com/path" + client.perform!(:get, url, ssl_context: i.even? ? ssl_context : nil) + end + end + + Printer.memory_report(report) + + { + total_allocated_bytes: report.total_allocated_memsize, + total_retained_bytes: report.total_retained_memsize, + total_allocated_objects: report.total_allocated, + total_retained_objects: report.total_retained, + } + end +end + +module Printer + extend self + + def benchmark_title(test_number, title) + puts "\n#{test_number}. #{title}" + puts "-" * 60 + end + + def run_header(branch) + puts "Running benchmarks on branch: #{branch}" + puts "=" * 60 + end + + def memory_report(report) + puts "Total allocated: #{report.total_allocated_memsize} bytes" + puts "Total retained: #{report.total_retained_memsize} bytes" + puts "Allocated objects: #{report.total_allocated}" + puts "Retained objects: #{report.total_retained}" + end + + def results_saved(file) + puts "\nResults saved to: #{file}" + end + + def comparison_header + puts "\n#{"=" * 60}" + puts "COMPARISON WITH MASTER" + puts "=" * 60 + end + + def benchmark_section(bench_name) + puts "\n#{bench_name.tr('_', ' ').capitalize}:" + end + + def ips_comparison(test, current_ips, master_ips) + diff_percent = ((current_ips - master_ips) / master_ips * 100).round(2) + status = diff_percent.positive? ? "🚀 FASTER" : "🐌 SLOWER" + + puts " #{test}:" + puts " Master: #{master_ips.round(2)} i/s" + puts " Current: #{current_ips.round(2)} i/s" + puts " Change: #{diff_percent}% #{status}" + end + + def memory_section + puts "\nMemory Usage:" + end + + def memory_comparison(metric, current_val, master_val) + diff = current_val - master_val + diff_percent = (diff.to_f / master_val * 100).round(2) + status = diff.negative? ? "✅ BETTER" : "❌ WORSE" + + puts " #{metric.tr('_', ' ').capitalize}:" + puts " Master: #{master_val}" + puts " Current: #{current_val}" + puts " Change: #{diff} bytes (#{diff_percent}%) #{status}" + end + + def error(error) + puts "Error running benchmarks: #{error.message}" + puts error.backtrace + end +end + +module ComparerWithMaster + extend self + + def process(current_results) + master_file = RESULTS_DIR.join("master.json") + + return unless File.exist?(master_file) && CURRENT_BRANCH != "master" + + Printer.comparison_header + master_results = JSON.parse(File.read(master_file)) + + # Convert current results to string keys for consistency with JSON + current_results_str = current_results.transform_keys(&:to_s).transform_values do |value| + value.is_a?(Hash) ? value.transform_keys(&:to_s) : value + end + + compare_ips_benchmarks(current_results_str, master_results) + compare_memory_usage(current_results_str, master_results) + end + + private + + def compare_ips_benchmarks(current_results, master_results) + %w[ssl_certificate_requests multiple_url_requests cleanup_performance].each do |bench| + Printer.benchmark_section(bench) + + bench_results = current_results[bench] + master_bench = master_results[bench] + + next unless bench_results && master_bench + + bench_results.each do |test, current_ips| + master_ips = master_bench[test] + next unless master_ips + + Printer.ips_comparison(test, current_ips, master_ips) + end + end + end + + def compare_memory_usage(current_results, master_results) + Printer.memory_section + current_mem = current_results["memory_usage"] + master_mem = master_results["memory_usage"] + + return unless current_mem && master_mem + + %w[total_allocated_bytes total_retained_bytes].each do |metric| + current_val = current_mem[metric] + master_val = master_mem[metric] + next unless current_val && master_val + + Printer.memory_comparison(metric, current_val, master_val) + end + end +end + +begin + results = Benchmarks.run_all + + File.write(RESULTS_FILE, JSON.pretty_generate(results)) + Printer.results_saved(RESULTS_FILE) + + ComparerWithMaster.process(results) +rescue => error + Printer.error(error) + exit 1 +end diff --git a/ezclient.gemspec b/ezclient.gemspec index f767b0f..f2e02a5 100644 --- a/ezclient.gemspec +++ b/ezclient.gemspec @@ -5,7 +5,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "ezclient/version" Gem::Specification.new do |spec| - spec.required_ruby_version = ">= 3.0" + spec.required_ruby_version = ">= 3.1" spec.name = "ezclient" spec.version = EzClient::VERSION diff --git a/lib/ezclient/client.rb b/lib/ezclient/client.rb index 663ee61..fc98696 100644 --- a/lib/ezclient/client.rb +++ b/lib/ezclient/client.rb @@ -4,6 +4,7 @@ class EzClient::Client REQUEST_OPTION_KEYS = %i[ api_auth basic_auth + cleanup_interval cookies headers keep_alive @@ -21,6 +22,9 @@ class EzClient::Client def initialize(options = {}) self.request_options = options EzClient::CheckOptions.call(options, REQUEST_OPTION_KEYS) + self.persistent_client_registry = EzClient::PersistentClientRegistry.build_for_client( + cleanup_interval: options[:cleanup_interval], + ) end def request(verb, url, **options) @@ -32,13 +36,13 @@ def request(verb, url, **options) if keep_alive_timeout client = persistent_client_registry.for( - url, ssl_context: ssl_context, timeout: keep_alive_timeout + url, ssl_context:, timeout: keep_alive_timeout ) else client = HTTP::Client.new end - EzClient::Request.new(verb, url, client: client, **options).tap do |request| + EzClient::Request.new(verb, url, client:, **options).tap do |request| request.api_auth!(*api_auth) if api_auth end end @@ -51,11 +55,11 @@ def perform!(*args, **kwargs) request(*args, **kwargs).perform! end - private + def truncate! + persistent_client_registry.truncate! + end - attr_accessor :request_options + private - def persistent_client_registry - @persistent_client_registry ||= EzClient::PersistentClientRegistry.new - end + attr_accessor :request_options, :persistent_client_registry end diff --git a/lib/ezclient/persistent_client_registry.rb b/lib/ezclient/persistent_client_registry.rb index bc83a13..39fbbc2 100644 --- a/lib/ezclient/persistent_client_registry.rb +++ b/lib/ezclient/persistent_client_registry.rb @@ -1,35 +1,67 @@ # frozen_string_literal: true class EzClient::PersistentClientRegistry - def initialize + DEFAULT_CLEANUP_INTERVAL = 60 + + def self.build_for_client(cleanup_interval: nil) + new(cleanup_interval: cleanup_interval || DEFAULT_CLEANUP_INTERVAL) + end + + def initialize(cleanup_interval: DEFAULT_CLEANUP_INTERVAL) self.registry = {} + self.cert_hash_cache = {}.compare_by_identity + self.origin_cache = {} + self.last_cleanup_at = nil + self.cleanup_interval = cleanup_interval end def for(url, ssl_context:, timeout:) cleanup_registry! origin = get_origin(url) - registry[origin] ||= {} + ssl_bucket = ssl_context&.cert ? get_cached_cert_hash(ssl_context.cert) : nil + + registry_key = build_registry_key(origin, ssl_bucket) + + client = registry[registry_key] + + # If client exists but timed out, remove it and create a new one + if client&.timed_out? + registry.delete(registry_key) + nil + end - ssl_bucket = ssl_context&.cert ? get_cert_sha256(ssl_context.cert) : nil - registry[origin][ssl_bucket] ||= EzClient::PersistentClient.new(origin, timeout) + registry[registry_key] ||= EzClient::PersistentClient.new(origin, timeout) + end + + def truncate! + registry.clear + cert_hash_cache.clear + origin_cache.clear + self.last_cleanup_at = nil end private - attr_accessor :registry + attr_accessor :registry, :cert_hash_cache, :origin_cache, :last_cleanup_at, :cleanup_interval + + def get_cached_cert_hash(cert) + cert_hash_cache[cert] ||= Digest::SHA256.hexdigest(cert.to_der).freeze + end - def get_cert_sha256(cert) - Digest::SHA256.hexdigest(cert.to_der) + def build_registry_key(origin, ssl_bucket) + ssl_bucket ? "#{origin}|#{ssl_bucket}".freeze : origin end def get_origin(url) - HTTP::URI.parse(url).origin + origin_cache[url] ||= HTTP::URI.parse(url).origin end def cleanup_registry! - registry.each_value do |ssl_buckets| - ssl_buckets.delete_if { |_ssl_bucket, client| client.timed_out? } - end + current_time = EzClient.get_time + return if last_cleanup_at && (current_time - last_cleanup_at) < cleanup_interval + + self.last_cleanup_at = current_time + registry.delete_if { |_key, client| client.timed_out? } end end diff --git a/lib/ezclient/request.rb b/lib/ezclient/request.rb index a298d91..e0990e9 100644 --- a/lib/ezclient/request.rb +++ b/lib/ezclient/request.rb @@ -115,11 +115,11 @@ def perform_request self.elapsed_seconds = EzClient.get_time - perform_started_at end - def with_retry(&block) + def with_retry(&) retries = 0 begin - retry_on_connection_error(&block) + retry_on_connection_error(&) rescue *retried_exceptions => error if retries < max_retries.to_i retries += 1 @@ -209,7 +209,7 @@ def basic_auth case options[:basic_auth] when Array user, password = options[:basic_auth] - { user: user, pass: password } + { user:, pass: password } when Hash options[:basic_auth] end diff --git a/lib/ezclient/version.rb b/lib/ezclient/version.rb index d5465c7..4e6d5e5 100644 --- a/lib/ezclient/version.rb +++ b/lib/ezclient/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module EzClient - VERSION = "1.7.2" + VERSION = "1.7.3" end diff --git a/spec/ezclient_spec.rb b/spec/ezclient_spec.rb index 3fc9ee9..04d3fcd 100644 --- a/spec/ezclient_spec.rb +++ b/spec/ezclient_spec.rb @@ -52,7 +52,7 @@ def self.sign!(*); end end context "when headers request option is provided" do - let(:request_options) { { headers: headers } } + let(:request_options) { { headers: } } let(:headers) { { some_header: 1 } } it "makes request with proper headers" do @@ -78,7 +78,7 @@ def self.sign!(*); end end context "when cookies request option is provided" do - let(:request_options) { { headers: headers, cookies: cookies } } + let(:request_options) { { headers:, cookies: } } let(:cookies) { { a: 1 } } it "makes request with proper headers" do @@ -138,7 +138,7 @@ def self.sign!(*); end end context "when params request option is provided" do - let(:request_options) { { params: params } } + let(:request_options) { { params: } } let(:params) { { a: 1 } } it "makes proper request" do @@ -185,7 +185,7 @@ def self.sign!(*); end end context "when on_complete callback is provided" do - let(:client_options) { { on_complete: on_complete } } + let(:client_options) { { on_complete: } } let(:calls) { [] } let(:on_complete) do @@ -241,6 +241,15 @@ def self.sign!(*); end expect(response.body).to eq("some body") end end + + context "when cleanup_interval client option is provided" do + let(:client_options) { { keep_alive: 10, cleanup_interval: 30 } } + + it "uses custom cleanup interval" do + registry = client.send(:persistent_client_registry) + expect(registry.send(:cleanup_interval)).to eq(30) + end + end end context "when exception during request occurs" do @@ -253,7 +262,7 @@ def self.sign!(*); end end context "when on_error callback is provided" do - let(:client_options) { { on_error: on_error } } + let(:client_options) { { on_error: } } let(:calls) { [] } let(:on_error) do @@ -273,7 +282,7 @@ def self.sign!(*); end end context "when error_wrapper callback is provided" do - let(:client_options) { { error_wrapper: error_wrapper } } + let(:client_options) { { error_wrapper: } } let(:calls) { [] } let(:error_wrapper) do @@ -302,7 +311,7 @@ def self.sign!(*); end end context "when on_retry callback is provided" do - let(:client_options) { { on_retry: on_retry } } + let(:client_options) { { on_retry: } } let(:request_options) { { metadata: :smth } } let(:calls) { [] } @@ -511,7 +520,7 @@ def self.sign!(*); end end context "when on_retry callback is provided" do - let(:client_options) { { on_retry: on_retry } } + let(:client_options) { { on_retry: } } let(:request_options) { { retry_exceptions: SomeError, metadata: :smth } } let(:calls) { [] } @@ -548,4 +557,47 @@ def self.sign!(*); end expect(request.headers).to include("Authorization" => "Basic dXNlcjpwYXNzd29yZA==") end end + + context "with truncate!" do + before do + allow(EzClient::PersistentClientRegistry).to receive(:build_for_client).and_return(registry) + end + + before do + stub_request(:get, "https://example1.com").to_return(status: 200, body: "OK") + stub_request(:get, "https://example2.com").to_return(status: 200, body: "OK") + end + + let(:registry) { EzClient::PersistentClientRegistry.new } + + it "clears all internal state" do + client = EzClient.new(keep_alive: 10) + + cert_data = File.read("#{__dir__}/files/cert1/cert.pem") + cert = OpenSSL::X509::Certificate.new(cert_data) + ssl_context = OpenSSL::SSL::SSLContext.new + ssl_context.cert = cert + + client.perform!(:get, "https://example1.com") + client.perform!(:get, "https://example2.com", ssl_context:) + + registry.send(:get_origin, "https://example3.com/path") + + registry.send(:get_cached_cert_hash, cert) + + expect(registry.send(:registry).size).to eq(2) + expect(registry.send(:origin_cache).size).not_to eq(0) + expect(registry.send(:cert_hash_cache).size).not_to eq(0) + + registry.send(:cleanup_registry!) + expect(registry.send(:last_cleanup_at)).not_to be_nil + + client.truncate! + + expect(registry.send(:registry)).to be_empty + expect(registry.send(:origin_cache)).to be_empty + expect(registry.send(:cert_hash_cache)).to be_empty + expect(registry.send(:last_cleanup_at)).to be_nil + end + end end diff --git a/spec/persistent_connections_spec.rb b/spec/persistent_connections_spec.rb index 1f5fbba..c6218bb 100644 --- a/spec/persistent_connections_spec.rb +++ b/spec/persistent_connections_spec.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true RSpec.describe "Persistent Connections" do + let(:current_time_shift) { 0.5 } + let(:current_time) { 0 } + around do |example| WebMock.disable! example.run @@ -8,7 +11,14 @@ WebMock.enable! end - before { GC.start } + before do + time = current_time + allow(EzClient).to receive(:get_time) do + result = time + time += current_time_shift + result + end + end def read_file(path) File.new("#{__dir__}/files/#{path}").read @@ -21,46 +31,174 @@ def make_ssl_context(cert, key) end end - it "removes connections that are timed out on each request" do - client = EzClient.new(keep_alive: 1, timeout: 15) + context "when connections timeout" do + let(:current_time_shift) { 2 } + + it "removes connections that are timed out on each request" do + stub_const("EzClient::PersistentClientRegistry::DEFAULT_CLEANUP_INTERVAL", 5) + + client = EzClient.new(keep_alive: 1, timeout: 15) - 2.times do client.perform!(:get, "https://ya.ru") client.perform!(:get, "https://google.com") client.perform!(:get, "https://example.com") - sleep 0.6 + + client.perform!(:get, "https://example.com") + + GC.start + + connection_count = ObjectSpace.each_object(HTTP::Connection).count + expect(connection_count).to eq(1) end + end + + context "with SSL contexts" do + it "boots up separate http connections for different ssl contexts" do + client = EzClient.new(keep_alive: 15) + + cert1 = read_file("cert1/cert.pem") + key1 = read_file("cert1/key.pem") + cert2 = read_file("cert2/cert.pem") + key2 = read_file("cert2/key.pem") - sleep 0.6 + ssl_context1 = make_ssl_context(cert1, key1) + ssl_context2 = make_ssl_context(cert2, key2) - client.perform!(:get, "https://example.com") + 2.times do + client.perform!(:get, "https://ya.ru", ssl_context: ssl_context1) + end - GC.start + 2.times do + client.perform!(:get, "https://ya.ru", ssl_context: ssl_context2) + end - connection_count = ObjectSpace.each_object(HTTP::Connection).count - expect(connection_count).to eq(1) + GC.start + + connection_count = ObjectSpace.each_object(HTTP::Connection).count + expect(connection_count).to eq(2) + end end - it "boots up separate http connections for different ssl contexts" do - client = EzClient.new(keep_alive: 15) + context "with caching optimizations" do + before do + allow(EzClient::PersistentClientRegistry).to receive(:build_for_client).and_return(registry) + end + + let(:current_time_shift) { 0 } + let(:client) { EzClient.new(keep_alive: 10) } + let(:registry) { EzClient::PersistentClientRegistry.new } + + context "with SHA256 certificate hash caching" do + it "caches certificate hashes to avoid recomputation" do + cert = OpenSSL::X509::Certificate.new(read_file("cert1/cert.pem")) - cert1 = read_file("cert1/cert.pem") - key1 = read_file("cert1/key.pem") - cert2 = read_file("cert2/cert.pem") - key2 = read_file("cert2/key.pem") + expect(Digest::SHA256).to receive(:hexdigest).once.and_call_original - ssl_context1 = make_ssl_context(cert1, key1) - ssl_context2 = make_ssl_context(cert2, key2) + hash1 = registry.send(:get_cached_cert_hash, cert) + hash2 = registry.send(:get_cached_cert_hash, cert) - 2.times do - client.perform!(:get, "https://ya.ru", ssl_context: ssl_context1) + expect(hash1).to eq(hash2) + end end - 2.times do - client.perform!(:get, "https://ya.ru", ssl_context: ssl_context2) + context "with URL origin caching" do + it "caches parsed URL origins" do + url = "https://example.com/path?query=1" + + expect(HTTP::URI).to receive(:parse).once.and_call_original + + origin1 = registry.send(:get_origin, url) + origin2 = registry.send(:get_origin, url) + + expect(origin1).to eq(origin2) + expect(origin1).to eq("https://example.com") + end + end + + context "with cleanup interval optimization" do + it "skips cleanup if called within DEFAULT_CLEANUP_INTERVAL" do + stub_const("EzClient::PersistentClientRegistry::DEFAULT_CLEANUP_INTERVAL", 10) + + registry.send(:cleanup_registry!) + + expect(registry.send(:registry)).not_to receive(:delete_if) + registry.send(:cleanup_registry!) + end + + it "performs cleanup after DEFAULT_CLEANUP_INTERVAL has passed" do + current_time = 0 + allow(EzClient).to receive(:get_time) { current_time } + + # Create registry with custom cleanup_interval + test_registry = EzClient::PersistentClientRegistry.new(cleanup_interval: 10) + + test_registry.send(:cleanup_registry!) + + current_time = 15 + + expect(test_registry.send(:registry)).to receive(:delete_if).and_call_original + test_registry.send(:cleanup_registry!) + end end - connection_count = ObjectSpace.each_object(HTTP::Connection).count - expect(connection_count).to eq(2) + context "when client times out before cleanup" do + around do |example| + WebMock.enable! + example.run + ensure + WebMock.disable! + end + + before { stub_request(:get, "https://example.com").to_return(status: 200, body: "OK") } + + let(:registry) { EzClient::PersistentClientRegistry.new } + + it "removes timed out client on next access" do + client1 = registry.for("https://example.com", ssl_context: nil, timeout: 5) + request = HTTP::Request.new(verb: :get, uri: "https://example.com") + client1.perform(request, HTTP::Options.new) + expect(registry.send(:registry).size).to eq(1) + + allow(EzClient).to receive(:get_time).and_return(10) + + client2 = registry.for("https://example.com", ssl_context: nil, timeout: 5) + + expect(client2).not_to eq(client1) + expect(client1.timed_out?).to be true + expect(registry.send(:registry).size).to eq(1) + end + + context "with multiple timed out clients" do + before do + stub_request(:get, "https://example1.com").to_return(status: 200, body: "OK") + stub_request(:get, "https://example2.com").to_return(status: 200, body: "OK") + end + + it "handles correctly" do + client1 = registry.for("https://example1.com", ssl_context: nil, timeout: 5) + client2 = registry.for("https://example2.com", ssl_context: nil, timeout: 5) + + request1 = HTTP::Request.new(verb: :get, uri: "https://example1.com") + request2 = HTTP::Request.new(verb: :get, uri: "https://example2.com") + + client1.perform(request1, HTTP::Options.new) + client2.perform(request2, HTTP::Options.new) + + expect(registry.send(:registry).size).to eq(2) + + allow(EzClient).to receive(:get_time).and_return(10) + + new_client1 = registry.for("https://example1.com", ssl_context: nil, timeout: 5) + + expect(new_client1).not_to eq(client1) + expect(registry.send(:registry).size).to eq(2) + + new_client2 = registry.for("https://example2.com", ssl_context: nil, timeout: 5) + + expect(new_client2).not_to eq(client2) + expect(registry.send(:registry).size).to eq(2) + end + end + end end end