patient_http 1.4.0 → 1.6.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.
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Reads and validates HTTP response bodies.
4
+ # Reads and decodes HTTP response bodies.
5
5
  #
6
- # Encapsulates the logic for reading async HTTP responses with size validation
7
- # and building Response objects from the raw response data.
6
+ # Reading happens on the reactor thread and collects the raw (possibly
7
+ # compressed) body chunks with size validation. Decoding joining the
8
+ # chunks, inflating compressed content, and applying the charset — is a
9
+ # separate step so it can run on a completion worker thread instead of
10
+ # blocking the event loop.
8
11
  class ResponseReader
9
12
  # Raised when a body read is aborted because the processor was stopped
10
13
  # past its shutdown deadline. The shutdown sequence re-enqueues the task,
@@ -13,41 +16,210 @@ module PatientHttp
13
16
  # @api private
14
17
  class ReadAbortedError < StandardError; end
15
18
 
19
+ # Content encodings that are inflated during decoding, mapped to the window
20
+ # bits of each wire format the encoding may arrive in. Formats are tried in
21
+ # order until one inflates the body.
22
+ #
23
+ # A "deflate" body should carry a zlib header (RFC 9110 specifies the zlib
24
+ # format), but some servers send a bare deflate stream instead, so the raw
25
+ # format is kept as a fallback.
26
+ INFLATE_WINDOW_BITS = {
27
+ "gzip" => [Zlib::MAX_WBITS | 16].freeze,
28
+ "deflate" => [Zlib::MAX_WBITS, -Zlib::MAX_WBITS].freeze
29
+ }.freeze
30
+
31
+ # Content encoding that means the body was not encoded at all. It needs no
32
+ # work to decode, but it still has to be recognized so it does not stop the
33
+ # decode of the encodings applied before it.
34
+ IDENTITY_ENCODING = "identity"
35
+
36
+ class << self
37
+ # Split the encodings named in the content-encoding header into the ones
38
+ # that stay applied to the body and the ones that can be decoded.
39
+ #
40
+ # A body can carry more than one encoding. They are listed in the order
41
+ # they were applied, so decoding runs from the last name backwards and
42
+ # stops at the first name it does not recognize. Everything before that
43
+ # point stays applied to the body.
44
+ #
45
+ # @param headers_hash [Hash] the response headers
46
+ # @return [Array(Array<String>, Array<String>)] the encodings that remain
47
+ # applied and the encodings that can be decoded, both in applied order
48
+ def split_encodings(headers_hash)
49
+ encodings = content_encodings(headers_hash)
50
+ boundary = encodings.rindex { |name| !decodable?(name) }
51
+ return [[], encodings] if boundary.nil?
52
+
53
+ [encodings[0..boundary], encodings[(boundary + 1)..]]
54
+ end
55
+
56
+ # Parse the content-encoding header into encoding names.
57
+ #
58
+ # @param headers_hash [Hash] the response headers
59
+ # @return [Array<String>] the lowercased encoding names in applied order
60
+ def content_encodings(headers_hash)
61
+ headers_hash["content-encoding"].to_s.split(",").filter_map do |name|
62
+ name = name.strip.downcase
63
+ name unless name.empty?
64
+ end
65
+ end
66
+
67
+ # @param name [String] a lowercased content encoding name
68
+ # @return [Boolean] true if the reader can remove this encoding
69
+ def decodable?(name)
70
+ name == IDENTITY_ENCODING || INFLATE_WINDOW_BITS.key?(name)
71
+ end
72
+
73
+ # Restate the content-encoding header for a decoded body. The header is
74
+ # removed when nothing is left applied, and narrowed to the encodings the
75
+ # reader could not remove otherwise, so the header always describes the
76
+ # body delivered with it.
77
+ #
78
+ # @param headers_hash [Hash] the response headers
79
+ # @return [Hash] the headers with content-encoding updated or removed
80
+ def rewrite_content_encoding(headers_hash)
81
+ return headers_hash unless headers_hash.key?("content-encoding")
82
+
83
+ remaining, _decodable = split_encodings(headers_hash)
84
+
85
+ if remaining.empty?
86
+ headers_hash.except("content-encoding")
87
+ else
88
+ headers_hash.merge("content-encoding" => remaining.join(", "))
89
+ end
90
+ end
91
+ end
92
+
16
93
  # Initialize the reader.
17
94
  #
18
- # @param processor [Processor] the processor object
19
- def initialize(processor)
95
+ # Reading needs a processor so it can abort once the processor is past its
96
+ # shutdown deadline. Decoding needs only the configuration, so a caller
97
+ # that does its own reading can supply the configuration on its own.
98
+ #
99
+ # @param processor [Processor, nil] the processor object
100
+ # @param config [Configuration, nil] the configuration; defaults to the
101
+ # processor's configuration
102
+ def initialize(processor, config: nil)
20
103
  @processor = processor
104
+ @config = config || processor.config
21
105
  end
22
106
 
23
- # Read the response body with size validation.
107
+ # Read the raw response body chunks with size validation.
24
108
  #
25
109
  # Reads the async HTTP response body asynchronously to completion, which allows
26
110
  # the connection to be reused. The async-http client handles connection pooling
27
111
  # and keep-alive internally. Using iteration instead of read() ensures non-blocking
28
- # I/O that yields to the reactor.
112
+ # I/O that yields to the reactor. The chunks are the wire bytes: when the
113
+ # response is compressed, the size check here applies to the compressed
114
+ # bytes and {#decode_body} applies the same limit to the inflated bytes.
115
+ #
116
+ # The Content-Length header is checked against the size limit before the
117
+ # read starts. A response to a HEAD request has an empty body but reports
118
+ # the Content-Length of the resource, so the header check is skipped when
119
+ # the body reports itself as empty.
29
120
  #
30
121
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
31
122
  # @param headers_hash [Hash] the response headers
32
- # @return [String, nil] the response body or nil if no body present
33
- # @raise [ResponseTooLargeError] if body exceeds max_response_size
123
+ # @return [Array<String>, nil] the raw body chunks or nil if no body present
124
+ # @raise [ResponseTooLargeError] if the body exceeds max_response_size
34
125
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
35
- def read_body(async_response, headers_hash)
36
- return nil unless async_response.body
126
+ def read_raw_body(async_response, headers_hash)
127
+ body = async_response.body
128
+ return nil unless body
129
+
130
+ validate_content_length(headers_hash) unless body.empty?
131
+ read_body_chunks(async_response)
132
+ end
133
+
134
+ # Decode raw body chunks into the final body string.
135
+ #
136
+ # Joins the chunks, inflates gzip/deflate content (enforcing
137
+ # max_response_size on the inflated bytes), and applies the charset from
138
+ # the Content-Type header. This is CPU-bound work intended to run on a
139
+ # completion worker thread.
140
+ #
141
+ # An encoding the reader does not support leaves the body encoded, and a
142
+ # body that is still encoded keeps its binary encoding because the charset
143
+ # does not describe it. Use {.split_encodings} to find what stays applied
144
+ # so the content-encoding header delivered with the response describes the
145
+ # body it carries.
146
+ #
147
+ # @param chunks [Array<String>, nil] the raw body chunks
148
+ # @param headers_hash [Hash] the response headers
149
+ # @return [String, nil] the decoded body or nil if there was no body
150
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
151
+ def decode_body(chunks, headers_hash)
152
+ return nil if chunks.nil?
153
+
154
+ remaining, decodable = self.class.split_encodings(headers_hash)
155
+ warn_undecodable(remaining) unless remaining.empty?
156
+
157
+ body = inflate_encodings(chunks, decodable).join
158
+ body.force_encoding(Encoding::ASCII_8BIT)
159
+ # A body that is still encoded is not text yet, so the charset does not
160
+ # describe its bytes. Leave it binary for the caller to decode.
161
+ return body unless remaining.empty?
37
162
 
38
- validate_content_length(headers_hash)
39
- body = read_body_chunks(async_response)
40
163
  apply_charset_encoding(body, headers_hash)
41
164
  end
42
165
 
43
166
  private
44
167
 
168
+ # Remove the given encodings from the body, starting with the one applied
169
+ # last. Identity needs no work; every other name here inflates.
170
+ #
171
+ # @param chunks [Array<String>] the encoded body chunks
172
+ # @param encodings [Array<String>] decodable encoding names in applied order
173
+ # @return [Array<String>] the decoded chunks
174
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
175
+ def inflate_encodings(chunks, encodings)
176
+ encodings.reverse_each do |name|
177
+ chunks = [inflate_encoding(chunks, name)] if INFLATE_WINDOW_BITS.key?(name)
178
+ end
179
+
180
+ chunks
181
+ end
182
+
183
+ # Inflate one encoding, trying each wire format the encoding can use. The
184
+ # chunks are all in memory, so a format that turns out to be wrong can be
185
+ # abandoned and the next one started from the beginning of the body.
186
+ #
187
+ # @param chunks [Array<String>] the encoded body chunks
188
+ # @param name [String] a lowercased content encoding name
189
+ # @return [String] the inflated body
190
+ # @raise [Zlib::Error] if no format could inflate the body
191
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
192
+ def inflate_encoding(chunks, name)
193
+ formats = INFLATE_WINDOW_BITS.fetch(name)
194
+ last_index = formats.size - 1
195
+
196
+ formats.each_with_index do |window_bits, index|
197
+ return inflate_chunks(chunks, window_bits)
198
+ rescue Zlib::DataError, Zlib::BufError
199
+ raise if index == last_index
200
+ end
201
+ end
202
+
203
+ # Report an encoding that could not be removed. The body is still delivered
204
+ # with its content-encoding header, so the caller can decode it, but the
205
+ # server ignored the accept-encoding header and that is worth recording.
206
+ #
207
+ # @param remaining [Array<String>] the encodings left on the body
208
+ # @return [void]
209
+ def warn_undecodable(remaining)
210
+ logger&.warn(
211
+ "[PatientHttp] Cannot decode response body with content-encoding " \
212
+ "'#{remaining.join(", ")}'; returning the encoded body"
213
+ )
214
+ nil
215
+ end
216
+
45
217
  def max_response_size
46
- @processor.config.max_response_size
218
+ @config.max_response_size
47
219
  end
48
220
 
49
221
  def logger
50
- @processor.config.logger
222
+ @config.logger
51
223
  end
52
224
 
53
225
  # Validate content-length header doesn't exceed max size.
@@ -66,7 +238,7 @@ module PatientHttp
66
238
  # Read body chunks while checking size.
67
239
  #
68
240
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
69
- # @return [String] the response body in ASCII-8BIT encoding
241
+ # @return [Array<String>] the raw body chunks
70
242
  # @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
71
243
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
72
244
  def read_body_chunks(async_response)
@@ -80,7 +252,7 @@ module PatientHttp
80
252
  # Reads are allowed to finish while the processor is merely stopping
81
253
  # (the graceful shutdown window) so in-flight responses can still be
82
254
  # delivered.
83
- if @processor.stopped?
255
+ if @processor&.stopped?
84
256
  raise ReadAbortedError.new("Processor stopped while reading response body")
85
257
  end
86
258
 
@@ -97,8 +269,7 @@ module PatientHttp
97
269
 
98
270
  finished = true
99
271
 
100
- # Join chunks and force to binary encoding to preserve raw bytes
101
- chunks.join.force_encoding(Encoding::ASCII_8BIT)
272
+ chunks
102
273
  ensure
103
274
  # Always close the body if we were interrupted or if an error occurred
104
275
  # This ensures the connection is properly released back to the pool
@@ -106,6 +277,49 @@ module PatientHttp
106
277
  end
107
278
  end
108
279
 
280
+ # Inflate compressed body chunks with streaming size enforcement, so a
281
+ # small compressed body cannot expand past max_response_size.
282
+ #
283
+ # @param chunks [Array<String>] the raw compressed chunks
284
+ # @param window_bits [Integer] Zlib window bits for the content encoding
285
+ # @return [String] the inflated body
286
+ # @raise [ResponseTooLargeError] if the inflated size exceeds max_response_size
287
+ def inflate_chunks(chunks, window_bits)
288
+ # A response can declare a content encoding and still carry no body.
289
+ # There is nothing to inflate, and finishing an empty stream would
290
+ # raise a buffer error.
291
+ return +"" if chunks.all?(&:empty?)
292
+
293
+ inflater = Zlib::Inflate.new(window_bits)
294
+ body = +""
295
+
296
+ begin
297
+ # The block form yields the inflated output in buffer-sized pieces, so
298
+ # the size is checked before the whole expansion is materialized. A
299
+ # single small compressed chunk can otherwise inflate to gigabytes
300
+ # before any check runs.
301
+ appender = ->(output) do
302
+ body << output
303
+ validate_inflated_size(body)
304
+ end
305
+
306
+ chunks.each { |chunk| inflater.inflate(chunk, &appender) }
307
+ inflater.finish(&appender) unless inflater.finished?
308
+ ensure
309
+ inflater.close
310
+ end
311
+
312
+ body
313
+ end
314
+
315
+ def validate_inflated_size(body)
316
+ if body.bytesize > max_response_size
317
+ raise ResponseTooLargeError.new(
318
+ "Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
319
+ )
320
+ end
321
+ end
322
+
109
323
  # Extract charset from Content-Type header.
110
324
  #
111
325
  # @param headers_hash [Hash] the response headers
@@ -20,6 +20,7 @@ module PatientHttp
20
20
  @on_error = on_error
21
21
  @proxy_client = nil
22
22
  @request_preparer = RequestPreparer.new(config)
23
+ @response_reader = ResponseReader.new(nil, config: config)
23
24
  end
24
25
 
25
26
  # Execute the request synchronously.
@@ -62,11 +63,12 @@ module PatientHttp
62
63
  # flattened to a single joined string value.
63
64
  headers_hash = async_response.headers.to_h.transform_values(&:to_s)
64
65
 
65
- body_content = read_response_body(async_response, headers_hash)
66
+ chunks = @response_reader.read_raw_body(async_response, headers_hash)
67
+ body_content = @response_reader.decode_body(chunks, headers_hash)
66
68
 
67
69
  {
68
70
  status: async_response.status,
69
- headers: headers_hash,
71
+ headers: ResponseReader.rewrite_content_encoding(headers_hash),
70
72
  body: body_content
71
73
  }
72
74
  end
@@ -79,8 +81,7 @@ module PatientHttp
79
81
  redirect_error = check_redirect_error(@task, response_data)
80
82
  break if redirect_error
81
83
 
82
- location = response_data[:headers]["location"]
83
- @task = @task.redirect_task(location: location, status: response_data[:status])
84
+ @task = build_redirect_task(@task, response_data)
84
85
  end
85
86
 
86
87
  if redirect_error
@@ -133,19 +134,22 @@ module PatientHttp
133
134
 
134
135
  # Create HTTP client with config settings (retries, proxy, connection timeout).
135
136
  #
137
+ # The client is not wrapped in a Protocol::HTTP::AcceptEncoding middleware.
138
+ # That wrapper overwrites the request's accept-encoding header, which would
139
+ # ignore a caller opting out of compression, so response bodies are decoded
140
+ # by ResponseReader here exactly as they are on the async path.
141
+ #
136
142
  # @param url [String] the resolved request URL
137
- # @return [Protocol::HTTP::AcceptEncoding] wrapped HTTP client
143
+ # @return [Async::HTTP::Client] the HTTP client
138
144
  def create_http_client(url)
139
145
  endpoint = Async::HTTP::Endpoint.parse(url)
140
146
  endpoint = configure_endpoint(endpoint) if @config.connection_timeout
141
147
 
142
- client = if @config.proxy_url
148
+ if @config.proxy_url
143
149
  create_proxied_client(endpoint)
144
150
  else
145
151
  Async::HTTP::Client.new(endpoint, retries: @config.retries)
146
152
  end
147
-
148
- Protocol::HTTP::AcceptEncoding.new(client)
149
153
  end
150
154
 
151
155
  # Create a proxied HTTP client.
@@ -174,69 +178,6 @@ module PatientHttp
174
178
  )
175
179
  end
176
180
 
177
- # Read the response body with size validation.
178
- #
179
- # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
180
- # @param headers_hash [Hash] the response headers
181
- # @return [String, nil] the response body
182
- def read_response_body(async_response, headers_hash)
183
- return nil unless async_response.body
184
-
185
- content_length = headers_hash["content-length"]&.to_i
186
- if content_length && content_length > @config.max_response_size
187
- raise ResponseTooLargeError.new(
188
- "Response body size (#{content_length} bytes) exceeds maximum allowed size (#{@config.max_response_size} bytes)"
189
- )
190
- end
191
-
192
- chunks = []
193
- total_size = 0
194
- finished = false
195
-
196
- begin
197
- async_response.body.each do |chunk|
198
- total_size += chunk.bytesize
199
- if total_size > @config.max_response_size
200
- raise ResponseTooLargeError.new(
201
- "Response body size exceeded maximum allowed size (#{@config.max_response_size} bytes)"
202
- )
203
- end
204
- chunks << chunk
205
- end
206
-
207
- finished = true
208
- ensure
209
- # Close the body if the read was interrupted so the connection is released
210
- async_response.body.close unless finished
211
- end
212
-
213
- body = chunks.join.force_encoding(Encoding::ASCII_8BIT)
214
-
215
- charset = extract_charset(headers_hash)
216
- if charset
217
- begin
218
- encoding = Encoding.find(charset)
219
- body.force_encoding(encoding)
220
- rescue ArgumentError
221
- # Invalid charset, keep binary
222
- end
223
- end
224
-
225
- body
226
- end
227
-
228
- # Extract charset from Content-Type header.
229
- def extract_charset(headers_hash)
230
- content_type = headers_hash["content-type"]
231
- return nil unless content_type
232
-
233
- match = content_type.match(/;\s*charset\s*=\s*([^;\s]+)/i)
234
- return nil unless match
235
-
236
- charset = match[1].strip
237
- charset.gsub(/\A["']|["']\z/, "")
238
- end
239
-
240
181
  # Invoke callback synchronously.
241
182
  #
242
183
  # @param result [Response, Error] the result to pass to callback
data/lib/patient_http.rb CHANGED
@@ -30,8 +30,15 @@ module PatientHttp
30
30
 
31
31
  class ResponseTooLargeError < StandardError; end
32
32
 
33
- # HTTP redirect status codes that should be followed
34
- FOLLOWABLE_REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
33
+ # Raised when a request names a processor that is not configured. Handlers
34
+ # that support named processors raise this at enqueue time; the executing
35
+ # side raises it for a job that names an unconfigured processor so the job
36
+ # lands in the job system's retry mechanism instead of being dropped.
37
+ class UnknownProcessorError < StandardError; end
38
+
39
+ # HTTP redirect status codes that are followed when a Location header is present.
40
+ # A 300 response is followed only when the server names a preferred choice in Location.
41
+ FOLLOWABLE_REDIRECT_STATUSES = [300, 301, 302, 303, 307, 308].freeze
35
42
 
36
43
  VERSION = File.read(File.join(__dir__, "../VERSION")).strip
37
44
 
@@ -45,6 +52,7 @@ module PatientHttp
45
52
  autoload :Client, File.join(__dir__, "patient_http/client")
46
53
  autoload :ClientError, File.join(__dir__, "patient_http/http_error")
47
54
  autoload :ClientPool, File.join(__dir__, "patient_http/client_pool")
55
+ autoload :CompletionExecutor, File.join(__dir__, "patient_http/completion_executor")
48
56
  autoload :Configuration, File.join(__dir__, "patient_http/configuration")
49
57
  autoload :Encryptor, File.join(__dir__, "patient_http/encryptor")
50
58
  autoload :Error, File.join(__dir__, "patient_http/error")
@@ -268,6 +276,16 @@ module PatientHttp
268
276
  request(:get, uri, callback: callback, **kwargs)
269
277
  end
270
278
 
279
+ # Enqueues an HTTP HEAD request.
280
+ #
281
+ # @param uri [String] absolute URL
282
+ # @param callback [Class, String] callback class to handle the response
283
+ # @param kwargs [Hash] forwarded to `request`
284
+ # @return [Object] return value from the registered request handler
285
+ def head(uri, callback:, **kwargs)
286
+ request(:head, uri, callback: callback, **kwargs)
287
+ end
288
+
271
289
  # Enqueues an HTTP POST request.
272
290
  #
273
291
  # @param uri [String] absolute URL
@@ -308,9 +326,19 @@ module PatientHttp
308
326
  request(:delete, uri, callback: callback, **kwargs)
309
327
  end
310
328
 
329
+ # Enqueues an HTTP QUERY request.
330
+ #
331
+ # @param uri [String] absolute URL
332
+ # @param callback [Class, String] callback class to handle the response
333
+ # @param kwargs [Hash] forwarded to `request`
334
+ # @return [Object] return value from the registered request handler
335
+ def query(uri, callback:, **kwargs)
336
+ request(:query, uri, callback: callback, **kwargs)
337
+ end
338
+
311
339
  # Builds and dispatches an HTTP request.
312
340
  #
313
- # @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
341
+ # @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
314
342
  # @param url [String] absolute URL
315
343
  # @param callback [Class, String] callback class to handle the response
316
344
  # @param headers [Hash, nil] request headers
@@ -321,8 +349,16 @@ module PatientHttp
321
349
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
322
350
  # reported as errors
323
351
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
352
+ # @param max_redirects [Integer, nil] maximum redirects to follow (nil uses the configuration
353
+ # default, 0 disables redirects)
354
+ # @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
355
+ # HTTP method (nil uses the configuration default)
356
+ # @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
357
+ # to strip from redirected requests, in addition to the configured names
324
358
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
325
359
  # registered on the configuration to apply to the request when it is sent
360
+ # @param processor [String, Symbol, nil] name of the processor that should execute
361
+ # the request; handlers that support named processors route on this value
326
362
  # @return [Object] return value from the registered request handler
327
363
  def request(
328
364
  method,
@@ -335,7 +371,11 @@ module PatientHttp
335
371
  timeout: nil,
336
372
  raise_error_responses: nil,
337
373
  callback_args: nil,
338
- preprocessors: nil
374
+ max_redirects: nil,
375
+ follow_method_changing_redirects: nil,
376
+ redirect_strip_headers: nil,
377
+ preprocessors: nil,
378
+ processor: nil
339
379
  )
340
380
  request = Request.new(
341
381
  method,
@@ -345,7 +385,11 @@ module PatientHttp
345
385
  headers: headers,
346
386
  params: params,
347
387
  timeout: timeout,
348
- preprocessors: preprocessors
388
+ max_redirects: max_redirects,
389
+ follow_method_changing_redirects: follow_method_changing_redirects,
390
+ redirect_strip_headers: redirect_strip_headers,
391
+ preprocessors: preprocessors,
392
+ processor: processor
349
393
  )
350
394
  execute(
351
395
  request: request,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -89,6 +89,7 @@ files:
89
89
  - lib/patient_http/class_helper.rb
90
90
  - lib/patient_http/client.rb
91
91
  - lib/patient_http/client_pool.rb
92
+ - lib/patient_http/completion_executor.rb
92
93
  - lib/patient_http/configuration.rb
93
94
  - lib/patient_http/encryptor.rb
94
95
  - lib/patient_http/error.rb
@@ -145,7 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
145
146
  - !ruby/object:Gem::Version
146
147
  version: '0'
147
148
  requirements: []
148
- rubygems_version: 3.6.9
149
+ rubygems_version: 4.0.3
149
150
  specification_version: 4
150
151
  summary: Generic async HTTP connection pool for Ruby applications using Fiber-based
151
152
  concurrency