Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <!-- omit in toc -->

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 <!-- omit in toc -->

``` ruby
Expand Down
218 changes: 118 additions & 100 deletions lib/meilisearch/http_request.rb
Original file line number Diff line number Diff line change
@@ -1,99 +1,92 @@
# frozen_string_literal: true

require 'httparty'
require 'http'
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
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

# 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
@options = DEFAULT_OPTIONS.merge(options)
@headers = build_default_options_headers
@headers = build_default_headers
@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(
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

# 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(
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?),
Expand All @@ -104,56 +97,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
2 changes: 1 addition & 1 deletion meilisearch.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
9 changes: 5 additions & 4 deletions spec/meilisearch/client/errors_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand All @@ -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' }])
Expand Down
6 changes: 0 additions & 6 deletions spec/meilisearch/client/requests_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading