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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +18 -8
- data/CHANGELOG.md +43 -0
- data/README.md +564 -508
- data/VERSION +1 -1
- data/lib/patient_http/callback_args.rb +53 -48
- data/lib/patient_http/callback_validator.rb +11 -7
- data/lib/patient_http/class_helper.rb +6 -7
- data/lib/patient_http/client.rb +53 -26
- data/lib/patient_http/client_pool.rb +134 -39
- data/lib/patient_http/completion_executor.rb +20 -20
- data/lib/patient_http/configuration.rb +405 -119
- data/lib/patient_http/connection_endpoint.rb +150 -0
- data/lib/patient_http/encryptor.rb +28 -18
- data/lib/patient_http/error.rb +24 -18
- data/lib/patient_http/external_storage.rb +42 -38
- data/lib/patient_http/http_error.rb +30 -26
- data/lib/patient_http/http_headers.rb +35 -29
- data/lib/patient_http/immediate_retries.rb +98 -0
- data/lib/patient_http/inline_task_handler.rb +15 -10
- data/lib/patient_http/lifecycle_manager.rb +39 -40
- data/lib/patient_http/outgoing_request.rb +25 -23
- data/lib/patient_http/payload.rb +28 -26
- data/lib/patient_http/payload_store/active_record_store.rb +31 -34
- data/lib/patient_http/payload_store/base.rb +42 -46
- data/lib/patient_http/payload_store/file_store.rb +22 -26
- data/lib/patient_http/payload_store/redis_store.rb +28 -34
- data/lib/patient_http/payload_store/s3_store.rb +25 -28
- data/lib/patient_http/payload_store.rb +2 -0
- data/lib/patient_http/processor.rb +111 -79
- data/lib/patient_http/processor_observer.rb +65 -59
- data/lib/patient_http/rails/engine.rb +13 -8
- data/lib/patient_http/redirect_error.rb +50 -41
- data/lib/patient_http/redirect_helper.rb +38 -38
- data/lib/patient_http/request.rb +117 -46
- data/lib/patient_http/request_error.rb +47 -42
- data/lib/patient_http/request_helper.rb +178 -119
- data/lib/patient_http/request_preparer.rb +13 -10
- data/lib/patient_http/request_task.rb +115 -84
- data/lib/patient_http/request_template.rb +119 -64
- data/lib/patient_http/response.rb +58 -52
- data/lib/patient_http/response_reader.rb +166 -118
- data/lib/patient_http/secret_manager.rb +34 -30
- data/lib/patient_http/secret_reference.rb +33 -26
- data/lib/patient_http/synchronous_executor.rb +84 -95
- data/lib/patient_http/task_handler.rb +23 -19
- data/lib/patient_http/time_helper.rb +8 -8
- data/lib/patient_http.rb +327 -186
- data/patient_http.gemspec +3 -2
- metadata +21 -5
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
|
-
#
|
|
4
|
+
# A pool of HTTP clients, one for each host.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
77
|
+
# Returns or creates a client for the given endpoint.
|
|
37
78
|
#
|
|
38
|
-
# @param endpoint [Async::HTTP::Endpoint]
|
|
39
|
-
# @return [Async::HTTP::Client]
|
|
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
|
-
#
|
|
97
|
+
# Makes a request.
|
|
57
98
|
#
|
|
58
|
-
# @param http_method [String, Symbol] HTTP method
|
|
59
|
-
# @param url [String]
|
|
60
|
-
#
|
|
61
|
-
# @param
|
|
62
|
-
# @param
|
|
63
|
-
# @
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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]
|
|
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
|
|
122
|
-
|
|
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]
|
|
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
|
-
|
|
143
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
13
|
+
# Creates the executor and starts its worker threads.
|
|
14
14
|
#
|
|
15
|
-
# @param threads [Integer]
|
|
16
|
-
# @param logger [Logger, nil]
|
|
17
|
-
# @param thread_name_prefix [String]
|
|
18
|
-
# @param on_finished [#call, nil]
|
|
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
|
-
#
|
|
37
|
+
# Enqueues a job for execution.
|
|
38
38
|
#
|
|
39
|
-
# @param job [#call]
|
|
40
|
-
# @raise [ClosedQueueError]
|
|
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
|
-
#
|
|
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
|
-
#
|
|
60
|
+
# Returns whether the given thread is one of this executor's workers.
|
|
61
61
|
#
|
|
62
|
-
# @param thread [Thread]
|
|
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
|
-
#
|
|
69
|
-
# and exit
|
|
70
|
-
#
|
|
71
|
-
#
|
|
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
|
-
#
|
|
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]
|
|
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
|
-
#
|
|
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.
|