Skip to content

feat: opt-in HTTP keep-alive via keep_alive_connections#145

Open
erimicel wants to merge 3 commits into
decision-labs:masterfrom
OLIOEX:keepalive-connections
Open

feat: opt-in HTTP keep-alive via keep_alive_connections#145
erimicel wants to merge 3 commits into
decision-labs:masterfrom
OLIOEX:keepalive-connections

Conversation

@erimicel
Copy link
Copy Markdown
Contributor

@erimicel erimicel commented May 1, 2026

Summary

Each FCM call currently opens a fresh TCP/TLS connection via Faraday.default_adapter. For high-volume senders this is the dominant per-request cost. This PR adds an opt-in keep_alive_connections http_option that reuses a thread-local Faraday connection (per uri) backed by net-http-persistent, so the handshake and HTTP/2 stream are amortised across requests.

fcm = FCM.new(
  GOOGLE_APPLICATION_CREDENTIALS_PATH,
  FIREBASE_PROJECT_ID,
  keep_alive_connections: true,
  keep_alive_idle_timeout_seconds: 30, # optional, default 30
  keep_alive_pool_size: 1              # optional, default 1
)

Design

  • Default is false — existing callers see no behaviour change. The original Faraday.default_adapter one-shot path is preserved.
  • Per-(thread, uri) cache keyed by object_id, so multiple FCM clients in the same process do not share sockets and Net::HTTP's non-thread-safety is respected.
  • Bearer token + per-call extra_headers are re-applied each yield because JWTs expire (~1h) and project_id headers vary across calls.
  • On any error the cached connection is dropped and the error re-raised — a half-closed socket would just fail again on retry.
  • require "faraday/net_http_persistent" is only loaded when keep-alive is enabled, so existing users do not pull in the extra dependency at runtime.

Test plan

  • Existing 29 specs pass unchanged (default path).
  • New specs cover: connection is cached and reused, discard-on-error, no cross-instance sharing, default path still bypasses the cache.

Each FCM call currently opens a fresh TCP/TLS connection via
Faraday.default_adapter. For high-volume senders this is the dominant
per-request cost. Add an opt-in keep_alive_connections http_option that
reuses a thread-local Faraday connection (per uri) backed by
net-http-persistent, so the handshake and HTTP/2 stream are amortised
across requests.

- Default is false: existing callers see no behaviour change.
- Connections are cached per (thread, uri) keyed by object_id so multiple
  FCM clients in the same process do not share sockets, and Net::HTTP's
  non-thread-safety is respected.
- Bearer token and per-call extra_headers are re-applied each yield
  because JWTs expire and project_id headers vary across calls.
- On any error the cached connection is dropped — a half-closed socket
  would just fail again on retry.
- keep_alive_idle_timeout_seconds (default 30) and keep_alive_pool_size
  (default 1) are configurable.

Pattern mirrors the typesense-ruby fork (PR decision-labs#55).

Prompt: "Lets do lib/fcm tweak on fcm forked repo olio/fcm, I already
contributor so we can create PR there"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread lib/fcm.rb
# pool_size defaults to 1: we already cache one Faraday connection per
# (thread, uri), and Net::HTTP is not thread-safe — so a single socket
# per pool is the safe default. Override only with a specific reason.
faraday.adapter :net_http_persistent, pool_size: @keep_alive_pool_size do |http|
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [86/80]

Comment thread lib/fcm.rb
request: { timeout: @http_options.fetch(:timeout, DEFAULT_TIMEOUT) }
) do |faraday|
# pool_size defaults to 1: we already cache one Faraday connection per
# (thread, uri), and Net::HTTP is not thread-safe — so a single socket
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/AsciiComments: Use only ascii symbols in comments.

Comment thread lib/fcm.rb
def apply_default_headers(connection, extra_headers)
connection.headers["Content-Type"] = "application/json"
connection.headers["Authorization"] = "Bearer #{jwt_token}"
connection.headers["access_token_auth"] = "true"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.

Comment thread lib/fcm.rb

def apply_default_headers(connection, extra_headers)
connection.headers["Content-Type"] = "application/json"
connection.headers["Authorization"] = "Bearer #{jwt_token}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.

Comment thread lib/fcm.rb
end

def apply_default_headers(connection, extra_headers)
connection.headers["Content-Type"] = "application/json"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.

Comment thread spec/fcm_spec.rb
stub_request(:post, uri).to_raise(Faraday::ConnectionFailed.new('boom'))

expect { client.send_v1(send_v1_params) }.to raise_error(Faraday::ConnectionFailed)
expect(client.__send__(:thread_connections)).not_to have_key(FCM::BASE_URI_V1)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [84/80]

Comment thread spec/fcm_spec.rb

stub_request(:post, uri).to_raise(Faraday::ConnectionFailed.new('boom'))

expect { client.send_v1(send_v1_params) }.to raise_error(Faraday::ConnectionFailed)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [89/80]

Comment thread spec/fcm_spec.rb

it 'discards the cached connection when a request raises' do
client.send_v1(send_v1_params)
expect(client.__send__(:thread_connections)[FCM::BASE_URI_V1]).to be_a(Faraday::Connection)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [97/80]

Comment thread spec/fcm_spec.rb Outdated
stub_request(:post, uri).to_return(body: '{}', headers: {}, status: 200)
end

it 'caches a Faraday connection per (thread, uri) and reuses it across calls' do
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [84/80]

Comment thread spec/fcm_spec.rb Outdated
describe 'keep_alive_connections' do
let(:client) { FCM.new(json_key_path, project_name, keep_alive_connections: true) }
let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" }
let(:send_v1_params) { { 'token' => 'token', 'notification' => { 'title' => 'hi' } } }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Metrics/LineLength: Line is too long. [90/80]

@erimicel
Copy link
Copy Markdown
Contributor Author

@sabman we have been testing this on production some time now and we can see 20-30ms request time benefits on hot paths, as it is optional maybe okay to merge?

@sabman
Copy link
Copy Markdown
Member

sabman commented May 19, 2026

@erimicel sure let me quickly review today

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants