From 531f4cc3ff162b194bd3f90ecb92040ba6ad7f18 Mon Sep 17 00:00:00 2001 From: Aditya Agarwal Date: Mon, 24 Aug 2026 18:35:02 +0200 Subject: [PATCH 1/3] fix: drop pooled TLS connections before GCP LB idle timeout GCP SSL-proxy load balancers close idle TLS around 30s while the SDK kept net_http_persistent connections for 55s, causing SSL_read EOF on the next request. Default idle_timeout is now 25s. Co-authored-by: Cursor --- .github/workflows/ci.yml | 25 ++++++++++++++ CHANGELOG.md | 8 +++++ Makefile | 5 ++- lib/getstream_ruby/client.rb | 1 + lib/getstream_ruby/configuration.rb | 6 ++-- spec/connection_pooling_spec.rb | 2 +- spec/getstream_ruby_spec.rb | 2 +- spec/integration/gcp_lb_keepalive_spec.rb | 42 +++++++++++++++++++++++ 8 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 spec/integration/gcp_lb_keepalive_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f7d0ac4..7c1d7da7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,3 +113,28 @@ jobs: STREAM_API_SECRET: ${{ secrets.STREAM_VIDEO_API_SECRET }} STREAM_BASE_URL: ${{ vars.STREAM_VIDEO_BASE_URL }} run: make test-integration-video + + integration-gcp-lb: + name: GCP load balancer keep-alive + runs-on: ubuntu-latest + environment: ci + if: vars.STREAM_GCP_BASE_URL != '' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1.0' + + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + + - name: Run GCP keep-alive integration test + env: + STREAM_API_KEY: ${{ vars.STREAM_GCP_API_KEY || vars.STREAM_API_KEY }} + STREAM_API_SECRET: ${{ secrets.STREAM_GCP_API_SECRET || secrets.STREAM_API_SECRET }} + STREAM_BASE_URL: ${{ vars.STREAM_GCP_BASE_URL }} + run: make test-integration-gcp-lb diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c76dce7..71ecf64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## [Unreleased] +### Fixed + +- Default `idle_timeout` for `net_http_persistent` is now `25` seconds (was `55`). + GCP SSL-proxy load balancers close idle TLS around ~30s; keeping pooled + 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`. + ## [10.0.0] - 2026-07-24 ### Added diff --git a/Makefile b/Makefile index 6803a914..09330152 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # GetStream Ruby SDK Makefile -.PHONY: help install test test-unit test-integration test-integration-chat test-integration-video lint format clean setup +.PHONY: help install test test-unit test-integration test-integration-chat test-integration-video test-integration-gcp-lb lint format clean setup # Default target help: ## Show this help message @@ -40,6 +40,9 @@ test-integration-feed: ## Run feed integration tests only test-integration-video: ## Run video integration tests only bundle exec rspec spec/integration/video_integration_spec.rb spec/integration/video_client_integration_spec.rb +test-integration-gcp-lb: ## Run GCP load-balancer keep-alive integration test + bundle exec rspec spec/integration/gcp_lb_keepalive_spec.rb + test-all: ## Run all tests (unit + integration) bundle exec rspec spec/ diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index 0a0e8478..d262c543 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -325,6 +325,7 @@ def configure_adapter(connection) # Default: net_http_persistent with the 5-knob config. # Never set Connection: close; net_http_persistent keeps connections alive natively. + # idle_timeout must be below the GCP SSL-proxy idle (~30s); AWS ALB is 60s. idle = @configuration.idle_timeout connection.adapter :net_http_persistent, pool_size: @configuration.max_conns_per_host do |http| diff --git a/lib/getstream_ruby/configuration.rb b/lib/getstream_ruby/configuration.rb index e9f89fc9..c3e5857e 100644 --- a/lib/getstream_ruby/configuration.rb +++ b/lib/getstream_ruby/configuration.rb @@ -137,12 +137,14 @@ def assign_timeouts_and_pool(options, use_env:) env_request_timeout = ENV.fetch('STREAM_REQUEST_TIMEOUT', nil) || ENV.fetch('STREAM_TIMEOUT', nil) @request_timeout = (request_timeout || timeout || env_request_timeout || 30).to_i @max_conns_per_host = (max_conns_per_host || ENV.fetch('STREAM_MAX_CONNS_PER_HOST', nil) || 5).to_i - @idle_timeout = (idle_timeout || ENV.fetch('STREAM_IDLE_TIMEOUT', nil) || 55).to_i + # 25s sits under GCP SSL-proxy idle (~30s) and AWS ALB idle (60s) so + # net_http_persistent drops the conn before the LB does (CHA-4943). + @idle_timeout = (idle_timeout || ENV.fetch('STREAM_IDLE_TIMEOUT', nil) || 25).to_i @connect_timeout = (connect_timeout || ENV.fetch('STREAM_CONNECT_TIMEOUT', nil) || 10).to_i else @request_timeout = (request_timeout || timeout || 30).to_i @max_conns_per_host = (max_conns_per_host || 5).to_i - @idle_timeout = (idle_timeout || 55).to_i + @idle_timeout = (idle_timeout || 25).to_i @connect_timeout = (connect_timeout || 10).to_i end end diff --git a/spec/connection_pooling_spec.rb b/spec/connection_pooling_spec.rb index 9a6202f7..e30da571 100644 --- a/spec/connection_pooling_spec.rb +++ b/spec/connection_pooling_spec.rb @@ -230,7 +230,7 @@ def capture_adapter_call line = info_lines.first expect(line).to include('client.initialized') expect(line).to include('stream.client.max_conns_per_host=5') - expect(line).to include('stream.client.idle_timeout_seconds=55') + expect(line).to include('stream.client.idle_timeout_seconds=25') expect(line).to include('stream.client.connect_timeout_seconds=10') expect(line).to include('stream.client.request_timeout_seconds=30') expect(line).to include('stream.client.user_http_client=false') diff --git a/spec/getstream_ruby_spec.rb b/spec/getstream_ruby_spec.rb index 775ad731..0437ad79 100644 --- a/spec/getstream_ruby_spec.rb +++ b/spec/getstream_ruby_spec.rb @@ -27,7 +27,7 @@ expect(client.configuration.faraday_adapter_options).to eq({}) expect(client.configuration.connection_keep_alive).to eq(true) expect(client.configuration.max_conns_per_host).to eq(5) - expect(client.configuration.idle_timeout).to eq(55) + expect(client.configuration.idle_timeout).to eq(25) expect(client.configuration.connect_timeout).to eq(10) expect(client.configuration.request_timeout).to eq(30) expect(client.configuration.http_client).to be_nil diff --git a/spec/integration/gcp_lb_keepalive_spec.rb b/spec/integration/gcp_lb_keepalive_spec.rb new file mode 100644 index 00000000..86d75405 --- /dev/null +++ b/spec/integration/gcp_lb_keepalive_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'rspec' +require_relative 'chat_test_helpers' + +# CHA-4943: GCP SSL-proxy LBs close idle TLS around ~30s. The SDK pool must +# drop connections before that, otherwise the next request raises +# GetStreamRuby::TransportError (SSL_read: unexpected eof while reading). +# +# Skipped unless STREAM_BASE_URL is a GCP edge hostname so default CI (AWS / +# geo-routed chat.stream-io-api.com) does not wait 35s for a no-op. +RSpec.describe 'GCP load balancer keep-alive', type: :integration do + + include ChatTestHelpers + + def gcp_edge_base_url? + ENV.fetch('STREAM_BASE_URL', '').include?('.gcp.stream-io-api.com') + end + + before(:all) do + + skip 'STREAM_BASE_URL must be a *.gcp.stream-io-api.com edge' unless gcp_edge_base_url? + init_chat_client + + end + + after(:all) do + + cleanup_chat_resources if gcp_edge_base_url? + + end + + it 'reuses the pooled client after 35s idle without TLS EOF' do + + ids, = create_test_users(1) + sleep ENV.fetch('STREAM_GCP_LB_IDLE_SLEEP', '35').to_i + expect { create_test_users(1) }.not_to raise_error + expect(ids).not_to be_empty + + end + +end From 1114a484eafd0b1855fba5a14f193ae6ab874a55 Mon Sep 17 00:00:00 2001 From: Aditya Agarwal Date: Mon, 24 Aug 2026 18:39:48 +0200 Subject: [PATCH 2/3] fix: always run GCP keep-alive job so env vars are visible Job-level if: vars.STREAM_GCP_BASE_URL is evaluated before the ci environment is applied, so environment variables never un-skip the job. The spec still skips unless STREAM_BASE_URL is a GCP edge hostname. Co-authored-by: Cursor --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c1d7da7..88d12da0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,7 +118,6 @@ jobs: name: GCP load balancer keep-alive runs-on: ubuntu-latest environment: ci - if: vars.STREAM_GCP_BASE_URL != '' steps: - name: Checkout code From a3026814073d6dd7d92a4e8ba5d8545e301ebb2e Mon Sep 17 00:00:00 2001 From: itsmeadi Date: Mon, 24 Aug 2026 18:50:57 +0200 Subject: [PATCH 3/3] fix(spec): match GCP edge host by suffix to satisfy CodeQL (CHA-4943) --- spec/integration/gcp_lb_keepalive_spec.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/spec/integration/gcp_lb_keepalive_spec.rb b/spec/integration/gcp_lb_keepalive_spec.rb index 86d75405..876d6101 100644 --- a/spec/integration/gcp_lb_keepalive_spec.rb +++ b/spec/integration/gcp_lb_keepalive_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'rspec' +require 'uri' require_relative 'chat_test_helpers' # CHA-4943: GCP SSL-proxy LBs close idle TLS around ~30s. The SDK pool must @@ -13,8 +14,13 @@ include ChatTestHelpers + # Match on the parsed host suffix rather than a substring so a URL like + # https://gcp.stream-io-api.com.example.com does not slip through. def gcp_edge_base_url? - ENV.fetch('STREAM_BASE_URL', '').include?('.gcp.stream-io-api.com') + host = URI(ENV.fetch('STREAM_BASE_URL', '')).host.to_s + host.end_with?('.gcp.stream-io-api.com') + rescue URI::InvalidURIError + false end before(:all) do