Skip to content

Commit 86908f7

Browse files
committed
feat: opt-in HTTP keep-alive via keep_alive_connections
Currently `Typesense::ApiCall#perform_request` builds a fresh `Faraday.new(...)` (and therefore a new TCP and TLS handshake) on every request. On hot endpoints this can dominate the Typesense round-trip latency. This adds an opt-in `keep_alive_connections` configuration option (default `false`, so existing users see no behaviour change). When enabled: * Faraday connections are cached per `(thread, node)` rather than constructed per request. Net::HTTP is not thread-safe, so per-thread caching keeps concurrent callers isolated while still respecting the existing node round-robin. * Connections use the `:net_http_persistent` Faraday adapter with a 30s idle timeout, so reused sockets are dropped before most load balancers cull them. * On any rescued network error, the cached connection is dropped before the gem retries, so a half-closed keep-alive socket cannot fail the retry as well. Pair with `num_retries >= 1` for transparent recovery from server- or load-balancer-side idle timeouts. The `:net_http_persistent` adapter and its `net-http-persistent` runtime dependency are listed in the gemspec, and `require 'faraday/net_http_persistent'` is gated on the option being enabled, so loading the gem with the option off does not import the new dependency at runtime. New RSpec coverage: * connection reuse on the same thread * per-node cache keying * per-thread cache isolation * per-instance cache isolation * eviction on network error * timeouts propagate to the cached connection * the option defaults to false and the legacy per-request connection path is preserved
1 parent f33f224 commit 86908f7

5 files changed

Lines changed: 182 additions & 6 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,27 @@ Here are some examples with inline comments that walk you through how to use the
2929

3030
Tests are also a good place to know how the the library works internally: [spec](spec)
3131

32+
### Keep-alive connections
33+
34+
By default, the client opens a fresh HTTP connection (and TLS handshake) for every request. For high-traffic applications this can dominate request latency. Setting `keep_alive_connections: true` enables persistent connections via the `:net_http_persistent` Faraday adapter:
35+
36+
```ruby
37+
Typesense::Client.new(
38+
api_key: ENV['TYPESENSE_API_KEY'],
39+
nodes: [{ host: 'localhost', port: 8108, protocol: 'https' }],
40+
connection_timeout_seconds: 3,
41+
num_retries: 1,
42+
keep_alive_connections: true
43+
)
44+
```
45+
46+
Notes:
47+
48+
- Connections are cached per `(thread, node)`. `Net::HTTP` is not thread-safe, so each thread maintains its own keep-alive socket to each Typesense node, and the existing node round-robin still works.
49+
- A cached connection is dropped automatically when a network error occurs, so retries open a fresh socket. We recommend setting `num_retries` to at least `1` so the gem can recover from a server- or load-balancer-side idle timeout transparently.
50+
- Idle sockets are closed after 30 seconds; tune your load balancer's idle timeout to match or exceed this.
51+
- The option defaults to `false`, so upgrading the gem does not change behaviour until you opt in.
52+
3253
## Compatibility
3354

3455
| Typesense Server | typesense-ruby |

lib/typesense/api_call.rb

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
module Typesense
77
class ApiCall
88
API_KEY_HEADER_NAME = 'X-TYPESENSE-API-KEY'
9+
KEEP_ALIVE_IDLE_TIMEOUT_SECONDS = 30
910

1011
attr_reader :logger
1112

@@ -19,9 +20,16 @@ def initialize(configuration)
1920
@healthcheck_interval_seconds = @configuration.healthcheck_interval_seconds
2021
@num_retries_per_request = @configuration.num_retries
2122
@retry_interval_seconds = @configuration.retry_interval_seconds
23+
@keep_alive_connections = @configuration.keep_alive_connections
2224

2325
@logger = @configuration.logger
2426

27+
# Per-instance key for the thread-local connection cache so multiple
28+
# Typesense::Client instances in the same process do not share sockets.
29+
@thread_connections_key = :"_typesense_api_call_connections_#{object_id}"
30+
31+
require 'faraday/net_http_persistent' if @keep_alive_connections
32+
2533
initialize_metadata_for_nodes
2634
@current_node_index = -1
2735
end
@@ -69,14 +77,11 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni
6977
@logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}"
7078

7179
begin
72-
conn = Faraday.new(uri_for(endpoint, node)) do |f|
73-
f.options.timeout = @connection_timeout_seconds
74-
f.options.open_timeout = @connection_timeout_seconds
75-
end
80+
conn, request_path = connection_and_path_for(endpoint, node)
7681

7782
headers = default_headers.merge(additional_headers)
7883

79-
response = conn.send(method) do |req|
84+
response = conn.send(method, request_path) do |req|
8085
req.headers = headers
8186
req.params = query_parameters unless query_parameters.nil?
8287
unless body_parameters.nil?
@@ -108,6 +113,9 @@ def perform_request(method, endpoint, query_parameters: nil, body_parameters: ni
108113
# Rescue network layer exceptions and HTTP 5xx errors, so the loop can continue.
109114
# Using loops for retries instead of rescue...retry to maintain consistency with client libraries in
110115
# other languages that might not support the same construct.
116+
# Drop the cached keep-alive connection (if any): the underlying socket is likely
117+
# half-closed and reusing it would just fail again on retry.
118+
discard_connection(node) if @keep_alive_connections
111119
set_node_healthcheck(node, is_healthy: false)
112120
last_exception = e
113121
@logger.warn "Request #{method}:#{uri_for(endpoint, node)} to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\""
@@ -125,6 +133,54 @@ def uri_for(endpoint, node)
125133
"#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}"
126134
end
127135

136+
# Returns [connection, request_path]. When keep-alive is enabled, the connection
137+
# is cached per (thread, node) and the path is appended at request time. When it
138+
# is disabled, the original behaviour is preserved: a fresh Faraday is built for
139+
# the full per-request URL, so existing callers and stubs see no change.
140+
def connection_and_path_for(endpoint, node)
141+
if @keep_alive_connections
142+
[connection_for(node), endpoint]
143+
else
144+
[build_one_shot_connection(endpoint, node), nil]
145+
end
146+
end
147+
148+
def build_one_shot_connection(endpoint, node)
149+
Faraday.new(uri_for(endpoint, node)) do |f|
150+
f.options.timeout = @connection_timeout_seconds
151+
f.options.open_timeout = @connection_timeout_seconds
152+
end
153+
end
154+
155+
# Net::HTTP is not thread-safe, so connections are cached per (thread, node)
156+
# rather than shared across threads.
157+
def connection_for(node)
158+
thread_connections[connection_key(node)] ||= build_keep_alive_connection(node)
159+
end
160+
161+
def discard_connection(node)
162+
conn = thread_connections.delete(connection_key(node))
163+
conn&.close if conn.respond_to?(:close)
164+
end
165+
166+
def thread_connections
167+
Thread.current[@thread_connections_key] ||= {}
168+
end
169+
170+
def connection_key(node)
171+
"#{node[:protocol]}://#{node[:host]}:#{node[:port]}"
172+
end
173+
174+
def build_keep_alive_connection(node)
175+
Faraday.new(url: connection_key(node)) do |f|
176+
f.options.timeout = @connection_timeout_seconds
177+
f.options.open_timeout = @connection_timeout_seconds
178+
f.adapter :net_http_persistent, pool_size: 1 do |http|
179+
http.idle_timeout = KEEP_ALIVE_IDLE_TIMEOUT_SECONDS
180+
end
181+
end
182+
end
183+
128184
## Attempts to find the next healthy node, looping through the list of nodes once.
129185
# But if no healthy nodes are found, it will just return the next node, even if it's unhealthy
130186
# so we can try the request for good measure, in case that node has become healthy since

lib/typesense/configuration.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
module Typesense
66
class Configuration
7-
attr_accessor :nodes, :nearest_node, :connection_timeout_seconds, :healthcheck_interval_seconds, :num_retries, :retry_interval_seconds, :api_key, :logger, :log_level
7+
attr_accessor :nodes, :nearest_node, :connection_timeout_seconds, :healthcheck_interval_seconds, :num_retries, :retry_interval_seconds, :api_key, :logger, :log_level, :keep_alive_connections
88

99
def initialize(options = {})
1010
@nodes = options[:nodes] || []
@@ -14,6 +14,7 @@ def initialize(options = {})
1414
@num_retries = options[:num_retries] || (@nodes.length + (@nearest_node.nil? ? 0 : 1)) || 3
1515
@retry_interval_seconds = options[:retry_interval_seconds] || 0.1
1616
@api_key = options[:api_key]
17+
@keep_alive_connections = options.fetch(:keep_alive_connections, false)
1718

1819
@logger = options[:logger] || Logger.new($stdout)
1920
@log_level = options[:log_level] || Logger::WARN

spec/typesense/api_call_spec.rb

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,100 @@
258258
it_behaves_like 'General error handling', :delete
259259
it_behaves_like 'Node selection', :delete
260260
end
261+
262+
describe 'keep-alive connection caching' do
263+
subject(:api_call) { described_class.new(keep_alive_typesense.configuration) }
264+
265+
let(:keep_alive_typesense) do
266+
Typesense::Client.new(
267+
api_key: 'abcd',
268+
nodes: typesense.configuration.nodes,
269+
connection_timeout_seconds: 10,
270+
retry_interval_seconds: 0.01,
271+
log_level: Logger::ERROR,
272+
keep_alive_connections: true
273+
)
274+
end
275+
276+
let(:node) { keep_alive_typesense.configuration.nodes[0] }
277+
278+
before do
279+
keep_alive_typesense.configuration.nodes.each do |n|
280+
stub_request(:any, api_call.send(:uri_for, '/', n))
281+
.to_return(status: 200, body: JSON.dump('ok' => true), headers: { 'Content-Type' => 'application/json' })
282+
end
283+
end
284+
285+
it 'reuses the same Faraday connection across calls to the same node on the same thread' do
286+
first = api_call.send(:connection_for, node)
287+
second = api_call.send(:connection_for, node)
288+
289+
expect(second).to be(first)
290+
end
291+
292+
it 'caches connections separately per node' do
293+
first_node_conn = api_call.send(:connection_for, keep_alive_typesense.configuration.nodes[0])
294+
second_node_conn = api_call.send(:connection_for, keep_alive_typesense.configuration.nodes[1])
295+
296+
expect(second_node_conn).not_to be(first_node_conn)
297+
end
298+
299+
it 'isolates the cache per thread' do
300+
main_thread_conn = api_call.send(:connection_for, node)
301+
302+
other_thread_conn = Thread.new { api_call.send(:connection_for, node) }.value
303+
304+
expect(other_thread_conn).not_to be(main_thread_conn)
305+
end
306+
307+
it 'isolates the cache per ApiCall instance' do
308+
other_api_call = described_class.new(keep_alive_typesense.configuration)
309+
310+
expect(other_api_call.send(:connection_for, node))
311+
.not_to be(api_call.send(:connection_for, node))
312+
end
313+
314+
it 'evicts the cached connection when a network error occurs so retries open a fresh socket' do
315+
timeout_node = keep_alive_typesense.configuration.nodes[0]
316+
keep_alive_typesense.configuration.nodes.each do |n|
317+
stub_request(:any, api_call.send(:uri_for, '/', n)).to_timeout
318+
end
319+
320+
pre_call_conn = api_call.send(:connection_for, timeout_node)
321+
322+
begin
323+
api_call.get('/')
324+
rescue StandardError
325+
# expected: all nodes time out
326+
end
327+
328+
cache = Thread.current[api_call.instance_variable_get(:@thread_connections_key)] || {}
329+
expect(cache[api_call.send(:connection_key, timeout_node)]).to be_nil
330+
331+
post_retry_conn = api_call.send(:connection_for, timeout_node)
332+
expect(post_retry_conn).not_to be(pre_call_conn)
333+
end
334+
335+
it 'uses the configured timeouts on the cached connection' do
336+
conn = api_call.send(:connection_for, node)
337+
338+
expect(conn.options.timeout).to eq(keep_alive_typesense.configuration.connection_timeout_seconds)
339+
expect(conn.options.open_timeout).to eq(keep_alive_typesense.configuration.connection_timeout_seconds)
340+
end
341+
end
342+
343+
describe 'keep-alive disabled (default)' do
344+
it 'is off by default on the configuration' do
345+
expect(typesense.configuration.keep_alive_connections).to be(false)
346+
end
347+
348+
it 'builds a fresh Faraday connection per request' do
349+
stub_request(:any, api_call.send(:uri_for, '/', typesense.configuration.nodes[0]))
350+
.to_return(status: 200, body: JSON.dump('ok' => true), headers: { 'Content-Type' => 'application/json' })
351+
352+
api_call.get('/')
353+
354+
expect(Thread.current[api_call.instance_variable_get(:@thread_connections_key)]).to be_nil
355+
end
356+
end
261357
end

typesense.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ Gem::Specification.new do |spec|
2828

2929
spec.add_dependency 'base64', '~> 0.2.0'
3030
spec.add_dependency 'faraday', '~> 2.8'
31+
spec.add_dependency 'faraday-net_http_persistent', '~> 2.0'
3132
spec.add_dependency 'json', '~> 2.9'
33+
spec.add_dependency 'net-http-persistent', '~> 4.0'
3234
spec.metadata['rubygems_mfa_required'] = 'true'
3335
end

0 commit comments

Comments
 (0)