patient_http 1.6.1 → 1.7.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 +8 -7
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +539 -515
  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 +28 -22
  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 +367 -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 +70 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +142 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +113 -84
  40. data/lib/patient_http/request_template.rb +87 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +66 -65
  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 +67 -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 +311 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
@@ -1,104 +1,159 @@
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] The graceful shutdown timeout in seconds.
32
46
  attr_reader :shutdown_timeout
33
47
 
34
- # @return [Integer] Maximum response size in bytes
48
+ # @return [Integer] The maximum response body size in bytes.
35
49
  attr_reader :max_response_size
36
50
 
37
- # @return [String, nil] Default User-Agent header value
51
+ # @return [String, nil] The default `User-Agent` header value.
38
52
  attr_accessor :user_agent
39
53
 
40
- # @return [Boolean] Whether to raise HttpError for non-2xx responses by default
54
+ # @return [Boolean] Whether non-2xx responses go to the `on_error` callback
55
+ # as an {HttpError} by default.
41
56
  attr_accessor :raise_error_responses
42
57
 
43
- # @return [Integer] Maximum number of redirects to follow (0 disables redirects)
58
+ # @return [Integer] The maximum number of redirects to follow. If `0`,
59
+ # redirects aren't followed.
44
60
  attr_reader :max_redirects
45
61
 
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.
62
+ # @return [Boolean] Whether to follow a redirect that changes the HTTP
63
+ # method, such as POST to GET on a 302. If `false`, the redirect response
64
+ # is the result.
49
65
  attr_reader :follow_method_changing_redirects
50
66
 
51
- # @return [Array<String>] Lowercase header names that are always stripped from
52
- # redirected requests
67
+ # @return [Array<String>] The lowercase names of headers to remove from all
68
+ # redirected requests.
53
69
  attr_reader :redirect_strip_headers
54
70
 
55
- # @return [Integer] This is the maximum number of hosts for which connections
56
- # will be kept alive for at one time.
71
+ # @return [Integer] The maximum number of hosts whose connections are kept
72
+ # open at the same time.
57
73
  attr_reader :connection_pool_size
58
74
 
59
- # @return [Numeric, nil] Connection timeout in seconds
75
+ # @return [Numeric, nil] The timeout in seconds to open a connection,
76
+ # including the TCP connect and the TLS handshake. It doesn't limit the
77
+ # wait for a response. `request_timeout` sets that limit.
60
78
  attr_reader :connection_timeout
61
79
 
62
- # @return [String, nil] HTTP/HTTPS proxy URL (supports authentication)
80
+ # @return [Hash, nil] The TCP keepalive settings for each connection, or `nil`
81
+ # if the kernel sends no probes. The hash has the `:idle` seconds before
82
+ # the first probe, the `:interval` seconds between probes, and the `:count`
83
+ # of probes before the connection is closed.
84
+ attr_reader :tcp_keepalive
85
+
86
+ # @return [Numeric, nil] The seconds that sent data can stay unacknowledged
87
+ # before the kernel closes the connection. This value sets
88
+ # `TCP_USER_TIMEOUT`, which is available only on Linux. A request to a peer
89
+ # that stopped without notice then fails without waiting for the request
90
+ # timeout. Acknowledged data isn't affected, so a slow response continues.
91
+ attr_reader :tcp_user_timeout
92
+
93
+ # @return [String, nil] The HTTP or HTTPS proxy URL. The URL can include a
94
+ # user name and password.
63
95
  attr_reader :proxy_url
64
96
 
65
- # @return [Integer] Number of retries for failed requests
97
+ # @return [Integer] The number of retries for failed requests.
66
98
  attr_reader :retries
67
99
 
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).
100
+ # @return [Symbol, nil] The HTTP protocol: `:http1` or `:http2`. If `nil`, the
101
+ # protocol is negotiated with the server, and HTTP/2 is preferred for
102
+ # HTTPS.
70
103
  attr_reader :protocol
71
104
 
72
- # @return [SecretManager] the secret manager instance
105
+ # @return [SecretManager] The manager for the registered secrets.
73
106
  attr_reader :secret_manager
74
107
 
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.
108
+ # @return [Integer] The size in bytes above which a serialized payload goes
109
+ # to the registered payload store instead of the job queue.
110
+ attr_reader :payload_store_threshold
111
+
112
+ # Creates a configuration.
113
+ #
114
+ # @param max_connections [Integer] The maximum number of concurrent requests.
115
+ # @param request_timeout [Numeric] The default request timeout in seconds.
116
+ # @param shutdown_timeout [Numeric] The graceful shutdown timeout in seconds.
117
+ # @param logger [Logger, nil] The logger. If `nil`, errors are logged to
118
+ # standard error.
119
+ # @param max_response_size [Integer] The maximum response body size in bytes.
120
+ # @param user_agent [String, nil] The default `User-Agent` header value.
121
+ # @param raise_error_responses [Boolean] Whether non-2xx responses go to the
122
+ # `on_error` callback as an {HttpError} by default.
123
+ # @param max_redirects [Integer] The maximum number of redirects to follow. If
124
+ # `0`, redirects aren't followed.
125
+ # @param follow_method_changing_redirects [Boolean] Whether to follow a
126
+ # redirect that changes the HTTP method, such as POST to GET on a 301, 302,
127
+ # or 303 response. If `false`, the redirect response is the result.
128
+ # @param redirect_strip_headers [String, Array<String>] The names of headers to
129
+ # remove from all redirected requests. Names are case insensitive.
130
+ # @param connection_pool_size [Integer] The maximum number of hosts whose
131
+ # connections are kept open.
132
+ # @param connection_timeout [Numeric, nil] The timeout in seconds to open a
133
+ # connection, including the TCP connect and the TLS handshake.
134
+ # @param tcp_keepalive [Integer, Hash, nil] The idle seconds before the first
135
+ # keepalive probe, or a hash with `:idle`, `:interval`, and `:count`. If
136
+ # `nil`, the kernel sends no probes.
137
+ # @param tcp_user_timeout [Numeric, nil] The seconds that sent data can stay
138
+ # unacknowledged before the kernel closes the connection. Linux only. If
139
+ # `nil`, the kernel default applies.
140
+ # @param proxy_url [String, nil] The HTTP or HTTPS proxy URL.
141
+ # @param retries [Integer] The number of retries for failed requests.
142
+ # @param protocol [Symbol, nil] The HTTP protocol: `:http1` or `:http2`. If
143
+ # `nil`, the protocol is negotiated.
144
+ # @param encryption_key [String, Array<String>, nil] The encryption key, or an
145
+ # array of keys for key rotation. See {#encryption_key=}.
146
+ # @param max_connections_per_host [Integer, nil] The maximum number of
147
+ # connections to each host, or `nil` for no limit.
148
+ # @param completion_threads [Integer] The number of threads that decode
149
+ # responses and deliver results.
150
+ # @param completion_retries [Integer] The number of times to retry result
151
+ # delivery after it fails. A retry calls `TaskHandler#on_complete` or
152
+ # `TaskHandler#on_error` again. If `0`, the first failure is reported
153
+ # without a retry.
154
+ # @param payload_store_threshold [Integer, nil] The size in bytes above which
155
+ # a serialized payload goes to the registered payload store.
156
+ # @raise [ArgumentError] If an option isn't valid.
102
157
  def initialize(
103
158
  max_connections: 256,
104
159
  request_timeout: 60,
@@ -112,13 +167,16 @@ module PatientHttp
112
167
  redirect_strip_headers: [],
113
168
  connection_pool_size: 100,
114
169
  connection_timeout: nil,
170
+ tcp_keepalive: nil,
171
+ tcp_user_timeout: nil,
115
172
  proxy_url: nil,
116
173
  retries: 3,
117
174
  protocol: nil,
118
175
  encryption_key: nil,
119
176
  max_connections_per_host: nil,
120
177
  completion_threads: 2,
121
- completion_retries: 2
178
+ completion_retries: 2,
179
+ payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD
122
180
  )
123
181
  @mutex = Mutex.new
124
182
 
@@ -147,6 +205,8 @@ module PatientHttp
147
205
  self.redirect_strip_headers = redirect_strip_headers
148
206
  self.connection_pool_size = connection_pool_size
149
207
  self.connection_timeout = connection_timeout
208
+ self.tcp_keepalive = tcp_keepalive
209
+ self.tcp_user_timeout = tcp_user_timeout
150
210
  self.proxy_url = proxy_url
151
211
  self.retries = retries
152
212
  self.protocol = protocol
@@ -154,17 +214,28 @@ module PatientHttp
154
214
  self.max_connections_per_host = max_connections_per_host
155
215
  self.completion_threads = completion_threads
156
216
  self.completion_retries = completion_retries
217
+ self.payload_store_threshold = payload_store_threshold
157
218
  end
158
219
 
159
- # Get the logger to use to report pool events. Default is to log errors to STDERR.
160
- # @return [Logger] the logger instance
220
+ # @return [Logger] The logger for processor events. The default logger
221
+ # writes errors to standard error.
161
222
  attr_accessor :logger
162
223
 
224
+ # Sets the maximum number of concurrent requests.
225
+ #
226
+ # @param value [Integer] A positive number.
227
+ # @return [void]
228
+ # @raise [ArgumentError] If the value isn't positive.
163
229
  def max_connections=(value)
164
230
  validate_positive(:max_connections, value)
165
231
  @max_connections = value
166
232
  end
167
233
 
234
+ # Sets the maximum number of connections to each host.
235
+ #
236
+ # @param value [Integer, nil] A positive integer, or `nil` for no limit.
237
+ # @return [void]
238
+ # @raise [ArgumentError] If the value isn't `nil` or a positive integer.
168
239
  def max_connections_per_host=(value)
169
240
  if value.nil?
170
241
  @max_connections_per_host = nil
@@ -175,36 +246,94 @@ module PatientHttp
175
246
  @max_connections_per_host = value
176
247
  end
177
248
 
249
+ # Sets the number of threads that decode responses and deliver results. If
250
+ # the value is greater than 1, results are delivered concurrently, so task
251
+ # handlers must be thread-safe.
252
+ #
253
+ # @param value [Integer] A positive integer.
254
+ # @return [void]
255
+ # @raise [ArgumentError] If the value isn't a positive integer.
178
256
  def completion_threads=(value)
179
257
  validate_positive_integer(:completion_threads, value)
180
258
  @completion_threads = value
181
259
  end
182
260
 
261
+ # Sets the number of times to retry result delivery after it fails.
262
+ #
263
+ # @param value [Integer] A non-negative integer. If `0`, the first failure is
264
+ # reported without a retry.
265
+ # @return [void]
266
+ # @raise [ArgumentError] If the value isn't a non-negative integer.
183
267
  def completion_retries=(value)
184
268
  validate_non_negative_integer(:completion_retries, value)
185
269
  @completion_retries = value
186
270
  end
187
271
 
272
+ # Sets the size in bytes above which a serialized payload goes to the
273
+ # registered payload store instead of the job queue. This option has an
274
+ # effect only when a payload store is registered with
275
+ # {#register_payload_store}.
276
+ #
277
+ # @param value [Integer, nil] The size in bytes, or `nil` to use the default.
278
+ # @return [void]
279
+ # @raise [ArgumentError] If the value isn't `nil` or a positive integer.
280
+ def payload_store_threshold=(value)
281
+ value = DEFAULT_PAYLOAD_STORE_THRESHOLD if value.nil?
282
+ validate_positive_integer(:payload_store_threshold, value)
283
+ @payload_store_threshold = value
284
+ end
285
+
286
+ # Sets the default request timeout.
287
+ #
288
+ # @param value [Numeric] A positive number of seconds.
289
+ # @return [void]
290
+ # @raise [ArgumentError] If the value isn't positive.
188
291
  def request_timeout=(value)
189
292
  validate_positive(:request_timeout, value)
190
293
  @request_timeout = value
191
294
  end
192
295
 
296
+ # Sets the graceful shutdown timeout. Keep it less than the stop timeout of
297
+ # the process supervisor, so that in-flight requests finish before a hard
298
+ # kill.
299
+ #
300
+ # @param value [Numeric] A positive number of seconds.
301
+ # @return [void]
302
+ # @raise [ArgumentError] If the value isn't positive.
193
303
  def shutdown_timeout=(value)
194
304
  validate_positive(:shutdown_timeout, value)
195
305
  @shutdown_timeout = value
196
306
  end
197
307
 
308
+ # Sets the maximum response body size. For a compressed response, the limit
309
+ # applies to the decompressed body. A larger response raises
310
+ # {ResponseTooLargeError}.
311
+ #
312
+ # @param value [Integer] A positive number of bytes.
313
+ # @return [void]
314
+ # @raise [ArgumentError] If the value isn't positive.
198
315
  def max_response_size=(value)
199
316
  validate_positive(:max_response_size, value)
200
317
  @max_response_size = value
201
318
  end
202
319
 
320
+ # Sets the maximum number of redirects to follow.
321
+ #
322
+ # @param value [Integer] A non-negative integer. If `0`, redirects aren't
323
+ # followed.
324
+ # @return [void]
325
+ # @raise [ArgumentError] If the value isn't a non-negative integer.
203
326
  def max_redirects=(value)
204
327
  validate_non_negative_integer(:max_redirects, value)
205
328
  @max_redirects = value
206
329
  end
207
330
 
331
+ # Sets whether to follow a redirect that changes the HTTP method, such as
332
+ # POST to GET on a 302.
333
+ #
334
+ # @param value [Boolean] If `false`, the redirect response is the result.
335
+ # @return [void]
336
+ # @raise [ArgumentError] If the value isn't `true` or `false`.
208
337
  def follow_method_changing_redirects=(value)
209
338
  unless value == true || value == false
210
339
  raise ArgumentError.new("follow_method_changing_redirects must be true or false, got: #{value.inspect}")
@@ -213,15 +342,34 @@ module PatientHttp
213
342
  @follow_method_changing_redirects = value
214
343
  end
215
344
 
345
+ # Sets the names of headers to remove from all redirected requests. The
346
+ # `Authorization` and `Cookie` headers are always removed on cross-origin
347
+ # redirects.
348
+ #
349
+ # @param value [String, Array<String>, nil] The header names. Names are case
350
+ # insensitive.
351
+ # @return [void]
216
352
  def redirect_strip_headers=(value)
217
353
  @redirect_strip_headers = RedirectHelper.normalize_header_names(value)
218
354
  end
219
355
 
356
+ # Sets the maximum number of hosts whose connections are kept open.
357
+ #
358
+ # @param value [Integer] A positive integer.
359
+ # @return [void]
360
+ # @raise [ArgumentError] If the value isn't a positive integer.
220
361
  def connection_pool_size=(value)
221
362
  validate_positive_integer(:connection_pool_size, value)
222
363
  @connection_pool_size = value
223
364
  end
224
365
 
366
+ # Sets the timeout to open a connection, including the TCP connect and the
367
+ # TLS handshake. It doesn't limit the wait for a response.
368
+ #
369
+ # @param value [Numeric, nil] A positive number of seconds, or `nil` for no
370
+ # limit.
371
+ # @return [void]
372
+ # @raise [ArgumentError] If the value isn't `nil` or positive.
225
373
  def connection_timeout=(value)
226
374
  if value.nil?
227
375
  @connection_timeout = nil
@@ -232,6 +380,60 @@ module PatientHttp
232
380
  @connection_timeout = value
233
381
  end
234
382
 
383
+ # Sets TCP keepalive for pooled connections. The kernel sends probes on an
384
+ # idle connection, which keeps NAT and firewall mappings open and finds dead
385
+ # peers.
386
+ #
387
+ # @param value [Integer, Hash, nil] The idle seconds before the first probe,
388
+ # or a hash with `:idle` and the optional `:interval` (default 10 seconds)
389
+ # and `:count` (default 3 probes). If `nil`, the kernel sends no probes.
390
+ # @return [void]
391
+ # @raise [ArgumentError] If the hash has an unknown key, or a value isn't a
392
+ # positive integer.
393
+ def tcp_keepalive=(value)
394
+ if value.nil?
395
+ @tcp_keepalive = nil
396
+ return
397
+ end
398
+
399
+ settings = value.is_a?(Hash) ? value.transform_keys(&:to_sym) : {idle: value}
400
+ unknown = settings.keys - [:idle, :interval, :count]
401
+ unless unknown.empty?
402
+ raise ArgumentError.new("tcp_keepalive has unknown keys: #{unknown.inspect}")
403
+ end
404
+
405
+ settings = {interval: 10, count: 3}.merge(settings)
406
+ validate_positive_integer(:tcp_keepalive_idle, settings[:idle])
407
+ validate_positive_integer(:tcp_keepalive_interval, settings[:interval])
408
+ validate_positive_integer(:tcp_keepalive_count, settings[:count])
409
+ @tcp_keepalive = settings.slice(:idle, :interval, :count).freeze
410
+ end
411
+
412
+ # Sets the seconds that sent data can stay unacknowledged before the kernel
413
+ # closes the connection. The value applies only on platforms that support
414
+ # `TCP_USER_TIMEOUT`, which is Linux.
415
+ #
416
+ # @param value [Numeric, nil] A positive number of seconds, or `nil` to use
417
+ # the kernel default.
418
+ # @return [void]
419
+ # @raise [ArgumentError] If the value isn't `nil` or positive.
420
+ def tcp_user_timeout=(value)
421
+ if value.nil?
422
+ @tcp_user_timeout = nil
423
+ return
424
+ end
425
+
426
+ validate_positive(:tcp_user_timeout, value)
427
+ @tcp_user_timeout = value
428
+ end
429
+
430
+ # Sets the HTTP or HTTPS proxy URL.
431
+ #
432
+ # @param value [String, nil] The proxy URL, or `nil` for no proxy. The URL
433
+ # can include a user name and password, for example
434
+ # `http://user:pass@proxy.example.com:8080`.
435
+ # @return [void]
436
+ # @raise [ArgumentError] If the value isn't a valid HTTP or HTTPS URL.
235
437
  def proxy_url=(value)
236
438
  if value.nil?
237
439
  @proxy_url = nil
@@ -242,11 +444,24 @@ module PatientHttp
242
444
  @proxy_url = value
243
445
  end
244
446
 
447
+ # Sets the number of retries for failed requests.
448
+ #
449
+ # @param value [Integer] A non-negative integer.
450
+ # @return [void]
451
+ # @raise [ArgumentError] If the value isn't a non-negative integer.
245
452
  def retries=(value)
246
453
  validate_non_negative_integer(:retries, value)
247
454
  @retries = value
248
455
  end
249
456
 
457
+ # Sets the HTTP protocol. The `:http1` value also limits the TLS ALPN
458
+ # advertisement to `http/1.1`, which can work around proxies that intercept
459
+ # SSL and don't handle HTTP/2 correctly.
460
+ #
461
+ # @param value [Symbol, String, nil] `:http1` or `:http2`, or `nil` to
462
+ # negotiate the protocol with the server.
463
+ # @return [void]
464
+ # @raise [ArgumentError] If the value isn't a supported protocol.
250
465
  def protocol=(value)
251
466
  if value.nil?
252
467
  @protocol = nil
@@ -261,26 +476,47 @@ module PatientHttp
261
476
  @protocol = value
262
477
  end
263
478
 
264
- # Set the encryption callable for encrypting payloads before serialization.
479
+ # Sets the callable that encrypts serialized payloads. Set {#decryption} as
480
+ # well.
265
481
  #
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
482
+ # @param callable [#call, nil] An object that takes the bytes as a String and
483
+ # returns the encrypted bytes. Omit it when you give a block.
484
+ # @yield [data] Returns the encrypted bytes. Omit it when you give a
485
+ # callable.
486
+ # @yieldparam data [String] The bytes to encrypt.
487
+ # @return [void]
488
+ # @raise [ArgumentError] If you give both a callable and a block, or if the
489
+ # callable doesn't respond to `call`.
269
490
  def encryption(callable = nil, &block)
270
491
  @encryption = resolve_callable(:encryption, callable, &block)
271
492
  @encryptor = nil
272
493
  end
273
494
 
274
- # Set the decryption callable for decrypting payloads after deserialization.
495
+ # Sets the callable that decrypts serialized payloads. Set {#encryption} as
496
+ # well.
275
497
  #
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
498
+ # @param callable [#call, nil] An object that takes the encrypted bytes as a
499
+ # String and returns the decrypted bytes. Omit it when you give a block.
500
+ # @yield [data] Returns the decrypted bytes. Omit it when you give a
501
+ # callable.
502
+ # @yieldparam data [String] The bytes to decrypt.
503
+ # @return [void]
504
+ # @raise [ArgumentError] If you give both a callable and a block, or if the
505
+ # callable doesn't respond to `call`.
279
506
  def decryption(callable = nil, &block)
280
507
  @decryption = resolve_callable(:decryption, callable, &block)
281
508
  @encryptor = nil
282
509
  end
283
510
 
511
+ # Sets the encryption key. Payloads are encrypted with
512
+ # `ActiveSupport::MessageEncryptor` and AES-256-GCM. This method sets
513
+ # {#encryption} and {#decryption}.
514
+ #
515
+ # @param keys [String, Array<String>, nil] The key, or an array of keys for
516
+ # key rotation. The first key encrypts data, and all keys are tried for
517
+ # decryption. If `nil` or empty, encryption is turned off.
518
+ # @return [void]
519
+ # @raise [ArgumentError] If Active Support isn't available.
284
520
  def encryption_key=(keys)
285
521
  keys = Array(keys).map(&:to_s).reject(&:empty?)
286
522
  if keys.empty?
@@ -312,25 +548,25 @@ module PatientHttp
312
548
  @encryptor = nil
313
549
  end
314
550
 
315
- # Return an Encryptor instance. If encryption and decryption are not set, then
316
- # this will be an empty Encryptor that returns data unchanged.
551
+ # Returns the encryptor for serialized payloads. If encryption isn't set,
552
+ # the encryptor returns data unchanged.
317
553
  #
318
- # @return [Encryptor] the encryptor instance
554
+ # @return [Encryptor] The encryptor.
319
555
  def encryptor
320
556
  @encryptor ||= Encryptor.new(encryption: @encryption, decryption: @decryption)
321
557
  end
322
558
 
323
- # Register a named secret whose value can be referenced indirectly when building
324
- # requests via {PatientHttp.secret}.
559
+ # Registers a named secret. Requests refer to the secret with
560
+ # {PatientHttp.secret}, so the value isn't stored in the job queue.
325
561
  #
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).
562
+ # Give the value directly or as a block. The block runs with the secret
563
+ # name each time the secret is resolved. Use a block to read a value when
564
+ # it's needed, for example from the environment.
329
565
  #
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
566
+ # @param name [String, Symbol] The secret name.
567
+ # @param value [Object, nil] The secret value. Omit it when you give a block.
568
+ # @yield [name] Returns the secret value. Omit it when you give a value.
569
+ # @raise [ArgumentError] If you give both a value and a block, or neither.
334
570
  # @return [void]
335
571
  def register_secret(name, value = nil, &block)
336
572
  if value.nil? && block.nil?
@@ -347,23 +583,25 @@ module PatientHttp
347
583
  end
348
584
  end
349
585
 
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.
586
+ # Registers a named preprocessor. A preprocessor changes a request
587
+ # immediately before it's sent, for example to sign it.
352
588
  #
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.
589
+ # The preprocessor receives an {OutgoingRequest}. At that time, secret
590
+ # references are resolved, and the `x-request-id` and default `user-agent`
591
+ # headers are set. The preprocessor can change the headers and add query
592
+ # parameters.
358
593
  #
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.
594
+ # Requests refer to a preprocessor by name. The callable, and any
595
+ # credentials that it uses, stay in the processor and aren't serialized.
361
596
  #
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
597
+ # @param name [String, Symbol] The preprocessor name.
598
+ # @param callable [#call, nil] An object that takes the outgoing request.
599
+ # Omit it when you give a block.
600
+ # @yield [outgoing_request] Changes the outgoing request. Omit it when you
601
+ # give a callable.
602
+ # @yieldparam outgoing_request [OutgoingRequest] The request to change.
603
+ # @raise [ArgumentError] If you give both a callable and a block, or neither,
604
+ # or if the preprocessor doesn't take exactly one argument.
367
605
  # @return [void]
368
606
  def register_preprocessor(name, callable = nil, &block)
369
607
  preprocessor = resolve_callable(:preprocessor, callable, &block)
@@ -376,28 +614,30 @@ module PatientHttp
376
614
  end
377
615
  end
378
616
 
379
- # Get a registered preprocessor by name.
617
+ # Returns a registered preprocessor.
380
618
  #
381
- # @param name [String, Symbol] the preprocessor name
382
- # @return [#call, nil] the preprocessor or nil if not registered
619
+ # @param name [String, Symbol] The preprocessor name.
620
+ # @return [#call, nil] The preprocessor, or `nil` if it isn't registered.
383
621
  def preprocessor(name)
384
622
  @preprocessors[name.to_s]
385
623
  end
386
624
 
387
- # Register a payload store for external storage of large payloads.
625
+ # Registers a payload store for large payloads. A serialized payload larger
626
+ # than {#payload_store_threshold} goes to the store instead of the job
627
+ # queue.
388
628
  #
389
- # The name is included in the serialized references to the stored data.
390
- # Changing it will cause any existing reference to become invalid.
629
+ # References to the stored data include the store name. If you change the
630
+ # name, existing references become invalid.
391
631
  #
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.
632
+ # To move to a new store, register both. The last store registered is used
633
+ # for new writes. The other stores remain available for reads.
395
634
  #
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
635
+ # @param name [Symbol, String] The unique name for the store.
636
+ # @param adapter [Symbol, String] The adapter: `:file`, `:redis`, `:s3`,
637
+ # `:active_record`, or the name of a custom adapter.
638
+ # @param options [Hash] The options for the adapter.
399
639
  # @return [void]
400
- # @raise [ArgumentError] If the adapter is not registered
640
+ # @raise [ArgumentError] If the adapter isn't registered.
401
641
  def register_payload_store(name, adapter:, **options)
402
642
  name = name.to_sym
403
643
  adapter = adapter.to_sym
@@ -418,10 +658,12 @@ module PatientHttp
418
658
  end
419
659
  end
420
660
 
421
- # Get a registered payload store by name.
661
+ # Returns a registered payload store.
422
662
  #
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
663
+ # @param name [Symbol, String, nil] The store name. If `nil`, the default store
664
+ # is returned.
665
+ # @return [PayloadStore::Base, nil] The store, or `nil` if it isn't
666
+ # registered.
425
667
  def payload_store(name = nil)
426
668
  if name.nil?
427
669
  return nil unless @default_payload_store_name
@@ -432,20 +674,23 @@ module PatientHttp
432
674
  end
433
675
  end
434
676
 
435
- # Get the name of the default payload store.
677
+ # Returns the name of the default payload store, which is the store for new
678
+ # writes.
436
679
  #
437
- # @return [Symbol, nil] The default store name or nil if none registered
680
+ # @return [Symbol, nil] The store name, or `nil` if no store is registered.
438
681
  attr_reader :default_payload_store_name
439
682
 
440
- # Get all registered payload stores.
683
+ # Returns all registered payload stores.
441
684
  #
442
- # @return [Hash{Symbol => PayloadStore::Base}] Copy of registered stores
685
+ # @return [Hash{Symbol => PayloadStore::Base}] A copy of the stores, keyed by
686
+ # name.
443
687
  def payload_stores
444
688
  @payload_stores.dup
445
689
  end
446
690
 
447
- # Convert to hash for inspection
448
- # @return [Hash] hash representation with string keys
691
+ # Returns the configuration as a hash for inspection.
692
+ #
693
+ # @return [Hash{String => Object}] The option values, keyed by option name.
449
694
  def to_h
450
695
  {
451
696
  "max_connections" => max_connections,
@@ -460,6 +705,8 @@ module PatientHttp
460
705
  "redirect_strip_headers" => redirect_strip_headers,
461
706
  "connection_pool_size" => connection_pool_size,
462
707
  "connection_timeout" => connection_timeout,
708
+ "tcp_keepalive" => tcp_keepalive,
709
+ "tcp_user_timeout" => tcp_user_timeout,
463
710
  "proxy_url" => proxy_url,
464
711
  "retries" => retries,
465
712
  "protocol" => protocol,
@@ -468,6 +715,7 @@ module PatientHttp
468
715
  "completion_retries" => completion_retries,
469
716
  "payload_stores" => payload_stores.keys,
470
717
  "default_payload_store" => default_payload_store_name,
718
+ "payload_store_threshold" => payload_store_threshold,
471
719
  "secrets" => @mutex.synchronize { @secrets.keys },
472
720
  "preprocessors" => @mutex.synchronize { @preprocessors.keys }
473
721
  }
@@ -487,7 +735,7 @@ module PatientHttp
487
735
  callable || block
488
736
  end
489
737
 
490
- # Validate that a preprocessor can be invoked with a single positional argument.
738
+ # Validates that a preprocessor can be called with one positional argument.
491
739
  def validate_preprocessor_parameters!(preprocessor)
492
740
  method_obj = preprocessor.is_a?(Proc) ? preprocessor : preprocessor.method(:call)
493
741
  parameters = method_obj.parameters
@@ -528,9 +776,9 @@ module PatientHttp
528
776
  raise ArgumentError.new("#{attribute} must be a valid URL, got: #{value.inspect}")
529
777
  end
530
778
 
531
- # Ensure adapter class is loaded (triggers autoload).
779
+ # Loads the class of a built-in adapter.
532
780
  #
533
- # @param adapter [Symbol] The adapter name
781
+ # @param adapter [Symbol] The adapter name.
534
782
  # @return [void]
535
783
  def ensure_adapter_loaded(adapter)
536
784
  case adapter