solid-redis 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a4bfba5a15f75c77fa0c5a9d2a85a0d5d8b50ebada6fbd5e1895e56e47b5813a
4
+ data.tar.gz: 842b205f8ba0b9298451f24bec08c0d76542e6b46026b27539853d35c06fce54
5
+ SHA512:
6
+ metadata.gz: 8a6afa66bb51a4723939441be103372efd6260c670dd8e68ca81d788cc2430bc732221c2b7b930a35365d207b1e095a654ecbd88bac715c08bc8b6f86028872d
7
+ data.tar.gz: c010108671dfc8c343b213fde778afc1e9c3e2750051629ae585d2857037d8da1dd455ff2dba974b78098f014a7c25c495f30d4b1fc88b2b9ab2bf236bd0bca6
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicolas Vandenbogaerde
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,293 @@
1
+ # Solid Redis
2
+
3
+ [![Build Status](https://github.com/nicolasva/solid-redis/actions/workflows/ci.yml/badge.svg)](https://github.com/nicolasva/solid-redis/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/solid-redis.svg)](https://rubygems.org/gems/solid-redis)
5
+ [![Documentation Status](https://img.shields.io/badge/docs-RubyDoc.info-blue.svg)](https://www.rubydoc.info/gems/solid-redis)
6
+
7
+ `solid-redis` is a dependency-free Redis client designed around Ractor
8
+ isolation. Its Redis and Sentinel specifications are immutable and shareable;
9
+ every Ractor creates and retains its own resolution state, mutex, pool,
10
+ clients, and sockets.
11
+
12
+ It does not depend on or patch `redis-client`.
13
+
14
+ The implementation intentionally composes two small gems from the same
15
+ author:
16
+
17
+ - [`base-service`](https://github.com/nicolasva/base-service) executes each
18
+ Sentinel resolution and returns its immutable result and endpoint errors;
19
+ - [`callback-collection`](https://github.com/nicolasva/callback-collection)
20
+ provides immutable lifecycle event handlers.
21
+
22
+ ## Architecture
23
+
24
+ ```text
25
+ RUBY
26
+ |
27
+ immutable/shareable config
28
+ |
29
+ +----------------+----------------+
30
+ | | |
31
+ Ractor A Ractor B Ractor C
32
+ | | |
33
+ SentinelState A SentinelState B SentinelState C
34
+ | | |
35
+ Mutex A Mutex B Mutex C
36
+ | | |
37
+ Pool A Pool B Pool C
38
+ | | |
39
+ Socket A Socket B Socket C
40
+ | | |
41
+ +----------------+----------------+
42
+ |
43
+ Redis/Sentinel
44
+ ```
45
+
46
+ `SolidRedis::SentinelConfig` contains only declarative values. It can therefore
47
+ cross a Ractor boundary. Its first use in each Ractor creates a
48
+ `SolidRedis::SentinelState` in `Ractor.current` storage. The state owns:
49
+
50
+ - the cached Redis target;
51
+ - the current Sentinel ordering;
52
+ - dynamically discovered Sentinels;
53
+ - temporary Sentinel clients;
54
+ - a mutex protecting threads in that Ractor.
55
+
56
+ Sentinel connections are opened only while resolving a target and are then
57
+ closed. Resolution is cached until `reset` or a connection/failover error.
58
+
59
+ ## Requirements
60
+
61
+ Ruby **3.1 or newer** is required (tested on 3.1, 3.2, 3.3, 3.4, and 4.0).
62
+ Ruby 2.7 and 3.0 are not supported: the `Ractor` API this gem is built on
63
+ was introduced in 3.0, and the per-Ractor storage semantics are verified
64
+ from 3.1 onwards. Ruby >= 4.0 is recommended for workloads that run several
65
+ threads inside several Ractors (see the CRuby limitation below).
66
+
67
+ ## Installation
68
+
69
+ ```ruby
70
+ gem "solid-redis"
71
+ ```
72
+
73
+ ```sh
74
+ bundle install
75
+ ```
76
+
77
+ ## Direct Redis usage
78
+
79
+ ```ruby
80
+ require "solid_redis"
81
+
82
+ config = SolidRedis.config(
83
+ url: "redis://localhost:6379/0",
84
+ timeout: 1.0,
85
+ reconnect_attempts: 1
86
+ )
87
+
88
+ client = config.new_client
89
+ client.call("SET", "answer", 42)
90
+ client.call("GET", "answer") # => "42"
91
+ client.close
92
+ ```
93
+
94
+ The client supports TCP, Unix sockets, TLS, RESP2 and RESP3 response types,
95
+ AUTH, SELECT, CLIENT SETNAME, calls, vector calls, and pipelines:
96
+
97
+ ```ruby
98
+ client.pipelined do |pipeline|
99
+ pipeline.call("SET", "first", 1)
100
+ pipeline.call("GET", "first")
101
+ end
102
+ # => ["OK", "1"]
103
+ ```
104
+
105
+ ## Sentinel with Ractors
106
+
107
+ Create one immutable specification and pass it to every worker:
108
+
109
+ ```ruby
110
+ SENTINEL = SolidRedis.sentinel(
111
+ name: "mymaster",
112
+ sentinels: [
113
+ { host: "127.0.0.1", port: 26_380 },
114
+ { host: "127.0.0.1", port: 26_381 }
115
+ ],
116
+ role: :master,
117
+ timeout: 1.0
118
+ )
119
+
120
+ Ractor.shareable?(SENTINEL) # => true
121
+
122
+ workers = 4.times.map do
123
+ Ractor.new(SENTINEL) do |sentinel|
124
+ pool = sentinel.new_pool(size: 5)
125
+ pool.call("PING")
126
+ ensure
127
+ pool&.close
128
+ end
129
+ end
130
+
131
+ workers.map(&:value) # => ["PONG", "PONG", "PONG", "PONG"]
132
+ # On Ruby 3.x, use workers.map(&:take) instead.
133
+ ```
134
+
135
+ Each Ractor resolves `mymaster` independently on first use. A connection error
136
+ invalidates only that Ractor's cached target and causes its next connection
137
+ attempt to query Sentinel again.
138
+
139
+ For replicas, pass `role: :replica` (the `:slave` alias is accepted).
140
+ Unavailable replicas marked `s_down`, `o_down`, or `disconnected` are ignored.
141
+
142
+ Sentinel credentials and TLS are independent from Redis credentials and TLS:
143
+
144
+ ```ruby
145
+ SolidRedis.sentinel(
146
+ name: "mymaster",
147
+ sentinels: ["rediss://sentinel-user:secret@sentinel.example:26379"],
148
+ username: "redis-user",
149
+ password: "redis-secret",
150
+ ssl: true,
151
+ ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER },
152
+ sentinel_ssl: true,
153
+ sentinel_ssl_params: { verify_mode: OpenSSL::SSL::VERIFY_PEER }
154
+ )
155
+ ```
156
+
157
+ ## Per-Ractor pool
158
+
159
+ `new_pool` creates a conventional thread-safe pool owned by the calling
160
+ Ractor:
161
+
162
+ ```ruby
163
+ pool = SENTINEL.new_pool(size: 5, timeout: 1.0)
164
+
165
+ pool.with do |client|
166
+ client.call("INCR", "jobs")
167
+ end
168
+
169
+ pool.call("GET", "jobs")
170
+ pool.close
171
+ ```
172
+
173
+ The pool may be shared by threads in its owning Ractor. It must not be sent to
174
+ another Ractor. Create a separate pool inside every Ractor, as in the example
175
+ above.
176
+
177
+ ## Configuration
178
+
179
+ Direct Redis options:
180
+
181
+ | Option | Default | Description |
182
+ |---|---:|---|
183
+ | `url` | | `redis://`, `rediss://`, or `unix://` URL |
184
+ | `host` | `127.0.0.1` | Redis host |
185
+ | `port` | `6379` | Redis port |
186
+ | `path` | | Unix socket path |
187
+ | `username` | | ACL username |
188
+ | `password` | | Password |
189
+ | `db` | `0` | Database number |
190
+ | `timeout` | `1.0` | Default connect/read/write timeout |
191
+ | `connect_timeout` | `timeout` | Connection timeout |
192
+ | `read_timeout` | `timeout` | Read timeout |
193
+ | `write_timeout` | `timeout` | Write timeout |
194
+ | `reconnect_attempts` | `1` | Retries after a connection error |
195
+ | `ssl` | `false` | Enable TLS |
196
+ | `ssl_params` | | Immutable `OpenSSL::SSL::SSLContext` attributes |
197
+
198
+ Sentinel additionally requires `name` and `sentinels`, and accepts `role`,
199
+ `sentinel_username`, `sentinel_password`, `sentinel_ssl`, and
200
+ `sentinel_ssl_params`. Sentinel defaults to two reconnect attempts.
201
+
202
+ All configuration is copied and deeply frozen. Proc credentials and mutable
203
+ objects such as `OpenSSL::X509::Store` are rejected because Ruby cannot make
204
+ them Ractor-shareable. Prefer immutable values and paths in TLS parameters.
205
+
206
+ ## Lifecycle callbacks
207
+
208
+ Pass a shareable `CallbackCollection` through `callbacks:` to observe
209
+ `connected`, `disconnected`, `connection_error`, and `resolved` events:
210
+
211
+ ```ruby
212
+ module RedisEvents
213
+ def self.connected(url)
214
+ Logger.info("Connected to #{url}")
215
+ end
216
+
217
+ def self.connection_error(type, message)
218
+ Logger.warn("#{type}: #{message}")
219
+ end
220
+ end
221
+
222
+ callbacks = CallbackCollection.new do |collection|
223
+ collection.register(:connected, RedisEvents)
224
+ collection.register(:connection_error, RedisEvents)
225
+ end
226
+
227
+ config = SolidRedis.sentinel(
228
+ name: "mymaster",
229
+ sentinels: [{ host: "127.0.0.1", port: 26_379 }],
230
+ callbacks: callbacks
231
+ )
232
+ ```
233
+
234
+ | Callback | Arguments |
235
+ |---|---|
236
+ | `connected` | resolved server URL |
237
+ | `disconnected` | resolved server URL |
238
+ | `connection_error` | exception class name and message |
239
+ | `resolved` | Sentinel name and resolved server URL |
240
+
241
+ Method registration is required for a Ractor-shareable callback collection.
242
+ Block callbacks retain mutable lexical context and are therefore rejected.
243
+ Callback exceptions propagate to the caller.
244
+
245
+ ## Semantics and current scope
246
+
247
+ - Clients, pools, sockets, mutexes, and Sentinel runtime state are never
248
+ shared between Ractors.
249
+ - Multiple threads in one Ractor share one protected Sentinel resolution.
250
+ - Redis command errors are never retried.
251
+ - Connection errors may retry a command according to `reconnect_attempts`.
252
+ Applications requiring strict at-most-once semantics should set it to `0`.
253
+ - A pool waits up to its checkout timeout and then raises
254
+ `SolidRedis::CheckoutTimeoutError`.
255
+ - Pub/Sub, transactions, blocking-call helpers, cluster routing, middleware,
256
+ and an asynchronous actor pool are not part of version `0.1`.
257
+
258
+ ### Known CRuby limitation
259
+
260
+ Ractor support in CRuby is still experimental. On CRuby 3.4, running
261
+ **multiple threads inside multiple Ractors simultaneously** can hang
262
+ intermittently in the VM scheduler. This is reproducible with plain
263
+ `Thread` + CPU work in bare Ractors, without solid-redis, and is fixed
264
+ by the Ractor rewrite in CRuby >= 4.0 (verified by
265
+ `test/ractor_stress_test.rb`, which is skipped on older Rubies).
266
+ Safe patterns on CRuby 3.x:
267
+
268
+ - one thread per Ractor (the natural Ractor model), or
269
+ - multiple threads and a pool inside a single Ractor.
270
+
271
+ Both are fully supported by this gem.
272
+
273
+ An asynchronous `new_ractor_pool` would be a separate API: worker Ractors
274
+ would own connections and exchange commands/results through messages or
275
+ futures. It cannot transparently replace a block-based synchronous pool
276
+ without defining backpressure, cancellation, transaction, Pub/Sub, and
277
+ exception protocols.
278
+
279
+ ## Development
280
+
281
+ ```sh
282
+ bundle install
283
+ bundle exec rake
284
+ ```
285
+
286
+ The default task runs the Minitest suite and builds the gem in `pkg/`.
287
+ CI runs the suite on Ruby 3.1 through 4.0.
288
+
289
+ ## Publishing
290
+
291
+ Releases use RubyGems Trusted Publishing. Configure a pending trusted
292
+ publisher for `nicolasva/solid-redis`, workflow `release.yml`, environment
293
+ `release`, then push a tag matching `SolidRedis::VERSION`.
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+
6
+ module SolidRedis
7
+ class Client
8
+ attr_reader :config
9
+
10
+ def initialize(config, name: nil)
11
+ @config = config
12
+ @name = name&.to_s
13
+ @socket = nil
14
+ @reader = nil
15
+ @target = nil
16
+ end
17
+
18
+ def call(*command)
19
+ call_v(command)
20
+ end
21
+
22
+ def call_v(command)
23
+ with_reconnect do
24
+ write(RESP.encode(command))
25
+ @reader.read
26
+ end
27
+ end
28
+
29
+ def pipelined
30
+ pipeline = Pipeline.new
31
+ yield pipeline
32
+ return [] if pipeline.commands.empty?
33
+
34
+ with_reconnect do
35
+ write(pipeline.commands.map { |command| RESP.encode(command) }.join)
36
+ results = pipeline.commands.map { @reader.read(exception: false) }
37
+ raise results.find { |result| result.is_a?(CommandError) } if results.any?(CommandError)
38
+
39
+ results
40
+ end
41
+ end
42
+
43
+ def connected?
44
+ @socket && !@socket.closed?
45
+ end
46
+
47
+ def close
48
+ socket = @socket
49
+ target = @target
50
+ @socket = @reader = @target = nil
51
+ socket&.close
52
+ config.notify(:disconnected, target.server_url) if socket && target
53
+ self
54
+ rescue IOError
55
+ self
56
+ end
57
+
58
+ def server_url
59
+ config.server_url
60
+ end
61
+
62
+ private
63
+
64
+ def with_reconnect
65
+ attempts = 0
66
+ begin
67
+ connect unless connected?
68
+ yield
69
+ rescue ConnectionError, IO::WaitReadable, IO::WaitWritable, SystemCallError => error
70
+ close
71
+ config.reset if config.sentinel?
72
+ config.notify(:connection_error, error.class.name, error.message)
73
+ if attempts < config.reconnect_attempts
74
+ attempts += 1
75
+ retry
76
+ end
77
+ raise error if error.is_a?(Error)
78
+
79
+ raise ConnectionError, error.message, cause: error
80
+ end
81
+ end
82
+
83
+ def connect
84
+ @target = config.sentinel? ? config.resolve : config
85
+ @socket = open_socket(@target)
86
+ @reader = RESP::Reader.new(@socket, read_timeout: @target.read_timeout)
87
+ authenticate
88
+ raw_call(["SELECT", @target.db]) unless @target.db.zero?
89
+ raw_call(["CLIENT", "SETNAME", @name]) if @name
90
+ verify_role if config.sentinel?
91
+ config.notify(:connected, @target.server_url)
92
+ self
93
+ rescue StandardError
94
+ close
95
+ raise
96
+ end
97
+
98
+ def open_socket(target)
99
+ socket = if target.path
100
+ UNIXSocket.new(target.path)
101
+ else
102
+ Socket.tcp(target.host, target.port, connect_timeout: target.connect_timeout)
103
+ end
104
+ return socket unless target.ssl?
105
+
106
+ context = OpenSSL::SSL::SSLContext.new
107
+ apply_ssl_params(context, target.ssl_params || {})
108
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, context)
109
+ ssl_socket.hostname = target.host if ssl_socket.respond_to?(:hostname=)
110
+ ssl_socket.sync_close = true
111
+ ssl_connect(ssl_socket, target.connect_timeout)
112
+ ssl_socket
113
+ rescue TimeoutError
114
+ socket&.close
115
+ raise
116
+ rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError => error
117
+ socket&.close
118
+ raise ConnectionError, error.message, cause: error
119
+ rescue ArgumentError
120
+ socket&.close
121
+ raise
122
+ end
123
+
124
+ def ssl_connect(socket, timeout)
125
+ deadline = monotonic_time + timeout
126
+ loop do
127
+ result = socket.connect_nonblock(exception: false)
128
+ return if result == socket
129
+
130
+ remaining = deadline - monotonic_time
131
+ raise TimeoutError, "TLS handshake timed out after #{timeout}s" unless remaining.positive?
132
+
133
+ readers = result == :wait_readable ? [socket] : nil
134
+ writers = result == :wait_writable ? [socket] : nil
135
+ IO.select(readers, writers, nil, remaining)
136
+ end
137
+ end
138
+
139
+ def apply_ssl_params(context, params)
140
+ params.each do |name, value|
141
+ writer = :"#{name}="
142
+ raise ArgumentError, "Unknown SSL context option: #{name.inspect}" unless context.respond_to?(writer)
143
+
144
+ context.public_send(writer, value)
145
+ end
146
+ end
147
+
148
+ def authenticate
149
+ return unless @target.password
150
+
151
+ command = @target.username ? ["AUTH", @target.username, @target.password] : ["AUTH", @target.password]
152
+ raw_call(command)
153
+ end
154
+
155
+ def verify_role
156
+ actual = raw_call(["ROLE"]).first
157
+ expected = config.role == :master ? "master" : "slave"
158
+ return if actual == expected
159
+
160
+ raise FailoverError, "Expected Redis role #{expected.inspect}, got #{actual.inspect}"
161
+ end
162
+
163
+ def raw_call(command)
164
+ write(RESP.encode(command))
165
+ @reader.read
166
+ end
167
+
168
+ def write(payload)
169
+ offset = 0
170
+ while offset < payload.bytesize
171
+ unless IO.select(nil, [@socket], nil, @target.write_timeout)
172
+ raise TimeoutError, "Redis write timed out after #{@target.write_timeout}s"
173
+ end
174
+
175
+ written = @socket.write_nonblock(payload.byteslice(offset..), exception: false)
176
+ next if written == :wait_writable
177
+
178
+ offset += written
179
+ end
180
+ rescue IOError, SystemCallError => error
181
+ raise ConnectionError, error.message, cause: error
182
+ end
183
+
184
+ def monotonic_time
185
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
186
+ end
187
+
188
+ class Pipeline
189
+ attr_reader :commands
190
+
191
+ def initialize
192
+ @commands = []
193
+ end
194
+
195
+ def call(*command)
196
+ @commands << command
197
+ nil
198
+ end
199
+
200
+ def call_v(command)
201
+ @commands << command
202
+ nil
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module SolidRedis
6
+ class Config
7
+ DEFAULT_TIMEOUT = 1.0
8
+ DEFAULT_PORT = 6379
9
+
10
+ attr_reader :host, :port, :path, :username, :password, :db,
11
+ :connect_timeout, :read_timeout, :write_timeout, :reconnect_attempts,
12
+ :ssl_params
13
+
14
+ def initialize(
15
+ url: nil,
16
+ host: "127.0.0.1",
17
+ port: DEFAULT_PORT,
18
+ path: nil,
19
+ username: nil,
20
+ password: nil,
21
+ db: 0,
22
+ timeout: DEFAULT_TIMEOUT,
23
+ connect_timeout: timeout,
24
+ read_timeout: timeout,
25
+ write_timeout: timeout,
26
+ reconnect_attempts: 1,
27
+ ssl: false,
28
+ ssl_params: nil,
29
+ callbacks: nil
30
+ )
31
+ if url
32
+ values = parse_url(url)
33
+ host = values[:host]
34
+ port = values[:port]
35
+ path = values[:path]
36
+ username ||= values[:username]
37
+ password ||= values[:password]
38
+ db = values[:db] unless values[:db].nil?
39
+ ssl ||= values[:ssl]
40
+ end
41
+
42
+ @host = String(host).dup.freeze unless path
43
+ @port = Integer(port) unless path
44
+ @path = String(path).dup.freeze if path
45
+ @username = String(username).dup.freeze if username
46
+ @password = String(password).dup.freeze if password
47
+ @db = Integer(db)
48
+ @connect_timeout = positive_float(connect_timeout, :connect_timeout)
49
+ @read_timeout = positive_float(read_timeout, :read_timeout)
50
+ @write_timeout = positive_float(write_timeout, :write_timeout)
51
+ @reconnect_attempts = Integer(reconnect_attempts)
52
+ raise ArgumentError, "reconnect_attempts must not be negative" if @reconnect_attempts.negative?
53
+
54
+ @ssl = !!ssl
55
+ @ssl_params = Shareable.copy(ssl_params, label: "ssl_params") if ssl_params
56
+ @callbacks = Shareable.copy(callbacks, label: "callbacks") if callbacks
57
+ Ractor.make_shareable(self)
58
+ end
59
+
60
+ def ssl?
61
+ @ssl
62
+ end
63
+
64
+ def sentinel?
65
+ false
66
+ end
67
+
68
+ def resolved?
69
+ true
70
+ end
71
+
72
+ def server_key
73
+ [path, host, port].freeze
74
+ end
75
+
76
+ def server_url
77
+ return "unix://#{path}?db=#{db}" if path
78
+
79
+ scheme = ssl? ? "rediss" : "redis"
80
+ address = host.include?(":") ? "[#{host}]" : host
81
+ "#{scheme}://#{address}:#{port}/#{db}"
82
+ end
83
+
84
+ def new_client(**options)
85
+ Client.new(self, **options)
86
+ end
87
+
88
+ def new_pool(**options)
89
+ Pool.new(self, **options)
90
+ end
91
+
92
+ def notify(event, *arguments)
93
+ return unless @callbacks&.respond_to?(event)
94
+
95
+ @callbacks.respond_with(event, *arguments)
96
+ end
97
+
98
+ def inspect
99
+ "#<#{self.class.name} #{server_url}>"
100
+ end
101
+
102
+ private
103
+
104
+ def parse_url(url)
105
+ uri = URI.parse(url)
106
+ unless %w[redis rediss unix].include?(uri.scheme)
107
+ raise ArgumentError, "Unsupported Redis URL scheme: #{uri.scheme.inspect}"
108
+ end
109
+
110
+ if uri.scheme == "unix"
111
+ {
112
+ path: uri.path,
113
+ username: decoded(uri.user),
114
+ password: decoded(uri.password),
115
+ db: query_db(uri),
116
+ ssl: false,
117
+ }
118
+ else
119
+ {
120
+ host: uri.host,
121
+ port: uri.port || DEFAULT_PORT,
122
+ username: decoded(uri.user),
123
+ password: decoded(uri.password),
124
+ db: path_db(uri) || query_db(uri),
125
+ ssl: uri.scheme == "rediss",
126
+ }
127
+ end
128
+ rescue URI::InvalidURIError => error
129
+ raise ArgumentError, "Invalid Redis URL: #{error.message}", cause: error
130
+ end
131
+
132
+ def path_db(uri)
133
+ return if uri.path.nil? || uri.path.empty? || uri.path == "/"
134
+
135
+ Integer(uri.path.delete_prefix("/"))
136
+ end
137
+
138
+ def query_db(uri)
139
+ return 0 unless uri.query
140
+
141
+ value = URI.decode_www_form(uri.query).to_h["db"]
142
+ value ? Integer(value) : 0
143
+ end
144
+
145
+ def decoded(value)
146
+ URI.decode_www_form_component(value) if value
147
+ end
148
+
149
+ def positive_float(value, name)
150
+ value = Float(value)
151
+ raise ArgumentError, "#{name} must be positive" unless value.positive?
152
+
153
+ value
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidRedis
4
+ class Error < StandardError; end
5
+ class ConnectionError < Error; end
6
+ class TimeoutError < ConnectionError; end
7
+ class ProtocolError < Error; end
8
+ class CommandError < Error; end
9
+ class AuthenticationError < CommandError; end
10
+ class FailoverError < ConnectionError; end
11
+ class CheckoutTimeoutError < TimeoutError; end
12
+ class ClosedError < Error; end
13
+ end