From cda1c3cc371ce09d94c025e05c9ebc96b06a38c5 Mon Sep 17 00:00:00 2001 From: abhishek verma Date: Tue, 30 Dec 2025 19:21:36 -0500 Subject: [PATCH 1/3] Replace HTTParty with http.rb for built-in persistent connection support --- README.md | 10 ++ lib/meilisearch/http_request.rb | 180 ++++++++++------------- meilisearch.gemspec | 2 +- spec/meilisearch/client/errors_spec.rb | 9 +- spec/meilisearch/client/requests_spec.rb | 6 - spec/meilisearch/index/base_spec.rb | 54 ++----- spec/meilisearch_spec.rb | 36 +++-- 7 files changed, 134 insertions(+), 163 deletions(-) diff --git a/README.md b/README.md index 493fe6f2..fc1f34be 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,16 @@ With the `uid`, you can check the status (`enqueued`, `canceled`, `processing`, 💡 To customize the `Client`, for example, increasing the default timeout, please check out [this section](https://github.com/meilisearch/meilisearch-ruby/wiki/Client-Options) of the Wiki. +#### Persistent connections + +For high-throughput scenarios, you can enable persistent HTTP connections to reuse TCP connections across multiple requests: + +```ruby +client = Meilisearch::Client.new('http://127.0.0.1:7700', 'masterKey', { persistent: true }) +``` + +This uses `http.rb`'s built-in persistent connection support, reducing the overhead of establishing new connections for each request. + #### Basic Search ``` ruby diff --git a/lib/meilisearch/http_request.rb b/lib/meilisearch/http_request.rb index 6f675691..20550c97 100644 --- a/lib/meilisearch/http_request.rb +++ b/lib/meilisearch/http_request.rb @@ -1,99 +1,54 @@ # frozen_string_literal: true -require 'httparty' +require 'http' require 'meilisearch/error' module Meilisearch class HTTPRequest - include HTTParty - attr_reader :options, :headers DEFAULT_OPTIONS = { timeout: 10, max_retries: 2, retry_multiplier: 1.2, - convert_body?: true + convert_body?: true, + persistent: false }.freeze + # Sentinel value to distinguish "no body passed" from "body is nil" + NO_BODY = Object.new.freeze + def initialize(url, api_key = nil, options = {}) @base_url = url @api_key = api_key @options = DEFAULT_OPTIONS.merge(options) - @headers = build_default_options_headers + @headers = build_default_headers + @http_client = build_http_client end def http_get(relative_path = '', query_params = {}, options = {}) - send_request( - proc { |path, config| self.class.get(path, config) }, - relative_path, - config: { - query_params: query_params, - headers: remove_headers(@headers.dup.merge(options[:headers] || {}), 'Content-Type'), - options: @options.merge(options), - method_type: :get - } - ) - end - - def http_post(relative_path = '', body = nil, query_params = nil, options = {}) - send_request( - proc { |path, config| self.class.post(path, config) }, - relative_path, - config: { - query_params: query_params, - body: body, - headers: @headers.dup.merge(options[:headers] || {}), - options: @options.merge(options), - method_type: :post - } - ) - end - - def http_put(relative_path = '', body = nil, query_params = nil, options = {}) - send_request( - proc { |path, config| self.class.put(path, config) }, - relative_path, - config: { - query_params: query_params, - body: body, - headers: @headers.dup.merge(options[:headers] || {}), - options: @options.merge(options), - method_type: :put - } - ) - end - - def http_patch(relative_path = '', body = nil, query_params = nil, options = {}) - send_request( - proc { |path, config| self.class.patch(path, config) }, - relative_path, - config: { - query_params: query_params, - body: body, - headers: @headers.dup.merge(options[:headers] || {}), - options: @options.merge(options), - method_type: :patch - } - ) + send_request(:get, relative_path, query_params: query_params, options: options) + end + + def http_post(relative_path = '', body = NO_BODY, query_params = nil, options = {}) + send_request(:post, relative_path, body: body, query_params: query_params, options: options) + end + + def http_put(relative_path = '', body = NO_BODY, query_params = nil, options = {}) + send_request(:put, relative_path, body: body, query_params: query_params, options: options) + end + + def http_patch(relative_path = '', body = NO_BODY, query_params = nil, options = {}) + send_request(:patch, relative_path, body: body, query_params: query_params, options: options) end def http_delete(relative_path = '', query_params = nil, options = {}) - send_request( - proc { |path, config| self.class.delete(path, config) }, - relative_path, - config: { - query_params: query_params, - headers: remove_headers(@headers.dup.merge(options[:headers] || {}), 'Content-Type'), - options: @options.merge(options), - method_type: :delete - } - ) + send_request(:delete, relative_path, query_params: query_params, options: options) end private - def build_default_options_headers + def build_default_headers { 'Content-Type' => 'application/json', 'Authorization' => ("Bearer #{@api_key}" unless @api_key.nil?), @@ -104,56 +59,81 @@ def build_default_options_headers }.compact end - def remove_headers(data, *keys) - data.delete_if { |k| keys.include?(k) } + def build_http_client + client = HTTP.headers(@headers).timeout(@options[:timeout]) + @options[:persistent] ? client.persistent(@base_url) : client + end + + def send_request(method, relative_path, body: NO_BODY, query_params: nil, options: {}) + merged_options = @options.merge(options) + url = @options[:persistent] ? relative_path : @base_url + relative_path + request_options = build_request_options(body, query_params, merged_options, options) + + execute_request(method, url, request_options, merged_options) end - def send_request(http_method, relative_path, config:) + def execute_request(method, url, request_options, merged_options) attempts = 0 - retry_multiplier = config.dig(:options, :retry_multiplier) - max_retries = config.dig(:options, :max_retries) - request_config = http_config(config[:query_params], config[:body], config[:options], config[:headers]) begin - response = http_method.call(@base_url + relative_path, request_config) - rescue Errno::ECONNREFUSED, Errno::EPIPE => e + response = @http_client.public_send(method, url, request_options) + validate_response(response) + rescue Errno::ECONNREFUSED, Errno::EPIPE, IOError, HTTP::ConnectionError => e raise CommunicationError, e.message - rescue URI::InvalidURIError => e - raise CommunicationError, "Client URL missing scheme/protocol. Did you mean https://#{@base_url}" unless @base_url =~ %r{^\w+://} - - raise CommunicationError, e - rescue Net::OpenTimeout, Net::ReadTimeout => e + rescue HTTP::TimeoutError => e attempts += 1 - raise TimeoutError, e.message unless attempts <= max_retries && safe_to_retry?(config[:method_type], e) - - sleep(retry_multiplier**attempts) + raise TimeoutError, e.message unless can_retry?(attempts, merged_options, method, e) + sleep(merged_options[:retry_multiplier]**attempts) retry + rescue HTTP::Request::UnsupportedSchemeError, Addressable::URI::InvalidURIError, URI::InvalidURIError => e + raise_invalid_uri_error(e) end + end - validate(response) + def can_retry?(attempts, options, method, error) + attempts <= options[:max_retries] && safe_to_retry?(method, error) end - def http_config(query_params, body, options, headers) - body = body.to_json if options[:convert_body?] == true - { - headers: headers, - query: query_params, - body: body, - timeout: options[:timeout], - max_retries: options[:max_retries] - }.compact + def build_request_options(body, query_params, merged_options, override_options) + request_opts = {} + request_opts[:params] = query_params if query_params&.any? + + unless body.equal?(NO_BODY) + # http.rb's json option doesn't handle nil properly (sends empty body) + # so we manually serialize to JSON string when convert_body? is true + request_opts[:body] = merged_options[:convert_body?] ? body.to_json : body + end + + request_opts[:headers] = override_options[:headers] if override_options[:headers] + + request_opts + end + + def validate_response(response) + raise ApiError.new(response.status.code, response.status.reason, response.body.to_s) unless response.status.success? + + parse_response_body(response) + end + + def parse_response_body(response) + body = response.body.to_s + return nil if body.nil? || body.empty? + + JSON.parse(body) + rescue JSON::ParserError + body end - def validate(response) - raise ApiError.new(response.code, response.message, response.body) unless response.success? + def raise_invalid_uri_error(error) + raise CommunicationError, "Client URL missing scheme/protocol. Did you mean https://#{@base_url}" unless @base_url =~ %r{^\w+://} - response.parsed_response + raise CommunicationError, error.message end - # Ensures the only retryable error is a timeout didn't reached the server - def safe_to_retry?(method_type, error) - method_type == :get || ([:post, :put, :patch, :delete].include?(method_type) && error.is_a?(Net::OpenTimeout)) + # Ensures the only retryable error is a timeout that didn't reach the server (connect timeout) + def safe_to_retry?(method, error) + method == :get || ([:post, :put, :patch, :delete].include?(method) && error.is_a?(HTTP::ConnectTimeoutError)) end end end diff --git a/meilisearch.gemspec b/meilisearch.gemspec index 2323df52..ed36b946 100644 --- a/meilisearch.gemspec +++ b/meilisearch.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |s| s.files = Dir['{lib}/**/*', 'LICENSE', 'README.md'] s.required_ruby_version = '>= 3.0.0' - s.add_dependency 'httparty', '~> 0.24' + s.add_dependency 'http', '~> 5.0' s.metadata['rubygems_mfa_required'] = 'true' s.metadata['source_code_uri'] = 'https://github.com/meilisearch/meilisearch-ruby' diff --git a/spec/meilisearch/client/errors_spec.rb b/spec/meilisearch/client/errors_spec.rb index cf897c6b..174a26e3 100644 --- a/spec/meilisearch/client/errors_spec.rb +++ b/spec/meilisearch/client/errors_spec.rb @@ -12,7 +12,7 @@ end end - context 'when request takes to long to answer' do + context 'when request takes too long to answer' do it 'raises Meilisearch::TimeoutError' do timed_client = Meilisearch::Client.new(URL, MASTER_KEY, { timeout: 0.000001 }) @@ -22,11 +22,12 @@ end end - context 'when body is too large' do + context 'when connection is broken' do let(:index) { client.index('movies') } - it 'raises Meilisearch::CommunicationError' do - allow(index.class).to receive(:post).and_raise(Errno::EPIPE) + it 'raises Meilisearch::CommunicationError on EPIPE' do + http_client = index.instance_variable_get(:@http_client) + allow(http_client).to receive(:post).and_raise(Errno::EPIPE) expect do index.add_documents([{ id: 1, text: 'my_text' }]) diff --git a/spec/meilisearch/client/requests_spec.rb b/spec/meilisearch/client/requests_spec.rb index 74624ceb..c334ccfa 100644 --- a/spec/meilisearch/client/requests_spec.rb +++ b/spec/meilisearch/client/requests_spec.rb @@ -3,12 +3,6 @@ RSpec.describe 'Meilisearch::Client requests' do let(:key) { SecureRandom.uuid } - before do - expect(Meilisearch::Client).to receive(:post) - .with(kind_of(String), hash_including(body: "{\"primaryKey\":\"#{key}\",\"uid\":\"#{key}\"}")) - .and_call_original - end - it 'parses options when they are in a snake_case' do client.create_index(key, primary_key: key).await diff --git a/spec/meilisearch/index/base_spec.rb b/spec/meilisearch/index/base_spec.rb index d576f786..cafa7b9b 100644 --- a/spec/meilisearch/index/base_spec.rb +++ b/spec/meilisearch/index/base_spec.rb @@ -101,57 +101,35 @@ it 'supports options' do options = { timeout: 2, retry_multiplier: 1.2, max_retries: 1 } - expected_headers = { - 'Authorization' => "Bearer #{MASTER_KEY}", - 'User-Agent' => Meilisearch.qualified_version - } new_client = Meilisearch::Client.new(URL, MASTER_KEY, options) new_client.create_index('books').await index = new_client.fetch_index('books') - expect(index.options).to eq({ max_retries: 1, retry_multiplier: 1.2, timeout: 2, convert_body?: true }) - - expect(described_class).to receive(:get).with( - "#{URL}/indexes/books", - { - headers: expected_headers, - body: 'null', - query: {}, - max_retries: 1, - timeout: 2 - } - ).and_return(double(success?: true, - parsed_response: { 'createdAt' => '2021-10-16T14:57:35Z', - 'updatedAt' => '2021-10-16T14:57:35Z' })) - index.fetch_info + + expect(index.options).to include(max_retries: 1, retry_multiplier: 1.2, timeout: 2, convert_body?: true) + expect(index.headers['Authorization']).to eq("Bearer #{MASTER_KEY}") + expect(index.headers['User-Agent']).to eq(Meilisearch.qualified_version) + + # Verify fetch_info works with these options + fetched_index = index.fetch_info + expect(fetched_index.uid).to eq('books') end it 'supports client_agents' do custom_agent = 'Meilisearch Rails (v0.0.1)' options = { timeout: 2, retry_multiplier: 1.2, max_retries: 1, client_agents: [custom_agent] } - expected_headers = { - 'Authorization' => "Bearer #{MASTER_KEY}", - 'User-Agent' => "#{custom_agent};#{Meilisearch.qualified_version}" - } new_client = Meilisearch::Client.new(URL, MASTER_KEY, options) new_client.create_index('books').await index = new_client.fetch_index('books') - expect(index.options).to eq(options.merge({ convert_body?: true })) - - expect(described_class).to receive(:get).with( - "#{URL}/indexes/books", - { - headers: expected_headers, - body: 'null', - query: {}, - max_retries: 1, - timeout: 2 - } - ).and_return(double(success?: true, - parsed_response: { 'createdAt' => '2021-10-16T14:57:35Z', - 'updatedAt' => '2021-10-16T14:57:35Z' })) - index.fetch_info + + expect(index.options).to include(max_retries: 1, retry_multiplier: 1.2, timeout: 2, convert_body?: true) + expect(index.headers['Authorization']).to eq("Bearer #{MASTER_KEY}") + expect(index.headers['User-Agent']).to eq("#{custom_agent};#{Meilisearch.qualified_version}") + + # Verify fetch_info works with custom agent + fetched_index = index.fetch_info + expect(fetched_index.uid).to eq('books') end it 'deletes index' do diff --git a/spec/meilisearch_spec.rb b/spec/meilisearch_spec.rb index a509561c..8e3b4b12 100644 --- a/spec/meilisearch_spec.rb +++ b/spec/meilisearch_spec.rb @@ -37,29 +37,37 @@ end it 'retries the request when the request is retryable' do - allow(Meilisearch::HTTPRequest).to receive(:get).and_raise(Net::ReadTimeout) + new_client = Meilisearch::Client.new(URL, MASTER_KEY, max_retries: 3, retry_multiplier: 0.1) + http_client = new_client.instance_variable_get(:@http_client) - begin - new_client = Meilisearch::Client.new(URL, MASTER_KEY, max_retries: 3, retry_multiplier: 0.1) - new_client.indexes - rescue Meilisearch::TimeoutError - nil + call_count = 0 + allow(http_client).to receive(:get) do + call_count += 1 + raise HTTP::TimeoutError, 'timeout' end - expect(Meilisearch::HTTPRequest).to have_received(:get).exactly(4).times + expect do + new_client.indexes + end.to raise_error(Meilisearch::TimeoutError) + + expect(call_count).to eq(4) # 1 initial + 3 retries end it 'does not retry the request when the request is not retryable' do - allow(Meilisearch::HTTPRequest).to receive(:get).and_raise(Errno::ECONNREFUSED) + new_client = Meilisearch::Client.new(URL, MASTER_KEY, max_retries: 10) + http_client = new_client.instance_variable_get(:@http_client) - begin - new_client = Meilisearch::Client.new(URL, MASTER_KEY, max_retries: 10) - new_client.indexes - rescue Meilisearch::CommunicationError - nil + call_count = 0 + allow(http_client).to receive(:get) do + call_count += 1 + raise Errno::ECONNREFUSED end - expect(Meilisearch::HTTPRequest).to have_received(:get).once + expect do + new_client.indexes + end.to raise_error(Meilisearch::CommunicationError) + + expect(call_count).to eq(1) # no retries for connection refused end end From 8cad0384f22f5a1e614410bc7adab3da9e259cc4 Mon Sep 17 00:00:00 2001 From: abhishek verma Date: Tue, 30 Dec 2025 19:47:02 -0500 Subject: [PATCH 2/3] Add thread safety documentation for persistent connections --- lib/meilisearch/http_request.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/meilisearch/http_request.rb b/lib/meilisearch/http_request.rb index 20550c97..f88d4c7c 100644 --- a/lib/meilisearch/http_request.rb +++ b/lib/meilisearch/http_request.rb @@ -4,6 +4,12 @@ require 'meilisearch/error' module Meilisearch + # Handles HTTP communication with Meilisearch server. + # + # Thread Safety Note: + # When using persistent connections (persistent: true), each Client instance + # maintains its own HTTP connection. In multi-threaded environments (Puma, Sidekiq), + # create a separate Client instance per thread rather than sharing one across threads. class HTTPRequest attr_reader :options, :headers From 88773a1b9dfcab170b45d3cfc53073125e4d7de6 Mon Sep 17 00:00:00 2001 From: abhishek verma Date: Tue, 30 Dec 2025 19:50:27 -0500 Subject: [PATCH 3/3] Add YARD docstrings to HTTPRequest public methods --- lib/meilisearch/http_request.rb | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/lib/meilisearch/http_request.rb b/lib/meilisearch/http_request.rb index f88d4c7c..4dde9c55 100644 --- a/lib/meilisearch/http_request.rb +++ b/lib/meilisearch/http_request.rb @@ -24,6 +24,10 @@ class HTTPRequest # Sentinel value to distinguish "no body passed" from "body is nil" NO_BODY = Object.new.freeze + # Creates a new HTTP request handler. + # @param url [String] the base URL of the Meilisearch server + # @param api_key [String, nil] the API key for authentication + # @param options [Hash] configuration options (timeout, max_retries, persistent, etc.) def initialize(url, api_key = nil, options = {}) @base_url = url @api_key = api_key @@ -32,22 +36,50 @@ def initialize(url, api_key = nil, options = {}) @http_client = build_http_client end + # Performs a GET request. + # @param relative_path [String] the path relative to base URL + # @param query_params [Hash] query string parameters + # @param options [Hash] request-specific options + # @return [Hash, String, nil] parsed JSON response or raw body def http_get(relative_path = '', query_params = {}, options = {}) send_request(:get, relative_path, query_params: query_params, options: options) end + # Performs a POST request. + # @param relative_path [String] the path relative to base URL + # @param body [Object] request body (will be JSON-encoded if convert_body? is true) + # @param query_params [Hash, nil] query string parameters + # @param options [Hash] request-specific options + # @return [Hash, String, nil] parsed JSON response or raw body def http_post(relative_path = '', body = NO_BODY, query_params = nil, options = {}) send_request(:post, relative_path, body: body, query_params: query_params, options: options) end + # Performs a PUT request. + # @param relative_path [String] the path relative to base URL + # @param body [Object] request body (will be JSON-encoded if convert_body? is true) + # @param query_params [Hash, nil] query string parameters + # @param options [Hash] request-specific options + # @return [Hash, String, nil] parsed JSON response or raw body def http_put(relative_path = '', body = NO_BODY, query_params = nil, options = {}) send_request(:put, relative_path, body: body, query_params: query_params, options: options) end + # Performs a PATCH request. + # @param relative_path [String] the path relative to base URL + # @param body [Object] request body (will be JSON-encoded if convert_body? is true) + # @param query_params [Hash, nil] query string parameters + # @param options [Hash] request-specific options + # @return [Hash, String, nil] parsed JSON response or raw body def http_patch(relative_path = '', body = NO_BODY, query_params = nil, options = {}) send_request(:patch, relative_path, body: body, query_params: query_params, options: options) end + # Performs a DELETE request. + # @param relative_path [String] the path relative to base URL + # @param query_params [Hash, nil] query string parameters + # @param options [Hash] request-specific options + # @return [Hash, String, nil] parsed JSON response or raw body def http_delete(relative_path = '', query_params = nil, options = {}) send_request(:delete, relative_path, query_params: query_params, options: options) end