Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions lib/getstream_ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
require_relative 'error_mapping'
require_relative 'log_redaction'
require_relative 'request_logging'
require_relative 'tls'

module GetStreamRuby

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion lib/getstream_ruby/error_mapping.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
29 changes: 29 additions & 0 deletions lib/getstream_ruby/tls.rb
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions spec/connection_pooling_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions spec/errors_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
134 changes: 134 additions & 0 deletions spec/stale_keepalive_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading