patient_http 1.6.1 → 1.8.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +18 -8
  3. data/CHANGELOG.md +43 -0
  4. data/README.md +564 -508
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +53 -26
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +405 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +35 -29
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +117 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +178 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +115 -84
  40. data/lib/patient_http/request_template.rb +119 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +166 -118
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +84 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +327 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Pool of HTTP clients with LRU eviction.
4
+ # A pool of HTTP clients, one for each host.
5
5
  #
6
- # Maintains a pool of clients lazily instantiated for each host. The pool
7
- # is capped with an LRU algorithm - when a new client is needed and the
8
- # pool is at capacity, the least recently used client is closed and removed.
6
+ # A client is created when a host first needs one. When the pool is full and
7
+ # a new client is needed, the least recently used client is closed and
8
+ # removed.
9
+ #
10
+ # @api private
9
11
  class ClientPool
10
12
  # Supported protocol names mapped to their async-http implementations. Forcing
11
13
  # :http1 also limits the TLS ALPN advertisement to http/1.1, which avoids
@@ -15,7 +17,40 @@ module PatientHttp
15
17
  http2: Async::HTTP::Protocol::HTTP2
16
18
  }.freeze
17
19
 
18
- def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil, connection_limit: nil)
20
+ class << self
21
+ # Builds a pool with the connection settings from a configuration.
22
+ #
23
+ # @param config [Configuration] The configuration to read settings from.
24
+ # @return [ClientPool] The new pool.
25
+ def from_config(config)
26
+ new(
27
+ max_size: config.connection_pool_size,
28
+ connection_timeout: config.connection_timeout,
29
+ proxy_url: config.proxy_url,
30
+ retries: config.retries,
31
+ protocol: config.protocol,
32
+ connection_limit: config.max_connections_per_host,
33
+ tcp_keepalive: config.tcp_keepalive,
34
+ tcp_user_timeout: config.tcp_user_timeout
35
+ )
36
+ end
37
+ end
38
+
39
+ # Creates a client pool.
40
+ #
41
+ # @param max_size [Integer] The maximum number of host clients in the pool.
42
+ # @param connection_timeout [Numeric, nil] The timeout in seconds to open a
43
+ # connection.
44
+ # @param proxy_url [String, nil] The HTTP or HTTPS proxy URL.
45
+ # @param retries [Integer] The number of retries for failed requests.
46
+ # @param protocol [Symbol, nil] The HTTP protocol: `:http1` or `:http2`.
47
+ # @param connection_limit [Integer, nil] The maximum number of connections
48
+ # to each host.
49
+ # @param tcp_keepalive [Integer, Hash, nil] The TCP keepalive settings.
50
+ # @param tcp_user_timeout [Numeric, nil] The TCP user timeout in seconds.
51
+ # @raise [ArgumentError] If the protocol isn't supported.
52
+ def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil,
53
+ connection_limit: nil, tcp_keepalive: nil, tcp_user_timeout: nil)
19
54
  if protocol && !PROTOCOLS.include?(protocol)
20
55
  raise ArgumentError.new("protocol must be one of #{PROTOCOLS.keys.inspect}, got: #{protocol.inspect}")
21
56
  end
@@ -23,20 +58,26 @@ module PatientHttp
23
58
  @clients = {}
24
59
  @max_size = max_size
25
60
  @connection_timeout = connection_timeout
61
+ @tcp_keepalive = tcp_keepalive.is_a?(Numeric) ? {idle: tcp_keepalive} : tcp_keepalive
62
+ @tcp_user_timeout = tcp_user_timeout
26
63
  @proxy_url = proxy_url
27
64
  @retries = retries
28
65
  @protocol = protocol
29
66
  @connection_limit = connection_limit
30
67
  @mutex = Mutex.new
31
68
  @proxy_client = nil
69
+ @closing_tasks = []
70
+ @closing_mutex = Mutex.new
32
71
  end
33
72
 
34
- attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol, :connection_limit
73
+ # @return [Object] The connection settings that the pool was created with.
74
+ attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol, :connection_limit,
75
+ :tcp_keepalive, :tcp_user_timeout
35
76
 
36
- # Get or create a client for the given endpoint.
77
+ # Returns or creates a client for the given endpoint.
37
78
  #
38
- # @param endpoint [Async::HTTP::Endpoint] the target endpoint
39
- # @return [Async::HTTP::Client] the client for the endpoint's host
79
+ # @param endpoint [Async::HTTP::Endpoint] The target endpoint.
80
+ # @return [Async::HTTP::Client] The client for the endpoint's host.
40
81
  def client_for(endpoint)
41
82
  key = host_key(endpoint)
42
83
 
@@ -53,17 +94,20 @@ module PatientHttp
53
94
  end
54
95
  end
55
96
 
56
- # Make a request.
97
+ # Makes a request.
57
98
  #
58
- # @param http_method [String, Symbol] HTTP method
59
- # @param url [String] request URL
60
- # @param headers [Hash] request headers
61
- # @param body [String, nil] request body
62
- # @param block [Proc] optional block to process the response
63
- # @return [Protocol::HTTP::Response] the response
64
- def request(http_method, url, headers, body, &block)
65
- endpoint = Async::HTTP::Endpoint.parse(url)
66
- client = client_for(endpoint)
99
+ # @param http_method [String, Symbol] HTTP method.
100
+ # @param url [String, Async::HTTP::Endpoint] Request URL, or the endpoint
101
+ # already parsed from it.
102
+ # @param headers [Hash] Request headers.
103
+ # @param body [String, nil] Request body.
104
+ # @param client [Async::HTTP::Client, nil] The pooled client to send through,
105
+ # normally the one {#client_for} returned for the URL; nil looks it up.
106
+ # @param block [Proc] Optional block to process the response.
107
+ # @return [Protocol::HTTP::Response] The response.
108
+ def request(http_method, url, headers, body, client: nil, &block)
109
+ endpoint = url.is_a?(Async::HTTP::Endpoint) ? url : Async::HTTP::Endpoint.parse(url)
110
+ client ||= client_for(endpoint)
67
111
 
68
112
  verb = http_method.to_s.upcase
69
113
 
@@ -86,7 +130,10 @@ module PatientHttp
86
130
  end
87
131
  end
88
132
 
89
- # Close all clients and release resources.
133
+ # Closes all clients and releases their resources.
134
+ #
135
+ # Clients evicted earlier whose close is still waiting for their in-flight
136
+ # requests are waited on as well, so no connection outlives the pool.
90
137
  #
91
138
  # @return [void]
92
139
  def close
@@ -105,29 +152,39 @@ module PatientHttp
105
152
  end
106
153
  @proxy_client = nil
107
154
  end
155
+
156
+ pending = @closing_mutex.synchronize { @closing_tasks.dup }
157
+ pending.each do |task|
158
+ task.wait
159
+ rescue StandardError, Async::Stop
160
+ nil
161
+ end
108
162
  end
109
163
 
110
- # Evict and close the client for the given URL.
164
+ # Evicts and closes the client for the host of a URL.
111
165
  #
112
166
  # This forces a new connection to be established on the next request to this host.
167
+ # When the client that failed is given, only that client is evicted: a
168
+ # replacement installed for the host after an earlier eviction is left alone,
169
+ # so a late failure on the old client cannot discard a healthy new one.
113
170
  #
114
- # @param url [String] the request URL whose host client should be evicted
171
+ # @param url [String] The request URL whose host client should be evicted.
172
+ # @param client [Async::HTTP::Client, nil] The client that failed, or nil to
173
+ # evict whichever client the pool currently holds for the host.
115
174
  # @return [void]
116
- def evict(url)
175
+ def evict(url, client = nil)
117
176
  endpoint = Async::HTTP::Endpoint.parse(url)
118
177
  key = host_key(endpoint)
119
178
 
120
- @mutex.synchronize do
121
- client = @clients.delete(key)
122
- begin
123
- client&.close
124
- rescue
125
- nil
179
+ evicted = @mutex.synchronize do
180
+ if client.nil? || @clients[key].equal?(client)
181
+ @clients.delete(key)
126
182
  end
127
183
  end
184
+ close_later(evicted) if evicted
128
185
  end
129
186
 
130
- # @return [Integer] number of clients in the pool
187
+ # @return [Integer] Number of clients in the pool.
131
188
  def size
132
189
  @mutex.synchronize { @clients.size }
133
190
  end
@@ -139,10 +196,29 @@ module PatientHttp
139
196
  return unless lru_key
140
197
 
141
198
  @clients.delete(lru_key)
142
- begin
143
- lru_client.close
199
+ close_later(lru_client)
200
+ end
201
+
202
+ # Closing a client waits for its in-flight requests to finish before closing
203
+ # their connections. Evictions run on a request task while other requests are
204
+ # waiting to be dispatched, so the close runs in its own task and neither the
205
+ # evicting request nor the pool mutex waits for it. Outside a reactor the
206
+ # block runs inline.
207
+ #
208
+ # The task is transient so it does not hold the evicting request's task
209
+ # open, and it is tracked so {#close} can wait for it. The tracking list has
210
+ # its own mutex because evictions spawn the task while holding the pool mutex.
211
+ def close_later(client)
212
+ task = Async(transient: true) do |current|
213
+ client.close
144
214
  rescue
145
215
  nil
216
+ ensure
217
+ @closing_mutex.synchronize { @closing_tasks.delete(current) }
218
+ end
219
+
220
+ unless task.finished?
221
+ @closing_mutex.synchronize { @closing_tasks << task }
146
222
  end
147
223
  end
148
224
 
@@ -158,12 +234,16 @@ module PatientHttp
158
234
  # Response bodies are decoded by ResponseReader on a completion worker
159
235
  # thread instead of a Protocol::HTTP::AcceptEncoding wrapper, so the
160
236
  # reactor thread never pays for inflating compressed bodies.
237
+ #
238
+ # Each client makes a single attempt per request. Retries, bounded by the
239
+ # pool's retries setting, are applied by ImmediateRetries so that only
240
+ # one layer decides when a request is sent again.
161
241
  @proxy_url ? make_proxied_client(endpoint) : make_direct_client(endpoint)
162
242
  end
163
243
 
164
244
  def make_direct_client(endpoint)
165
- configured_endpoint = configure_endpoint(endpoint)
166
- Async::HTTP::Client.new(configured_endpoint, retries: @retries, **client_options)
245
+ configured_endpoint = connectable_endpoint(configure_endpoint(endpoint))
246
+ Async::HTTP::Client.new(configured_endpoint, retries: 1, **client_options)
167
247
  end
168
248
 
169
249
  def make_proxied_client(endpoint)
@@ -173,7 +253,8 @@ module PatientHttp
173
253
  configured_endpoint = configure_endpoint(endpoint)
174
254
 
175
255
  proxy = @proxy_client.proxy(configured_endpoint)
176
- Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries, **client_options)
256
+ tunneled_endpoint = connectable_endpoint(proxy.wrap_endpoint(configured_endpoint))
257
+ Async::HTTP::Client.new(tunneled_endpoint, retries: 1, **client_options)
177
258
  end
178
259
 
179
260
  def client_options
@@ -182,15 +263,29 @@ module PatientHttp
182
263
 
183
264
  def create_proxy_client
184
265
  proxy_endpoint = Async::HTTP::Endpoint.parse(@proxy_url)
185
- if @connection_timeout
186
- proxy_endpoint = Async::HTTP::Endpoint.new(proxy_endpoint.url, timeout: @connection_timeout)
266
+ Async::HTTP::Client.new(connectable_endpoint(proxy_endpoint))
267
+ end
268
+
269
+ # The connection timeout is enforced by the wrapper around establishing the
270
+ # connection rather than passed to the endpoint, which would set it as an IO
271
+ # timeout on every read and write for the life of the connection. The
272
+ # wrapper also applies the TCP keepalive and user timeout settings to each
273
+ # new socket.
274
+ def connectable_endpoint(endpoint)
275
+ unless @connection_timeout || @tcp_keepalive || @tcp_user_timeout
276
+ return endpoint
187
277
  end
188
- Async::HTTP::Client.new(proxy_endpoint)
278
+
279
+ ConnectionEndpoint.new(
280
+ endpoint,
281
+ connection_timeout: @connection_timeout,
282
+ tcp_keepalive: @tcp_keepalive,
283
+ tcp_user_timeout: @tcp_user_timeout
284
+ )
189
285
  end
190
286
 
191
287
  def configure_endpoint(endpoint)
192
288
  options = {}
193
- options[:timeout] = @connection_timeout if @connection_timeout
194
289
  options[:protocol] = PROTOCOLS.fetch(@protocol) if @protocol
195
290
  return endpoint if options.empty?
196
291
 
@@ -10,13 +10,13 @@ module PatientHttp
10
10
  #
11
11
  # @api private
12
12
  class CompletionExecutor
13
- # Initialize the executor and start its worker threads.
13
+ # Creates the executor and starts its worker threads.
14
14
  #
15
- # @param threads [Integer] number of worker threads
16
- # @param logger [Logger, nil] logger for unexpected job errors
17
- # @param thread_name_prefix [String] prefix for worker thread names
18
- # @param on_finished [#call, nil] invoked after each job completes, outside
19
- # any executor lock, so the owner can re-check idle conditions
15
+ # @param threads [Integer] Number of worker threads.
16
+ # @param logger [Logger, nil] Logger for unexpected job errors.
17
+ # @param thread_name_prefix [String] Prefix for worker thread names.
18
+ # @param on_finished [#call, nil] Invoked after each job completes, outside
19
+ # any executor lock, so the owner can re-check idle conditions.
20
20
  def initialize(threads:, logger: nil, thread_name_prefix: "patient-http-completion", on_finished: nil)
21
21
  @queue = Thread::Queue.new
22
22
  @logger = logger
@@ -34,10 +34,10 @@ module PatientHttp
34
34
  end
35
35
  end
36
36
 
37
- # Enqueue a job for execution.
37
+ # Enqueues a job for execution.
38
38
  #
39
- # @param job [#call] the job to run
40
- # @raise [ClosedQueueError] if the executor has been shut down
39
+ # @param job [#call] The job to run.
40
+ # @raise [ClosedQueueError] If the executor has been shut down.
41
41
  # @return [void]
42
42
  def enqueue(job)
43
43
  @mutex.synchronize { @outstanding += 1 }
@@ -50,30 +50,30 @@ module PatientHttp
50
50
  nil
51
51
  end
52
52
 
53
- # Check whether the executor has no queued or running jobs.
53
+ # Returns whether the executor has no queued or running jobs.
54
54
  #
55
55
  # @return [Boolean]
56
56
  def idle?
57
57
  @mutex.synchronize { @outstanding == 0 }
58
58
  end
59
59
 
60
- # Check whether the given thread is one of this executor's workers.
60
+ # Returns whether the given thread is one of this executor's workers.
61
61
  #
62
- # @param thread [Thread] the thread to check
62
+ # @param thread [Thread] The thread to check.
63
63
  # @return [Boolean]
64
64
  def worker_thread?(thread = Thread.current)
65
65
  @threads.include?(thread)
66
66
  end
67
67
 
68
- # Shut down the executor: close the queue so workers drain remaining jobs
69
- # and exit, then join them within the timeout. Workers still alive after
70
- # the deadline are killed; their tasks remain durably tracked and are
71
- # recovered by the owner's re-enqueue logic.
68
+ # Shuts down the executor. The queue is closed, so the workers run the
69
+ # remaining jobs and exit. The executor waits for the workers until the
70
+ # timeout, and then kills the workers that are still alive. Their tasks
71
+ # stay tracked, and the owner re-enqueues them.
72
72
  #
73
- # Safe to call more than once and from a worker thread itself (the
74
- # current thread is never joined or killed).
73
+ # You can call this method more than once, and from a worker thread. The
74
+ # current thread is never joined or killed.
75
75
  #
76
- # @param timeout [Numeric] seconds to wait for workers to drain
76
+ # @param timeout [Numeric] Seconds to wait for workers to drain.
77
77
  # @return [void]
78
78
  def shutdown(timeout: 5)
79
79
  @queue.close
@@ -96,7 +96,7 @@ module PatientHttp
96
96
 
97
97
  private
98
98
 
99
- # Drop jobs left in the closed queue by workers that were killed at the
99
+ # Drops jobs left in the closed queue by workers that were killed at the
100
100
  # shutdown deadline. Those jobs can never run, so they must stop counting
101
101
  # against the outstanding total or the executor would never report itself
102
102
  # idle again.