diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ecf64..ba06627 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ connections for 55s (AWS ALB idle minus 5s) caused `SSL_read: unexpected eof while reading` (`GetStreamRuby::TransportError`) on the next request. Override with `idle_timeout:` or `STREAM_IDLE_TIMEOUT`. +- Recover when a GCP load balancer half-closes a pooled TLS connection without + `close_notify` (CHA-4943). OpenSSL 3 made Net::HTTP's dead-socket probe raise + `SSL_read: unexpected eof while reading` instead of reconnecting. The SDK now + sets `OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF` (process-global; restores the + OpenSSL 1.1.1 probe so GET and POST reconnect before the request is sent) and + restores `max_retries=1` (Faraday had forced 0) as a GET/HEAD backstop. + `TransportError#error_type` for that EOF is `connection_reset`, not + `tls_handshake_failed`. ## [10.0.0] - 2026-07-24 diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index d262c54..c47c745 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -21,6 +21,7 @@ require_relative 'error_mapping' require_relative 'log_redaction' require_relative 'request_logging' +require_relative 'tls' module GetStreamRuby @@ -292,6 +293,8 @@ def build_connection # Use it as-is; none of the 5 knobs apply. return @configuration.http_client if @configuration.http_client + Tls.tolerate_unclean_shutdown! + Faraday.new(url: @configuration.base_url) do |conn| conn.request :multipart @@ -330,6 +333,10 @@ def configure_adapter(connection) connection.adapter :net_http_persistent, pool_size: @configuration.max_conns_per_host do |http| http.idle_timeout = idle + # Faraday forces max_retries=0 on every request. Restore Net::HTTP's + # default of 1 so an idempotent retry can cover GET/HEAD if the + # OpenSSL EOF probe is unavailable (pre-OpenSSL 3). + http.max_retries = 1 if http.respond_to?(:max_retries=) end rescue Faraday::Error, ArgumentError => e diff --git a/lib/getstream_ruby/error_mapping.rb b/lib/getstream_ruby/error_mapping.rb index bf70f43..65b2a88 100644 --- a/lib/getstream_ruby/error_mapping.rb +++ b/lib/getstream_ruby/error_mapping.rb @@ -102,7 +102,7 @@ def classify_faraday_error(error) when Faraday::TimeoutError 'timeout' when Faraday::SSLError - 'tls_handshake_failed' + unexpected_eof?(error) ? 'connection_reset' : 'tls_handshake_failed' when Faraday::ConnectionFailed classify_connection_failure(error) else @@ -120,6 +120,20 @@ def classify_connection_failure(error) end end + def unexpected_eof?(error) + eof_message?(error.message) || eof_message?(wrapped_message(error)) + end + + def eof_message?(message) + message.to_s.include?('unexpected eof') + end + + def wrapped_message(error) + return nil unless error.respond_to?(:wrapped_exception) + + error.wrapped_exception&.message + end + def build_task_error(task_id, error_payload) hash = if error_payload.respond_to?(:to_h) error_payload.to_h diff --git a/lib/getstream_ruby/tls.rb b/lib/getstream_ruby/tls.rb new file mode 100644 index 0000000..d727a95 --- /dev/null +++ b/lib/getstream_ruby/tls.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'openssl' + +module GetStreamRuby + + # TLS knobs that Faraday / Net::HTTP do not expose per-connection. + module Tls + + module_function + + # OpenSSL 3 treats a TCP FIN without TLS close_notify as an error. GCP + # load balancers half-close idle keep-alive sockets that way, so + # Net::HTTP's pre-reuse `eof?` probe raises `SSL_read: unexpected eof + # while reading` instead of reporting a dead connection. + # + # `OP_IGNORE_UNEXPECTED_EOF` restores the OpenSSL 1.1.1 probe behavior. + # Net::HTTP has no per-connection hook for SSLContext options, so this + # is process-global. It only relaxes unclean-shutdown detection. + def tolerate_unclean_shutdown! + return unless defined?(OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF) + + OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] |= + OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF + end + + end + +end diff --git a/spec/connection_pooling_spec.rb b/spec/connection_pooling_spec.rb index e30da57..ef4064c 100644 --- a/spec/connection_pooling_spec.rb +++ b/spec/connection_pooling_spec.rb @@ -51,6 +51,28 @@ def capture_adapter_call end + it 'restores max_retries=1 on the persistent adapter' do + + handler = client.instance_variable_get(:@connection).builder.adapter + http = Net::HTTP::Persistent.new(name: 'spec-max-retries') + handler.instance_variable_get(:@block).call(http) + expect(http.max_retries).to eq(1) + + end + + it 'enables OpenSSL OP_IGNORE_UNEXPECTED_EOF when available' do + + skip 'OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF is not defined' unless + defined?(OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF) + + GetStreamRuby.manual(api_key: 'k', api_secret: 's') + options = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] + expect(options & OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF).to eq( + OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF, + ) + + end + end describe 'individual knob overrides' do diff --git a/spec/errors_spec.rb b/spec/errors_spec.rb index e8ccacb..bcf2d02 100644 --- a/spec/errors_spec.rb +++ b/spec/errors_spec.rb @@ -355,6 +355,18 @@ end + it 'classifies unexpected-eof SSLError as connection_reset' do + + stubs.post('/api/v2/x') { raise Faraday::SSLError, 'SSL_read: unexpected eof while reading' } + + expect { client.post('/api/v2/x') }.to raise_error(GetStreamRuby::TransportError) do |err| + + expect(err.error_type).to eq('connection_reset') + + end + + end + it 'classifies a ConnectionFailed wrapping SocketError as dns_failure' do # Pass the underlying exception as the first arg so Faraday::Error diff --git a/spec/stale_keepalive_spec.rb b/spec/stale_keepalive_spec.rb new file mode 100644 index 0000000..116ff83 --- /dev/null +++ b/spec/stale_keepalive_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'socket' +require 'openssl' +require 'faraday' +require 'faraday/net_http_persistent' + +# CHA-4943: GCP LBs half-close idle keep-alive TLS without close_notify. +# OpenSSL 3 turns that into SSL_read: unexpected eof while reading on the +# next request unless OP_IGNORE_UNEXPECTED_EOF is set. +RSpec.describe 'stale keep-alive TLS (CHA-4943)' do + + def self_signed_context + key = OpenSSL::PKey::RSA.new(2048) + cert = OpenSSL::X509::Certificate.new + cert.version = 2 + cert.serial = 1 + cert.subject = OpenSSL::X509::Name.parse('/CN=localhost') + cert.issuer = cert.subject + cert.public_key = key.public_key + cert.not_before = Time.now - 60 + cert.not_after = Time.now + 3600 + cert.sign(key, OpenSSL::Digest.new('SHA256')) + + ctx = OpenSSL::SSL::SSLContext.new + ctx.cert = cert + ctx.key = key + ctx + end + + def start_half_close_server + tcp = TCPServer.new('127.0.0.1', 0) + port = tcp.addr[1] + server = OpenSSL::SSL::SSLServer.new(tcp, self_signed_context) + server.start_immediately = false + thread = Thread.new do + + loop do + + raw = server.accept + Thread.new(raw) do |sock| + + sock.accept + loop { break if sock.gets.to_s.strip.empty? } + body = '{"ok":true}' + sock.write( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \ + "Content-Length: #{body.bytesize}\r\nConnection: keep-alive\r\n\r\n#{body}", + ) + sock.to_io.shutdown(Socket::SHUT_WR) + sleep 5 + sock.to_io.close + rescue StandardError + nil + + end + + end + + rescue StandardError + nil + + end + [tcp, thread, port] + end + + def faraday_conn(port, max_retries: 0) + Faraday.new(url: "https://127.0.0.1:#{port}", ssl: { verify: false }) do |conn| + + conn.adapter :net_http_persistent, pool_size: 5 do |http| + + http.idle_timeout = 25 + http.max_retries = max_retries if http.respond_to?(:max_retries=) + + end + + end + end + + def request(conn, method) + conn.send(method) do |req| + + req.url '/api/v2/x' + req.body = '{}' if method == :post + + end + end + + around do |example| + + skip 'OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF is not defined' unless + defined?(OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF) + + original = OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] + OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] = + original & ~OpenSSL::SSL::OP_IGNORE_UNEXPECTED_EOF + example.run + OpenSSL::SSL::SSLContext::DEFAULT_PARAMS[:options] = original + + end + + it 'reconnects GET and POST after a half-close when unclean shutdown is tolerated' do + + tcp, thread, port = start_half_close_server + GetStreamRuby::Tls.tolerate_unclean_shutdown! + + conn = faraday_conn(port) + expect(request(conn, :get).status).to eq(200) + expect(request(conn, :get).status).to eq(200) + + conn = faraday_conn(port) + expect(request(conn, :post).status).to eq(200) + expect(request(conn, :post).status).to eq(200) + ensure + tcp&.close + thread&.kill + + end + + it 'raises SSLError on a half-closed POST without OP_IGNORE_UNEXPECTED_EOF' do + + tcp, thread, port = start_half_close_server + conn = faraday_conn(port, max_retries: 0) + + expect(request(conn, :post).status).to eq(200) + expect { request(conn, :post) }.to raise_error(Faraday::SSLError, /unexpected eof/) + ensure + tcp&.close + thread&.kill + + end + +end