diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f7d0ac..88d12da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,3 +113,27 @@ 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 + + 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 3c76dce..71ecf64 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 6803a91..0933015 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 0a0e847..d262c54 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 e9f89fc..c3e5857 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 9a6202f..e30da57 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 775ad73..0437ad7 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 0000000..876d610 --- /dev/null +++ b/spec/integration/gcp_lb_keepalive_spec.rb @@ -0,0 +1,48 @@ +# 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 +# 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 + + # 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? + 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 + + 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