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,107 +1,170 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Configuration for the PatientHttp processor.
4
+ # The configuration for the processor and its HTTP connections.
5
5
  #
6
- # This class holds all configuration options for the HTTP connection pool,
7
- # including connection limits, timeouts, and other HTTP client settings.
8
- # It has no dependencies on any job system.
6
+ # The options include connection limits, timeouts, redirects, secrets,
7
+ # preprocessors, encryption, and payload stores. This class doesn't depend on
8
+ # a job system. Job system integration gems subclass it to add their own
9
+ # options.
10
+ #
11
+ # @example
12
+ # PatientHttp.configure do |config|
13
+ # config.max_connections = 512
14
+ # config.request_timeout = 120
15
+ # end
9
16
  class Configuration
10
- # Salt used for generating encryption keys. This is a fixed value to ensure
11
- # consistent key generation across instances and must never be changed.
17
+ # The salt for key derivation. Changing it makes existing encrypted data
18
+ # unreadable.
12
19
  SALT = "patient_http_payload_encryption"
13
20
  private_constant :SALT
14
21
 
15
- # @return [Integer] Maximum number of concurrent connections
22
+ # The default size in bytes above which a serialized payload goes to a
23
+ # payload store instead of the job queue.
24
+ DEFAULT_PAYLOAD_STORE_THRESHOLD = 64 * 1024 # 64KB
25
+
26
+ # @return [Integer] The maximum number of concurrent requests.
16
27
  attr_reader :max_connections
17
28
 
18
- # @return [Integer, nil] Maximum number of connections per host (nil for unlimited)
29
+ # @return [Integer, nil] The maximum number of connections to each host, or
30
+ # `nil` for no limit.
19
31
  attr_reader :max_connections_per_host
20
32
 
21
- # @return [Integer] Number of threads that deliver completed results
33
+ # @return [Integer] The number of threads that decode responses and deliver
34
+ # results.
22
35
  attr_reader :completion_threads
23
36
 
24
- # @return [Integer] Number of retries when delivering a completed result fails.
25
- # A retry calls the task handler again, so handlers must be idempotent.
37
+ # @return [Integer] The number of times to retry result delivery after it
38
+ # fails. A retry calls the task handler again, so handlers must be
39
+ # idempotent.
26
40
  attr_reader :completion_retries
27
41
 
28
- # @return [Numeric] Default request timeout in seconds
42
+ # @return [Numeric] The default request timeout in seconds.
29
43
  attr_reader :request_timeout
30
44
 
31
- # @return [Numeric] Graceful shutdown timeout in seconds
45
+ # @return [Numeric, nil] The default maximum number of seconds to wait for
46
+ # data from the server, or `nil` for no limit. See {#read_timeout=}.
47
+ attr_reader :read_timeout
48
+
49
+ # @return [Numeric] The graceful shutdown timeout in seconds.
32
50
  attr_reader :shutdown_timeout
33
51
 
34
- # @return [Integer] Maximum response size in bytes
52
+ # @return [Integer] The maximum response body size in bytes.
35
53
  attr_reader :max_response_size
36
54
 
37
- # @return [String, nil] Default User-Agent header value
55
+ # @return [String, nil] The default `User-Agent` header value.
38
56
  attr_accessor :user_agent
39
57
 
40
- # @return [Boolean] Whether to raise HttpError for non-2xx responses by default
58
+ # @return [Boolean] Whether non-2xx responses go to the `on_error` callback
59
+ # as an {HttpError} by default.
41
60
  attr_accessor :raise_error_responses
42
61
 
43
- # @return [Integer] Maximum number of redirects to follow (0 disables redirects)
62
+ # @return [Integer] The maximum number of redirects to follow. If `0`,
63
+ # redirects aren't followed.
44
64
  attr_reader :max_redirects
45
65
 
46
- # @return [Boolean] Whether a redirect that requires changing the HTTP method
47
- # (for example POST to GET on a 302) may be followed. When false, such a
48
- # redirect response is returned as the result instead of being followed.
66
+ # @return [Boolean] Whether to follow a redirect that changes the HTTP
67
+ # method, such as POST to GET on a 302. If `false`, the redirect response
68
+ # is the result.
49
69
  attr_reader :follow_method_changing_redirects
50
70
 
51
- # @return [Array<String>] Lowercase header names that are always stripped from
52
- # redirected requests
71
+ # @return [Array<String>] The lowercase names of headers to remove from all
72
+ # redirected requests.
53
73
  attr_reader :redirect_strip_headers
54
74
 
55
- # @return [Integer] This is the maximum number of hosts for which connections
56
- # will be kept alive for at one time.
75
+ # @return [Integer] The maximum number of hosts whose connections are kept
76
+ # open at the same time.
57
77
  attr_reader :connection_pool_size
58
78
 
59
- # @return [Numeric, nil] Connection timeout in seconds
79
+ # @return [Numeric, nil] The timeout in seconds to open a connection,
80
+ # including the TCP connect and the TLS handshake. It doesn't limit the
81
+ # wait for a response. `request_timeout` sets that limit.
60
82
  attr_reader :connection_timeout
61
83
 
62
- # @return [String, nil] HTTP/HTTPS proxy URL (supports authentication)
84
+ # @return [Hash, nil] The TCP keepalive settings for each connection, or `nil`
85
+ # if the kernel sends no probes. The hash has the `:idle` seconds before
86
+ # the first probe, the `:interval` seconds between probes, and the `:count`
87
+ # of probes before the connection is closed.
88
+ attr_reader :tcp_keepalive
89
+
90
+ # @return [Numeric, nil] The seconds that sent data can stay unacknowledged
91
+ # before the kernel closes the connection. This value sets
92
+ # `TCP_USER_TIMEOUT`, which is available only on Linux. A request to a peer
93
+ # that stopped without notice then fails without waiting for the request
94
+ # timeout. Acknowledged data isn't affected, so a slow response continues.
95
+ attr_reader :tcp_user_timeout
96
+
97
+ # @return [String, nil] The HTTP or HTTPS proxy URL. The URL can include a
98
+ # user name and password.
63
99
  attr_reader :proxy_url
64
100
 
65
- # @return [Integer] Number of retries for failed requests
101
+ # @return [Integer] The number of retries for failed requests.
66
102
  attr_reader :retries
67
103
 
68
- # @return [Symbol, nil] HTTP protocol to use (:http1 or :http2). When nil, the
69
- # protocol is negotiated with the server (HTTP/2 preferred for HTTPS).
104
+ # @return [Symbol, nil] The HTTP protocol: `:http1` or `:http2`. If `nil`, the
105
+ # protocol is negotiated with the server, and HTTP/2 is preferred for
106
+ # HTTPS.
70
107
  attr_reader :protocol
71
108
 
72
- # @return [SecretManager] the secret manager instance
109
+ # @return [SecretManager] The manager for the registered secrets.
73
110
  attr_reader :secret_manager
74
111
 
75
- # Initializes a new Configuration with the specified options.
76
- #
77
- # @param max_connections [Integer] Maximum number of concurrent connections
78
- # @param request_timeout [Numeric] Default request timeout in seconds
79
- # @param shutdown_timeout [Numeric] Graceful shutdown timeout in seconds
80
- # @param logger [Logger, nil] Logger instance to use (defaults to stdout)
81
- # @param max_response_size [Integer] Maximum response size in bytes
82
- # @param user_agent [String, nil] Default User-Agent header value
83
- # @param raise_error_responses [Boolean] Whether to raise HttpError for non-2xx responses by default
84
- # @param max_redirects [Integer] Maximum number of redirects to follow (0 disables redirects)
85
- # @param follow_method_changing_redirects [Boolean] Whether to follow a redirect that requires changing the
86
- # HTTP method, such as POST to GET on a 301, 302, or 303 response. When false, requests
87
- # whose method would change do not follow the redirect and receive the redirect response.
88
- # @param redirect_strip_headers [String, Array<String>] Header names (case insensitive)
89
- # that are always stripped from redirected requests, so sensitive headers are never
90
- # sent to a redirect target
91
- # @param connection_pool_size [Integer] Maximum number of host clients to pool
92
- # @param connection_timeout [Numeric, nil] Connection timeout in seconds
93
- # @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
94
- # @param retries [Integer] Number of retries for failed requests
95
- # @param protocol [Symbol, nil] HTTP protocol to use (:http1 or :http2); nil to negotiate
96
- # @param max_connections_per_host [Integer, nil] Maximum number of connections per host (nil for unlimited)
97
- # @param completion_threads [Integer] Number of threads that deliver completed results
98
- # @param completion_retries [Integer] Number of retries when delivering a completed result fails.
99
- # A retry calls TaskHandler#on_complete or #on_error again, so a handler that raises after
100
- # its side effect delivers the callback more than once unless it is idempotent. Set to 0 to
101
- # report the first failure without retrying.
112
+ # @return [Integer] The size in bytes above which a serialized payload goes
113
+ # to the registered payload store instead of the job queue.
114
+ attr_reader :payload_store_threshold
115
+
116
+ # Creates a configuration.
117
+ #
118
+ # @param max_connections [Integer] The maximum number of concurrent requests.
119
+ # @param request_timeout [Numeric] The default request timeout in seconds.
120
+ # @param read_timeout [Numeric, nil] The default maximum number of seconds to
121
+ # wait for data from the server. If `nil`, there is no limit. See
122
+ # {#read_timeout=}.
123
+ # @param shutdown_timeout [Numeric] The graceful shutdown timeout in seconds.
124
+ # @param logger [Logger, nil] The logger. If `nil`, errors are logged to
125
+ # standard error.
126
+ # @param max_response_size [Integer] The maximum response body size in bytes.
127
+ # @param user_agent [String, nil] The default `User-Agent` header value.
128
+ # @param raise_error_responses [Boolean] Whether non-2xx responses go to the
129
+ # `on_error` callback as an {HttpError} by default.
130
+ # @param max_redirects [Integer] The maximum number of redirects to follow. If
131
+ # `0`, redirects aren't followed.
132
+ # @param follow_method_changing_redirects [Boolean] Whether to follow a
133
+ # redirect that changes the HTTP method, such as POST to GET on a 301, 302,
134
+ # or 303 response. If `false`, the redirect response is the result.
135
+ # @param redirect_strip_headers [String, Array<String>] The names of headers to
136
+ # remove from all redirected requests. Names are case insensitive.
137
+ # @param connection_pool_size [Integer] The maximum number of hosts whose
138
+ # connections are kept open.
139
+ # @param connection_timeout [Numeric, nil] The timeout in seconds to open a
140
+ # connection, including the TCP connect and the TLS handshake.
141
+ # @param tcp_keepalive [Integer, Hash, nil] The idle seconds before the first
142
+ # keepalive probe, or a hash with `:idle`, `:interval`, and `:count`. If
143
+ # `nil`, the kernel sends no probes.
144
+ # @param tcp_user_timeout [Numeric, nil] The seconds that sent data can stay
145
+ # unacknowledged before the kernel closes the connection. Linux only. If
146
+ # `nil`, the kernel default applies.
147
+ # @param proxy_url [String, nil] The HTTP or HTTPS proxy URL.
148
+ # @param retries [Integer] The number of retries for failed requests.
149
+ # @param protocol [Symbol, nil] The HTTP protocol: `:http1` or `:http2`. If
150
+ # `nil`, the protocol is negotiated.
151
+ # @param encryption_key [String, Array<String>, nil] The encryption key, or an
152
+ # array of keys for key rotation. See {#encryption_key=}.
153
+ # @param max_connections_per_host [Integer, nil] The maximum number of
154
+ # connections to each host, or `nil` for no limit.
155
+ # @param completion_threads [Integer] The number of threads that decode
156
+ # responses and deliver results.
157
+ # @param completion_retries [Integer] The number of times to retry result
158
+ # delivery after it fails. A retry calls `TaskHandler#on_complete` or
159
+ # `TaskHandler#on_error` again. If `0`, the first failure is reported
160
+ # without a retry.
161
+ # @param payload_store_threshold [Integer, nil] The size in bytes above which
162
+ # a serialized payload goes to the registered payload store.
163
+ # @raise [ArgumentError] If an option isn't valid.
102
164
  def initialize(
103
165
  max_connections: 256,
104
166
  request_timeout: 60,
167
+ read_timeout: nil,
105
168
  shutdown_timeout: 30,
106
169
  logger: nil,
107
170
  max_response_size: 1024 * 1024,
@@ -112,13 +175,16 @@ module PatientHttp
112
175
  redirect_strip_headers: [],
113
176
  connection_pool_size: 100,
114
177
  connection_timeout: nil,
178
+ tcp_keepalive: nil,
179
+ tcp_user_timeout: nil,
115
180
  proxy_url: nil,
116
181
  retries: 3,
117
182
  protocol: nil,
118
183
  encryption_key: nil,
119
184
  max_connections_per_host: nil,
120
185
  completion_threads: 2,
121
- completion_retries: 2
186
+ completion_retries: 2,
187
+ payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD
122
188
  )
123
189
  @mutex = Mutex.new
124
190
 
@@ -137,6 +203,7 @@ module PatientHttp
137
203
 
138
204
  self.max_connections = max_connections
139
205
  self.request_timeout = request_timeout
206
+ self.read_timeout = read_timeout
140
207
  self.shutdown_timeout = shutdown_timeout
141
208
  self.logger = logger || Logger.new($stderr, level: Logger::ERROR)
142
209
  self.max_response_size = max_response_size
@@ -147,6 +214,8 @@ module PatientHttp
147
214
  self.redirect_strip_headers = redirect_strip_headers
148
215
  self.connection_pool_size = connection_pool_size
149
216
  self.connection_timeout = connection_timeout
217
+ self.tcp_keepalive = tcp_keepalive
218
+ self.tcp_user_timeout = tcp_user_timeout
150
219
  self.proxy_url = proxy_url
151
220
  self.retries = retries
152
221
  self.protocol = protocol
@@ -154,17 +223,28 @@ module PatientHttp
154
223
  self.max_connections_per_host = max_connections_per_host
155
224
  self.completion_threads = completion_threads
156
225
  self.completion_retries = completion_retries
226
+ self.payload_store_threshold = payload_store_threshold
157
227
  end
158
228
 
159
- # Get the logger to use to report pool events. Default is to log errors to STDERR.
160
- # @return [Logger] the logger instance
229
+ # @return [Logger] The logger for processor events. The default logger
230
+ # writes errors to standard error.
161
231
  attr_accessor :logger
162
232
 
233
+ # Sets the maximum number of concurrent requests.
234
+ #
235
+ # @param value [Integer] A positive number.
236
+ # @return [void]
237
+ # @raise [ArgumentError] If the value isn't positive.
163
238
  def max_connections=(value)
164
239
  validate_positive(:max_connections, value)
165
240
  @max_connections = value
166
241
  end
167
242
 
243
+ # Sets the maximum number of connections to each host.
244
+ #
245
+ # @param value [Integer, nil] A positive integer, or `nil` for no limit.
246
+ # @return [void]
247
+ # @raise [ArgumentError] If the value isn't `nil` or a positive integer.
168
248
  def max_connections_per_host=(value)
169
249
  if value.nil?
170
250
  @max_connections_per_host = nil
@@ -175,36 +255,122 @@ module PatientHttp
175
255
  @max_connections_per_host = value
176
256
  end
177
257
 
258
+ # Sets the number of threads that decode responses and deliver results. If
259
+ # the value is greater than 1, results are delivered concurrently, so task
260
+ # handlers must be thread-safe.
261
+ #
262
+ # @param value [Integer] A positive integer.
263
+ # @return [void]
264
+ # @raise [ArgumentError] If the value isn't a positive integer.
178
265
  def completion_threads=(value)
179
266
  validate_positive_integer(:completion_threads, value)
180
267
  @completion_threads = value
181
268
  end
182
269
 
270
+ # Sets the number of times to retry result delivery after it fails.
271
+ #
272
+ # @param value [Integer] A non-negative integer. If `0`, the first failure is
273
+ # reported without a retry.
274
+ # @return [void]
275
+ # @raise [ArgumentError] If the value isn't a non-negative integer.
183
276
  def completion_retries=(value)
184
277
  validate_non_negative_integer(:completion_retries, value)
185
278
  @completion_retries = value
186
279
  end
187
280
 
281
+ # Sets the size in bytes above which a serialized payload goes to the
282
+ # registered payload store instead of the job queue. This option has an
283
+ # effect only when a payload store is registered with
284
+ # {#register_payload_store}.
285
+ #
286
+ # @param value [Integer, nil] The size in bytes, or `nil` to use the default.
287
+ # @return [void]
288
+ # @raise [ArgumentError] If the value isn't `nil` or a positive integer.
289
+ def payload_store_threshold=(value)
290
+ value = DEFAULT_PAYLOAD_STORE_THRESHOLD if value.nil?
291
+ validate_positive_integer(:payload_store_threshold, value)
292
+ @payload_store_threshold = value
293
+ end
294
+
295
+ # Sets the default request timeout.
296
+ #
297
+ # @param value [Numeric] A positive number of seconds.
298
+ # @return [void]
299
+ # @raise [ArgumentError] If the value isn't positive.
188
300
  def request_timeout=(value)
189
301
  validate_positive(:request_timeout, value)
190
302
  @request_timeout = value
191
303
  end
192
304
 
305
+ # Sets the default read timeout. This is the maximum number of seconds to
306
+ # wait for data from the server. The timer starts again each time a body
307
+ # chunk arrives. A response that sends data at regular intervals can thus
308
+ # continue for longer than the read timeout. The request timeout continues
309
+ # to limit the full request.
310
+ #
311
+ # The first timer starts before the connection opens. It covers the time to
312
+ # get a connection, send the request, and wait for the response headers.
313
+ # Immediate retries of a refused connection use the same timer. This is
314
+ # different from clients such as Net::HTTP, which use a separate open
315
+ # timeout and write timeout. The connection timeout continues to limit the
316
+ # time to open a connection.
317
+ #
318
+ # Use it for streamed responses, such as server-sent events. Do not use it
319
+ # for requests where the server does all of its work before it sends the
320
+ # response headers, because the wait for the headers must then be shorter
321
+ # than the read timeout.
322
+ #
323
+ # @param value [Numeric, nil] A positive number of seconds, or `nil` for no
324
+ # limit.
325
+ # @return [void]
326
+ # @raise [ArgumentError] If the value isn't positive or `nil`.
327
+ def read_timeout=(value)
328
+ validate_positive(:read_timeout, value) unless value.nil?
329
+ @read_timeout = value
330
+ end
331
+
332
+ # Sets the graceful shutdown timeout. Keep it less than the stop timeout of
333
+ # the process supervisor, so that in-flight requests finish before a hard
334
+ # kill.
335
+ #
336
+ # @param value [Numeric] A positive number of seconds.
337
+ # @return [void]
338
+ # @raise [ArgumentError] If the value isn't positive.
193
339
  def shutdown_timeout=(value)
194
340
  validate_positive(:shutdown_timeout, value)
195
341
  @shutdown_timeout = value
196
342
  end
197
343
 
344
+ # Sets the maximum response body size. For a compressed response, the limit
345
+ # applies to the decompressed body. A larger response raises
346
+ # {ResponseTooLargeError}. A request can set its own limit with
347
+ # {Request#max_response_size}.
348
+ #
349
+ # @param value [Integer] A positive number of bytes.
350
+ # @return [void]
351
+ # @raise [ArgumentError] If the value isn't positive.
198
352
  def max_response_size=(value)
199
353
  validate_positive(:max_response_size, value)
200
354
  @max_response_size = value
201
355
  end
202
356
 
357
+ # Sets the maximum number of redirects to follow.
358
+ #
359
+ # @param value [Integer] A non-negative integer. If `0`, redirects aren't
360
+ # followed.
361
+ # @return [void]
362
+ # @raise [ArgumentError] If the value isn't a non-negative integer.
203
363
  def max_redirects=(value)
204
364
  validate_non_negative_integer(:max_redirects, value)
205
365
  @max_redirects = value
206
366
  end
207
367
 
368
+ # Sets whether to follow a redirect that changes the HTTP method, such as
369
+ # POST to GET on a 302.
370
+ #
371
+ # @param value [Boolean] If `false`, the redirect response is the result.
372
+ # @return [void]
373
+ # @raise [ArgumentError] If the value isn't `true` or `false`.
208
374
  def follow_method_changing_redirects=(value)
209
375
  unless value == true || value == false
210
376
  raise ArgumentError.new("follow_method_changing_redirects must be true or false, got: #{value.inspect}")
@@ -213,15 +379,34 @@ module PatientHttp
213
379
  @follow_method_changing_redirects = value
214
380
  end
215
381
 
382
+ # Sets the names of headers to remove from all redirected requests. The
383
+ # `Authorization` and `Cookie` headers are always removed on cross-origin
384
+ # redirects.
385
+ #
386
+ # @param value [String, Array<String>, nil] The header names. Names are case
387
+ # insensitive.
388
+ # @return [void]
216
389
  def redirect_strip_headers=(value)
217
390
  @redirect_strip_headers = RedirectHelper.normalize_header_names(value)
218
391
  end
219
392
 
393
+ # Sets the maximum number of hosts whose connections are kept open.
394
+ #
395
+ # @param value [Integer] A positive integer.
396
+ # @return [void]
397
+ # @raise [ArgumentError] If the value isn't a positive integer.
220
398
  def connection_pool_size=(value)
221
399
  validate_positive_integer(:connection_pool_size, value)
222
400
  @connection_pool_size = value
223
401
  end
224
402
 
403
+ # Sets the timeout to open a connection, including the TCP connect and the
404
+ # TLS handshake. It doesn't limit the wait for a response.
405
+ #
406
+ # @param value [Numeric, nil] A positive number of seconds, or `nil` for no
407
+ # limit.
408
+ # @return [void]
409
+ # @raise [ArgumentError] If the value isn't `nil` or positive.
225
410
  def connection_timeout=(value)
226
411
  if value.nil?
227
412
  @connection_timeout = nil
@@ -232,6 +417,60 @@ module PatientHttp
232
417
  @connection_timeout = value
233
418
  end
234
419
 
420
+ # Sets TCP keepalive for pooled connections. The kernel sends probes on an
421
+ # idle connection, which keeps NAT and firewall mappings open and finds dead
422
+ # peers.
423
+ #
424
+ # @param value [Integer, Hash, nil] The idle seconds before the first probe,
425
+ # or a hash with `:idle` and the optional `:interval` (default 10 seconds)
426
+ # and `:count` (default 3 probes). If `nil`, the kernel sends no probes.
427
+ # @return [void]
428
+ # @raise [ArgumentError] If the hash has an unknown key, or a value isn't a
429
+ # positive integer.
430
+ def tcp_keepalive=(value)
431
+ if value.nil?
432
+ @tcp_keepalive = nil
433
+ return
434
+ end
435
+
436
+ settings = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : {idle: value}
437
+ unknown = settings.keys - [:idle, :interval, :count]
438
+ unless unknown.empty?
439
+ raise ArgumentError.new("tcp_keepalive has unknown keys: #{unknown.inspect}")
440
+ end
441
+
442
+ settings = {interval: 10, count: 3}.merge(settings)
443
+ validate_positive_integer(:tcp_keepalive_idle, settings[:idle])
444
+ validate_positive_integer(:tcp_keepalive_interval, settings[:interval])
445
+ validate_positive_integer(:tcp_keepalive_count, settings[:count])
446
+ @tcp_keepalive = settings.slice(:idle, :interval, :count).freeze
447
+ end
448
+
449
+ # Sets the seconds that sent data can stay unacknowledged before the kernel
450
+ # closes the connection. The value applies only on platforms that support
451
+ # `TCP_USER_TIMEOUT`, which is Linux.
452
+ #
453
+ # @param value [Numeric, nil] A positive number of seconds, or `nil` to use
454
+ # the kernel default.
455
+ # @return [void]
456
+ # @raise [ArgumentError] If the value isn't `nil` or positive.
457
+ def tcp_user_timeout=(value)
458
+ if value.nil?
459
+ @tcp_user_timeout = nil
460
+ return
461
+ end
462
+
463
+ validate_positive(:tcp_user_timeout, value)
464
+ @tcp_user_timeout = value
465
+ end
466
+
467
+ # Sets the HTTP or HTTPS proxy URL.
468
+ #
469
+ # @param value [String, nil] The proxy URL, or `nil` for no proxy. The URL
470
+ # can include a user name and password, for example
471
+ # `http://user:pass@proxy.example.com:8080`.
472
+ # @return [void]
473
+ # @raise [ArgumentError] If the value isn't a valid HTTP or HTTPS URL.
235
474
  def proxy_url=(value)
236
475
  if value.nil?
237
476
  @proxy_url = nil
@@ -242,11 +481,24 @@ module PatientHttp
242
481
  @proxy_url = value
243
482
  end
244
483
 
484
+ # Sets the number of retries for failed requests.
485
+ #
486
+ # @param value [Integer] A non-negative integer.
487
+ # @return [void]
488
+ # @raise [ArgumentError] If the value isn't a non-negative integer.
245
489
  def retries=(value)
246
490
  validate_non_negative_integer(:retries, value)
247
491
  @retries = value
248
492
  end
249
493
 
494
+ # Sets the HTTP protocol. The `:http1` value also limits the TLS ALPN
495
+ # advertisement to `http/1.1`, which can work around proxies that intercept
496
+ # SSL and don't handle HTTP/2 correctly.
497
+ #
498
+ # @param value [Symbol, String, nil] `:http1` or `:http2`, or `nil` to
499
+ # negotiate the protocol with the server.
500
+ # @return [void]
501
+ # @raise [ArgumentError] If the value isn't a supported protocol.
250
502
  def protocol=(value)
251
503
  if value.nil?
252
504
  @protocol = nil
@@ -261,26 +513,47 @@ module PatientHttp
261
513
  @protocol = value
262
514
  end
263
515
 
264
- # Set the encryption callable for encrypting payloads before serialization.
516
+ # Sets the callable that encrypts serialized payloads. Set {#decryption} as
517
+ # well.
265
518
  #
266
- # @param callable [#call, nil] An object that responds to #call, taking data and returning encrypted data
267
- # @yield [data] A block that takes data and returns encrypted data
268
- # @raise [ArgumentError] If both callable and block are provided, or if callable doesn't respond to #call
519
+ # @param callable [#call, nil] An object that takes the bytes as a String and
520
+ # returns the encrypted bytes. Omit it when you give a block.
521
+ # @yield [data] Returns the encrypted bytes. Omit it when you give a
522
+ # callable.
523
+ # @yieldparam data [String] The bytes to encrypt.
524
+ # @return [void]
525
+ # @raise [ArgumentError] If you give both a callable and a block, or if the
526
+ # callable doesn't respond to `call`.
269
527
  def encryption(callable = nil, &block)
270
528
  @encryption = resolve_callable(:encryption, callable, &block)
271
529
  @encryptor = nil
272
530
  end
273
531
 
274
- # Set the decryption callable for decrypting payloads after deserialization.
532
+ # Sets the callable that decrypts serialized payloads. Set {#encryption} as
533
+ # well.
275
534
  #
276
- # @param callable [#call, nil] An object that responds to #call, taking data and returning decrypted data
277
- # @yield [data] A block that takes data and returns decrypted data
278
- # @raise [ArgumentError] If both callable and block are provided, or if callable doesn't respond to #call
535
+ # @param callable [#call, nil] An object that takes the encrypted bytes as a
536
+ # String and returns the decrypted bytes. Omit it when you give a block.
537
+ # @yield [data] Returns the decrypted bytes. Omit it when you give a
538
+ # callable.
539
+ # @yieldparam data [String] The bytes to decrypt.
540
+ # @return [void]
541
+ # @raise [ArgumentError] If you give both a callable and a block, or if the
542
+ # callable doesn't respond to `call`.
279
543
  def decryption(callable = nil, &block)
280
544
  @decryption = resolve_callable(:decryption, callable, &block)
281
545
  @encryptor = nil
282
546
  end
283
547
 
548
+ # Sets the encryption key. Payloads are encrypted with
549
+ # `ActiveSupport::MessageEncryptor` and AES-256-GCM. This method sets
550
+ # {#encryption} and {#decryption}.
551
+ #
552
+ # @param keys [String, Array<String>, nil] The key, or an array of keys for
553
+ # key rotation. The first key encrypts data, and all keys are tried for
554
+ # decryption. If `nil` or empty, encryption is turned off.
555
+ # @return [void]
556
+ # @raise [ArgumentError] If Active Support isn't available.
284
557
  def encryption_key=(keys)
285
558
  keys = Array(keys).map(&:to_s).reject(&:empty?)
286
559
  if keys.empty?
@@ -312,25 +585,25 @@ module PatientHttp
312
585
  @encryptor = nil
313
586
  end
314
587
 
315
- # Return an Encryptor instance. If encryption and decryption are not set, then
316
- # this will be an empty Encryptor that returns data unchanged.
588
+ # Returns the encryptor for serialized payloads. If encryption isn't set,
589
+ # the encryptor returns data unchanged.
317
590
  #
318
- # @return [Encryptor] the encryptor instance
591
+ # @return [Encryptor] The encryptor.
319
592
  def encryptor
320
593
  @encryptor ||= Encryptor.new(encryption: @encryption, decryption: @decryption)
321
594
  end
322
595
 
323
- # Register a named secret whose value can be referenced indirectly when building
324
- # requests via {PatientHttp.secret}.
596
+ # Registers a named secret. Requests refer to the secret with
597
+ # {PatientHttp.secret}, so the value isn't stored in the job queue.
325
598
  #
326
- # The value can be provided directly or as a block (callable). A block is invoked
327
- # lazily with the secret name each time the secret is resolved, which is useful for
328
- # values that should be read on demand (for example, from the environment).
599
+ # Give the value directly or as a block. The block runs with the secret
600
+ # name each time the secret is resolved. Use a block to read a value when
601
+ # it's needed, for example from the environment.
329
602
  #
330
- # @param name [String, Symbol] the secret name
331
- # @param value [Object, nil] the secret value (omit when providing a block)
332
- # @yield [name] a block that returns the secret value (omit when providing a value)
333
- # @raise [ArgumentError] if neither or both of value and block are provided
603
+ # @param name [String, Symbol] The secret name.
604
+ # @param value [Object, nil] The secret value. Omit it when you give a block.
605
+ # @yield [name] Returns the secret value. Omit it when you give a value.
606
+ # @raise [ArgumentError] If you give both a value and a block, or neither.
334
607
  # @return [void]
335
608
  def register_secret(name, value = nil, &block)
336
609
  if value.nil? && block.nil?
@@ -347,23 +620,25 @@ module PatientHttp
347
620
  end
348
621
  end
349
622
 
350
- # Register a named preprocessor that can be attached to requests to modify them
351
- # just before they are sent -- for example, to sign requests.
623
+ # Registers a named preprocessor. A preprocessor changes a request
624
+ # immediately before it's sent, for example to sign it.
352
625
  #
353
- # The preprocessor can be provided as a callable or a block taking a single
354
- # argument. When a request that references the preprocessor is sent, it is
355
- # invoked with an {OutgoingRequest} after secret references have been resolved
356
- # and the x-request-id and default user-agent headers have been set. It can
357
- # change the request headers and append query parameters.
626
+ # The preprocessor receives an {OutgoingRequest}. At that time, secret
627
+ # references are resolved, and the `x-request-id` and default `user-agent`
628
+ # headers are set. The preprocessor can change the headers and add query
629
+ # parameters.
358
630
  #
359
- # Requests reference preprocessors by name only, so the callable (and any
360
- # credentials it uses) stays on the processor side and is never serialized.
631
+ # Requests refer to a preprocessor by name. The callable, and any
632
+ # credentials that it uses, stay in the processor and aren't serialized.
361
633
  #
362
- # @param name [String, Symbol] the preprocessor name
363
- # @param callable [#call, nil] object invoked with the outgoing request (omit when providing a block)
364
- # @yield [outgoing_request] a block invoked with the outgoing request (omit when providing a callable)
365
- # @raise [ArgumentError] if neither or both of callable and block are provided, or
366
- # if the preprocessor cannot be called with a single argument
634
+ # @param name [String, Symbol] The preprocessor name.
635
+ # @param callable [#call, nil] An object that takes the outgoing request.
636
+ # Omit it when you give a block.
637
+ # @yield [outgoing_request] Changes the outgoing request. Omit it when you
638
+ # give a callable.
639
+ # @yieldparam outgoing_request [OutgoingRequest] The request to change.
640
+ # @raise [ArgumentError] If you give both a callable and a block, or neither,
641
+ # or if the preprocessor doesn't take exactly one argument.
367
642
  # @return [void]
368
643
  def register_preprocessor(name, callable = nil, &block)
369
644
  preprocessor = resolve_callable(:preprocessor, callable, &block)
@@ -376,28 +651,30 @@ module PatientHttp
376
651
  end
377
652
  end
378
653
 
379
- # Get a registered preprocessor by name.
654
+ # Returns a registered preprocessor.
380
655
  #
381
- # @param name [String, Symbol] the preprocessor name
382
- # @return [#call, nil] the preprocessor or nil if not registered
656
+ # @param name [String, Symbol] The preprocessor name.
657
+ # @return [#call, nil] The preprocessor, or `nil` if it isn't registered.
383
658
  def preprocessor(name)
384
659
  @preprocessors[name.to_s]
385
660
  end
386
661
 
387
- # Register a payload store for external storage of large payloads.
662
+ # Registers a payload store for large payloads. A serialized payload larger
663
+ # than {#payload_store_threshold} goes to the store instead of the job
664
+ # queue.
388
665
  #
389
- # The name is included in the serialized references to the stored data.
390
- # Changing it will cause any existing reference to become invalid.
666
+ # References to the stored data include the store name. If you change the
667
+ # name, existing references become invalid.
391
668
  #
392
- # Multiple stores can be registered for migration purposes. The last
393
- # store registered becomes the default used for new writes. References
394
- # to other registered stores remain valid for reading.
669
+ # To move to a new store, register both. The last store registered is used
670
+ # for new writes. The other stores remain available for reads.
395
671
  #
396
- # @param name [Symbol, String] Unique name for this store registration
397
- # @param adapter [Symbol, String] The adapter type (:file, :redis, :s3, etc.)
398
- # @param options [Hash] Options passed to the adapter constructor
672
+ # @param name [Symbol, String] The unique name for the store.
673
+ # @param adapter [Symbol, String] The adapter: `:file`, `:redis`, `:s3`,
674
+ # `:active_record`, or the name of a custom adapter.
675
+ # @param options [Hash] The options for the adapter.
399
676
  # @return [void]
400
- # @raise [ArgumentError] If the adapter is not registered
677
+ # @raise [ArgumentError] If the adapter isn't registered.
401
678
  def register_payload_store(name, adapter:, **options)
402
679
  name = name.to_sym
403
680
  adapter = adapter.to_sym
@@ -418,10 +695,12 @@ module PatientHttp
418
695
  end
419
696
  end
420
697
 
421
- # Get a registered payload store by name.
698
+ # Returns a registered payload store.
422
699
  #
423
- # @param name [Symbol, String, nil] Store name. If nil, returns the default store.
424
- # @return [PayloadStore::Base, nil] The store instance or nil if not found
700
+ # @param name [Symbol, String, nil] The store name. If `nil`, the default store
701
+ # is returned.
702
+ # @return [PayloadStore::Base, nil] The store, or `nil` if it isn't
703
+ # registered.
425
704
  def payload_store(name = nil)
426
705
  if name.nil?
427
706
  return nil unless @default_payload_store_name
@@ -432,24 +711,28 @@ module PatientHttp
432
711
  end
433
712
  end
434
713
 
435
- # Get the name of the default payload store.
714
+ # Returns the name of the default payload store, which is the store for new
715
+ # writes.
436
716
  #
437
- # @return [Symbol, nil] The default store name or nil if none registered
717
+ # @return [Symbol, nil] The store name, or `nil` if no store is registered.
438
718
  attr_reader :default_payload_store_name
439
719
 
440
- # Get all registered payload stores.
720
+ # Returns all registered payload stores.
441
721
  #
442
- # @return [Hash{Symbol => PayloadStore::Base}] Copy of registered stores
722
+ # @return [Hash{Symbol => PayloadStore::Base}] A copy of the stores, keyed by
723
+ # name.
443
724
  def payload_stores
444
725
  @payload_stores.dup
445
726
  end
446
727
 
447
- # Convert to hash for inspection
448
- # @return [Hash] hash representation with string keys
728
+ # Returns the configuration as a hash for inspection.
729
+ #
730
+ # @return [Hash{String => Object}] The option values, keyed by option name.
449
731
  def to_h
450
732
  {
451
733
  "max_connections" => max_connections,
452
734
  "request_timeout" => request_timeout,
735
+ "read_timeout" => read_timeout,
453
736
  "shutdown_timeout" => shutdown_timeout,
454
737
  "logger" => logger,
455
738
  "max_response_size" => max_response_size,
@@ -460,6 +743,8 @@ module PatientHttp
460
743
  "redirect_strip_headers" => redirect_strip_headers,
461
744
  "connection_pool_size" => connection_pool_size,
462
745
  "connection_timeout" => connection_timeout,
746
+ "tcp_keepalive" => tcp_keepalive,
747
+ "tcp_user_timeout" => tcp_user_timeout,
463
748
  "proxy_url" => proxy_url,
464
749
  "retries" => retries,
465
750
  "protocol" => protocol,
@@ -468,6 +753,7 @@ module PatientHttp
468
753
  "completion_retries" => completion_retries,
469
754
  "payload_stores" => payload_stores.keys,
470
755
  "default_payload_store" => default_payload_store_name,
756
+ "payload_store_threshold" => payload_store_threshold,
471
757
  "secrets" => @mutex.synchronize { @secrets.keys },
472
758
  "preprocessors" => @mutex.synchronize { @preprocessors.keys }
473
759
  }
@@ -487,7 +773,7 @@ module PatientHttp
487
773
  callable || block
488
774
  end
489
775
 
490
- # Validate that a preprocessor can be invoked with a single positional argument.
776
+ # Validates that a preprocessor can be called with one positional argument.
491
777
  def validate_preprocessor_parameters!(preprocessor)
492
778
  method_obj = preprocessor.is_a?(Proc) ? preprocessor : preprocessor.method(:call)
493
779
  parameters = method_obj.parameters
@@ -528,9 +814,9 @@ module PatientHttp
528
814
  raise ArgumentError.new("#{attribute} must be a valid URL, got: #{value.inspect}")
529
815
  end
530
816
 
531
- # Ensure adapter class is loaded (triggers autoload).
817
+ # Loads the class of a built-in adapter.
532
818
  #
533
- # @param adapter [Symbol] The adapter name
819
+ # @param adapter [Symbol] The adapter name.
534
820
  # @return [void]
535
821
  def ensure_adapter_loaded(adapter)
536
822
  case adapter