patient_http 1.1.0 → 1.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf333a4e860c7ec2acbabd0b2d99b050a2574fd3fe97e7d9cb10b5ba08868fed
4
- data.tar.gz: ea8fdac974792444d4f82ded653beeb644d39a58a88b2c1d7796029266dd0e39
3
+ metadata.gz: ba06f7676b7d33f9c53adea879761a89d31593f51c3f33b999400d7305fcbf06
4
+ data.tar.gz: ede7b685bbfbdd5049695316f73da55bf0abe203b0a9e1e28eb0bc1da6739f79
5
5
  SHA512:
6
- metadata.gz: 1ca730c46c12d7be338959fbb45b4c0863e7b81fc2c09ec43aa5e27f073cf4e6023df63441ffc7fa4b244e7fe560a26041872fc4013ed8dfaf8dfdd5b9390fb4
7
- data.tar.gz: 4073af1b84aac90f73e41efaded611eff0c46d2521221eedcdbb3dd640269d4fcf2865cb881e88134897825adc0899b022cf9ca041d0ec3a4818c93fade351f3
6
+ metadata.gz: 9271aecb38a8adac4634d6e02f7797472347e1d4d20a154cf52c39daa450789a1ea1511f464d0551172361f4815ab26eeac535d2d72fb483e169be58bd5900df
7
+ data.tar.gz: 379ff51d8e0a3e90f49577c9922c6f58a2caa6ed6e4d36a651f9f0071ea5d4999cf25df926cdaf1165e27a495db1d1e65d208d8c58cb41e3513f5c8210829332
data/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.1.2
8
+
9
+ ### Added
10
+
11
+ - Added `protocol` configuration option to force the HTTP protocol to `:http1` or `:http2` instead of negotiating with the server. Forcing `:http1` also limits the TLS ALPN advertisement to `http/1.1`, which can work around SSL-intercepting proxies that mishandle HTTP/2 negotiation.
12
+
13
+ ### Fixed
14
+
15
+ - `Errno::ECONNABORTED` ("Software caused connection abort") is now treated as a connection error: the pooled client for the host is evicted so the aborted connection is not reused, and `RequestError` classifies it as `:connection` instead of `:unknown`. This error is commonly raised when an SSL-intercepting proxy kills a connection.
16
+
17
+ ## 1.1.1
18
+
19
+ ### Fixed
20
+
21
+ - Connection pools are now closed gracefully inside the reactor during shutdown. Previously the reactor stopped with open pools, causing async-pool to force-cancel each connection pool's background gardener task mid-wait and emit a noisy (but harmless) `ThreadError: Attempt to unlock a mutex which is not locked` warning when the process was killed.
22
+
7
23
  ## 1.1.0
8
24
 
9
25
  ### Added
data/README.md CHANGED
@@ -555,6 +555,12 @@ config = PatientHttp::Configuration.new(
555
555
  # Retries for failed requests (default: 3)
556
556
  retries: 3,
557
557
 
558
+ # Force the HTTP protocol to :http1 or :http2 (default: nil, negotiates with
559
+ # the server, preferring HTTP/2 for HTTPS). Forcing :http1 also limits the TLS
560
+ # ALPN advertisement to http/1.1, which can work around SSL-intercepting
561
+ # proxies that mishandle HTTP/2.
562
+ protocol: nil,
563
+
558
564
  # Logger instance (default: Logger to STDERR at ERROR level)
559
565
  logger: Logger.new($stdout)
560
566
  )
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.1.0
1
+ 1.1.2
@@ -8,7 +8,8 @@ module PatientHttp
8
8
  max_size: config.connection_pool_size,
9
9
  connection_timeout: config.connection_timeout,
10
10
  proxy_url: config.proxy_url,
11
- retries: config.retries
11
+ retries: config.retries,
12
+ protocol: config.protocol
12
13
  )
13
14
  @response_reader = ResponseReader.new(@processor)
14
15
  end
@@ -64,8 +65,8 @@ module PatientHttp
64
65
 
65
66
  def connection_error?(exception)
66
67
  case exception
67
- when Async::TimeoutError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED,
68
- Errno::EHOSTUNREACH, SocketError, IOError
68
+ when Async::TimeoutError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
69
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError, IOError
69
70
  true
70
71
  else
71
72
  false
@@ -7,17 +7,30 @@ module PatientHttp
7
7
  # is capped with an LRU algorithm - when a new client is needed and the
8
8
  # pool is at capacity, the least recently used client is closed and removed.
9
9
  class ClientPool
10
- def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3)
10
+ # Supported protocol names mapped to their async-http implementations. Forcing
11
+ # :http1 also limits the TLS ALPN advertisement to http/1.1, which avoids
12
+ # HTTP/2 negotiation with servers and middleboxes that mishandle it.
13
+ PROTOCOLS = {
14
+ http1: Async::HTTP::Protocol::HTTP11,
15
+ http2: Async::HTTP::Protocol::HTTP2
16
+ }.freeze
17
+
18
+ def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil)
19
+ if protocol && !PROTOCOLS.include?(protocol)
20
+ raise ArgumentError.new("protocol must be one of #{PROTOCOLS.keys.inspect}, got: #{protocol.inspect}")
21
+ end
22
+
11
23
  @clients = {}
12
24
  @max_size = max_size
13
25
  @connection_timeout = connection_timeout
14
26
  @proxy_url = proxy_url
15
27
  @retries = retries
28
+ @protocol = protocol
16
29
  @mutex = Mutex.new
17
30
  @proxy_client = nil
18
31
  end
19
32
 
20
- attr_reader :max_size, :connection_timeout, :proxy_url, :retries
33
+ attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol
21
34
 
22
35
  # Get or create a client for the given endpoint.
23
36
  #
@@ -162,17 +175,19 @@ module PatientHttp
162
175
 
163
176
  def create_proxy_client
164
177
  proxy_endpoint = Async::HTTP::Endpoint.parse(@proxy_url)
165
- proxy_endpoint = configure_endpoint(proxy_endpoint) if @connection_timeout
178
+ if @connection_timeout
179
+ proxy_endpoint = Async::HTTP::Endpoint.new(proxy_endpoint.url, timeout: @connection_timeout)
180
+ end
166
181
  Async::HTTP::Client.new(proxy_endpoint)
167
182
  end
168
183
 
169
184
  def configure_endpoint(endpoint)
170
- return endpoint unless @connection_timeout
185
+ options = {}
186
+ options[:timeout] = @connection_timeout if @connection_timeout
187
+ options[:protocol] = PROTOCOLS.fetch(@protocol) if @protocol
188
+ return endpoint if options.empty?
171
189
 
172
- Async::HTTP::Endpoint.new(
173
- endpoint.url,
174
- timeout: @connection_timeout
175
- )
190
+ Async::HTTP::Endpoint.new(endpoint.url, **options)
176
191
  end
177
192
  end
178
193
  end
@@ -46,6 +46,10 @@ module PatientHttp
46
46
  # @return [Integer] Number of retries for failed requests
47
47
  attr_reader :retries
48
48
 
49
+ # @return [Symbol, nil] HTTP protocol to use (:http1 or :http2). When nil, the
50
+ # protocol is negotiated with the server (HTTP/2 preferred for HTTPS).
51
+ attr_reader :protocol
52
+
49
53
  # @return [SecretManager] the secret manager instance
50
54
  attr_reader :secret_manager
51
55
 
@@ -63,6 +67,7 @@ module PatientHttp
63
67
  # @param connection_timeout [Numeric, nil] Connection timeout in seconds
64
68
  # @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
65
69
  # @param retries [Integer] Number of retries for failed requests
70
+ # @param protocol [Symbol, nil] HTTP protocol to use (:http1 or :http2); nil to negotiate
66
71
  def initialize(
67
72
  max_connections: 256,
68
73
  request_timeout: 60,
@@ -76,6 +81,7 @@ module PatientHttp
76
81
  connection_timeout: nil,
77
82
  proxy_url: nil,
78
83
  retries: 3,
84
+ protocol: nil,
79
85
  encryption_key: nil
80
86
  )
81
87
  @mutex = Mutex.new
@@ -102,6 +108,7 @@ module PatientHttp
102
108
  self.connection_timeout = connection_timeout
103
109
  self.proxy_url = proxy_url
104
110
  self.retries = retries
111
+ self.protocol = protocol
105
112
  self.encryption_key = encryption_key
106
113
  end
107
114
 
@@ -164,6 +171,20 @@ module PatientHttp
164
171
  @retries = value
165
172
  end
166
173
 
174
+ def protocol=(value)
175
+ if value.nil?
176
+ @protocol = nil
177
+ return
178
+ end
179
+
180
+ value = value.to_sym if value.is_a?(String)
181
+ unless ClientPool::PROTOCOLS.key?(value)
182
+ raise ArgumentError.new("protocol must be one of #{ClientPool::PROTOCOLS.keys.inspect}, got: #{value.inspect}")
183
+ end
184
+
185
+ @protocol = value
186
+ end
187
+
167
188
  # Set the encryption callable for encrypting payloads before serialization.
168
189
  #
169
190
  # @param callable [#call, nil] An object that responds to #call, taking data and returning encrypted data
@@ -326,6 +347,7 @@ module PatientHttp
326
347
  "connection_timeout" => connection_timeout,
327
348
  "proxy_url" => proxy_url,
328
349
  "retries" => retries,
350
+ "protocol" => protocol,
329
351
  "payload_stores" => payload_stores.keys,
330
352
  "default_payload_store" => default_payload_store_name,
331
353
  "secrets" => @mutex.synchronize { @secrets.keys }
@@ -328,6 +328,17 @@ module PatientHttp
328
328
  @config.logger&.info("[PatientHttp] Reactor received stop signal")
329
329
  rescue => e
330
330
  @config.logger&.error("[PatientHttp] Reactor loop error: #{e.inspect}\n#{e.backtrace.join("\n")}")
331
+ ensure
332
+ # Close the HTTP connection pools while still inside the reactor so the
333
+ # pools' background gardener tasks shut down gracefully. Otherwise the
334
+ # reactor stops with open pools and async-pool force-cancels each
335
+ # gardener mid-wait, emitting a noisy (but harmless) ThreadError:
336
+ # "Attempt to unlock a mutex which is not locked".
337
+ begin
338
+ @http_client.close
339
+ rescue => e
340
+ @config.logger&.error("[PatientHttp] Error closing HTTP client: #{e.inspect}")
341
+ end
331
342
  end
332
343
  end
333
344
 
@@ -80,7 +80,7 @@ module PatientHttp
80
80
  :timeout
81
81
  in OpenSSL::SSL::SSLError
82
82
  :ssl
83
- in Errno::ECONNREFUSED | Errno::ECONNRESET | Errno::EHOSTUNREACH | Errno::EPIPE | SocketError | IOError
83
+ in Errno::ECONNREFUSED | Errno::ECONNRESET | Errno::ECONNABORTED | Errno::EHOSTUNREACH | Errno::EPIPE | SocketError | IOError
84
84
  :connection
85
85
  else
86
86
  if exception.is_a?(PatientHttp::ResponseTooLargeError)
data/lib/patient_http.rb CHANGED
@@ -307,8 +307,8 @@ module PatientHttp
307
307
  if missing_keywords.any?
308
308
  raise ArgumentError.new(
309
309
  "Handler must accept keyword arguments: " \
310
- "#{required_keywords.map(&:to_s).join(", ")}. " \
311
- "Missing: #{missing_keywords.map(&:to_s).join(", ")}"
310
+ "#{required_keywords.join(", ")}. " \
311
+ "Missing: #{missing_keywords.join(", ")}"
312
312
  )
313
313
  end
314
314
 
@@ -321,7 +321,7 @@ module PatientHttp
321
321
 
322
322
  raise ArgumentError.new(
323
323
  "Handler must not have extra required keyword parameters. " \
324
- "Found: #{extra_required_keywords.map(&:to_s).join(", ")}"
324
+ "Found: #{extra_required_keywords.join(", ")}"
325
325
  )
326
326
  end
327
327
  end
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.1.0
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -156,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
156
  - !ruby/object:Gem::Version
157
157
  version: '0'
158
158
  requirements: []
159
- rubygems_version: 4.0.3
159
+ rubygems_version: 3.6.9
160
160
  specification_version: 4
161
161
  summary: Generic async HTTP connection pool for Ruby applications using Fiber-based
162
162
  concurrency