patient_http 1.6.0 → 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 +39 -0
  4. data/README.md +540 -514
  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 +57 -26
  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 +20 -4
data/lib/patient_http.rb CHANGED
@@ -12,34 +12,42 @@ require "socket"
12
12
  require "securerandom"
13
13
  require "logger"
14
14
 
15
- # Generic async HTTP connection pool for Ruby applications.
15
+ # Runs HTTP requests on an async I/O processor and passes each result to a
16
+ # callback service.
16
17
  #
17
- # This module provides:
18
- # - Async HTTP request processing using Ruby's Fiber scheduler
19
- # - Connection pooling with HTTP/2 support
20
- # - Configurable timeouts, retries, and proxy support
21
- # - Error handling with typed errors
18
+ # The processor runs in a dedicated thread and uses Ruby's fiber scheduler, so
19
+ # one thread can run hundreds of requests at the same time. A job system
20
+ # integration gem, such as `patient_http-sidekiq` or `patient_http-solid_queue`,
21
+ # runs the processor and calls the callbacks in background jobs.
22
22
  #
23
- # This module can be used standalone or integrated with job systems
24
- # like Sidekiq via adapters.
23
+ # @example Make a request
24
+ # PatientHttp.get(
25
+ # "https://api.example.com/users/123",
26
+ # callback: FetchUserCallback,
27
+ # callback_args: {user_id: 123}
28
+ # )
25
29
  module PatientHttp
26
- # Raised when trying to enqueue a request when the processor is not running
30
+ # Raised when a request is enqueued on a processor that isn't running.
27
31
  class NotRunningError < StandardError; end
28
32
 
33
+ # Raised when a request is enqueued on a processor that is at
34
+ # `max_connections`.
29
35
  class MaxCapacityError < StandardError; end
30
36
 
37
+ # Raised when a response body is larger than `max_response_size`.
31
38
  class ResponseTooLargeError < StandardError; end
32
39
 
33
- # Raised when a request names a processor that is not configured. Handlers
34
- # that support named processors raise this at enqueue time; the executing
35
- # side raises it for a job that names an unconfigured processor so the job
36
- # lands in the job system's retry mechanism instead of being dropped.
40
+ # Raised when a request names a processor that isn't configured. The job
41
+ # system then retries the job instead of dropping it.
37
42
  class UnknownProcessorError < StandardError; end
38
43
 
39
- # HTTP redirect status codes that are followed when a Location header is present.
40
- # A 300 response is followed only when the server names a preferred choice in Location.
44
+ # The redirect status codes that are followed when the response has a
45
+ # `Location` header. A 300 response is followed only when `Location` names the
46
+ # server's preferred choice.
41
47
  FOLLOWABLE_REDIRECT_STATUSES = [300, 301, 302, 303, 307, 308].freeze
42
48
 
49
+ # The gem version.
50
+
43
51
  VERSION = File.read(File.join(__dir__, "../VERSION")).strip
44
52
 
45
53
  # Autoload utility modules
@@ -54,11 +62,13 @@ module PatientHttp
54
62
  autoload :ClientPool, File.join(__dir__, "patient_http/client_pool")
55
63
  autoload :CompletionExecutor, File.join(__dir__, "patient_http/completion_executor")
56
64
  autoload :Configuration, File.join(__dir__, "patient_http/configuration")
65
+ autoload :ConnectionEndpoint, File.join(__dir__, "patient_http/connection_endpoint")
57
66
  autoload :Encryptor, File.join(__dir__, "patient_http/encryptor")
58
67
  autoload :Error, File.join(__dir__, "patient_http/error")
59
68
  autoload :ExternalStorage, File.join(__dir__, "patient_http/external_storage")
60
69
  autoload :HttpError, File.join(__dir__, "patient_http/http_error")
61
70
  autoload :HttpHeaders, File.join(__dir__, "patient_http/http_headers")
71
+ autoload :ImmediateRetries, File.join(__dir__, "patient_http/immediate_retries")
62
72
  autoload :InlineTaskHandler, File.join(__dir__, "patient_http/inline_task_handler")
63
73
  autoload :LifecycleManager, File.join(__dir__, "patient_http/lifecycle_manager")
64
74
  autoload :OutgoingRequest, File.join(__dir__, "patient_http/outgoing_request")
@@ -89,38 +99,45 @@ module PatientHttp
89
99
  @handler_mutex = Monitor.new
90
100
  @inline_handler = nil
91
101
  @default_configuration = nil
92
- @inline_configuration = nil
102
+ @configuration_provider = nil
93
103
  @module_secrets = {}
94
104
  @config_mutex = Monitor.new
95
105
 
96
106
  class << self
97
- # Check if running in testing mode.
107
+ # Returns whether the process runs in test mode. The value is `true` when
108
+ # `RAILS_ENV`, `RACK_ENV`, or `APP_ENV` is `test`.
98
109
  #
110
+ # @return [Boolean] `true` in test mode.
99
111
  # @api private
100
112
  def testing?
101
113
  @testing
102
114
  end
103
115
 
104
- # Set testing mode.
116
+ # Sets whether the process runs in test mode.
105
117
  #
118
+ # @param value [Boolean] `true` to turn on test mode.
119
+ # @return [void]
106
120
  # @api private
107
121
  def testing=(value)
108
122
  @testing = !!value
109
123
  end
110
124
 
111
- # Registers a request handler that will be called to process each request.
112
- # The handler must be a callable object (responds to `call`) or a block.
125
+ # Registers the request handler. The handler receives every request made
126
+ # with the `PatientHttp` module methods and {RequestHelper}. Job system
127
+ # integration gems register a handler when they load.
113
128
  #
114
- # The handler will receive keyword arguments: request, callback, callback_args,
115
- # and raise_error_responses. It should return the request id for the enqueued request.
129
+ # The handler receives the `request`, `callback`, `callback_args`, and
130
+ # `raise_error_responses` keyword arguments. It should return the request ID.
116
131
  #
117
- # @param callable [#call, nil] A callable object that will handle requests.
118
- # @yield [request, callback, callback_args, raise_error_responses] If a block is given,
119
- # it will be used as the request handler
120
- # @raise [ArgumentError] if neither a callable nor a block is provided, or if both are provided
121
- # @raise [ArgumentError] if the provided callable does not respond to `call`
122
- # @raise [ArgumentError] if the handler does not support the required keyword arguments
123
- # @return [#call] the registered handler
132
+ # @param callable [#call, nil] An object that responds to `call`. Omit it
133
+ # when you give a block.
134
+ # @yield [request:, callback:, callback_args:, raise_error_responses:] The
135
+ # handler. Omit it when you give a callable.
136
+ # @raise [ArgumentError] If you give both a callable and a block, or neither.
137
+ # @raise [ArgumentError] If the callable doesn't respond to `call`.
138
+ # @raise [ArgumentError] If the handler doesn't accept the required keyword
139
+ # arguments.
140
+ # @return [#call] The registered handler.
124
141
  def register_handler(callable = nil, &block)
125
142
  raise ArgumentError.new("Must provide a callable object or a block") unless callable || block_given?
126
143
  raise ArgumentError.new("Cannot provide both a callable object and a block") if callable && block_given?
@@ -133,19 +150,20 @@ module PatientHttp
133
150
  @handler_mutex.synchronize { @handler = handler }
134
151
  end
135
152
 
136
- # Registers a request handler, raising an error if one is already registered.
137
- #
138
- # This is a safer alternative to {.register_handler} that prevents accidental
139
- # double-registration.
153
+ # Registers the request handler, and raises an error if a handler is
154
+ # already registered. Unlike {.register_handler}, this method can't replace
155
+ # a handler by accident.
140
156
  #
141
- # @param callable [#call, nil] A callable object that will handle requests.
142
- # @yield [request, callback, callback_args, raise_error_responses] If a block is given,
143
- # it will be used as the request handler
144
- # @raise [RuntimeError] if a handler is already registered
145
- # @raise [ArgumentError] if neither a callable nor a block is provided, or if both are provided
146
- # @raise [ArgumentError] if the provided callable does not respond to `call`
147
- # @raise [ArgumentError] if the handler does not support the required keyword arguments
148
- # @return [#call] the registered handler
157
+ # @param callable [#call, nil] An object that responds to `call`. Omit it
158
+ # when you give a block.
159
+ # @yield [request:, callback:, callback_args:, raise_error_responses:] The
160
+ # handler. Omit it when you give a callable.
161
+ # @raise [RuntimeError] If a handler is already registered.
162
+ # @raise [ArgumentError] If you give both a callable and a block, or neither.
163
+ # @raise [ArgumentError] If the callable doesn't respond to `call`.
164
+ # @raise [ArgumentError] If the handler doesn't accept the required keyword
165
+ # arguments.
166
+ # @return [#call] The registered handler.
149
167
  def register_handler!(callable = nil, &block)
150
168
  @handler_mutex.synchronize do
151
169
  if @handler
@@ -156,10 +174,10 @@ module PatientHttp
156
174
  end
157
175
  end
158
176
 
159
- # Unregisters the current request handler.
177
+ # Removes the registered request handler.
160
178
  #
161
- # @param handler [#call, nil] If provided, only unregisters if the given handler matches
162
- # the current handler
179
+ # @param handler [#call, nil] If given, the handler is removed only if it's
180
+ # the registered handler.
163
181
  # @return [void]
164
182
  def unregister_handler(handler = nil)
165
183
  @handler_mutex.synchronize do
@@ -167,17 +185,16 @@ module PatientHttp
167
185
  end
168
186
  end
169
187
 
170
- # Registers a request handler that executes requests inline (synchronously,
171
- # in-process) instead of dispatching them to a job system.
188
+ # Registers a request handler that runs each request inline, on the calling
189
+ # thread, instead of sending it to a job system.
172
190
  #
173
- # This is intended for consoles, tests, and development environments where no
174
- # job-system integration gem is configured. Each request runs through
175
- # {SynchronousExecutor} and the callback is invoked on the calling thread
176
- # before the handler returns.
191
+ # Use it in consoles, tests, and development environments that don't load a
192
+ # job system integration gem. Each request runs through
193
+ # {SynchronousExecutor}, and the callback runs before the request method
194
+ # returns.
177
195
  #
178
- # @param config [Configuration, nil] configuration to execute requests against.
179
- # Defaults to {.default_configuration}, or a lazily created configuration that
180
- # includes any secrets registered with {.register_secret}.
196
+ # @param config [Configuration, nil] The configuration for the requests. If
197
+ # `nil`, {.configuration} applies.
181
198
  # @return [void]
182
199
  def inline!(config: nil)
183
200
  handler = lambda do |request:, callback:, callback_args: nil, raise_error_responses: nil|
@@ -196,36 +213,37 @@ module PatientHttp
196
213
  end
197
214
  end
198
215
 
199
- # Check if the currently registered handler is the inline handler registered
200
- # by {.inline!}.
216
+ # Returns whether the registered handler is the inline handler from
217
+ # {.inline!}.
201
218
  #
202
- # @return [Boolean]
219
+ # @return [Boolean] `true` if requests run inline.
203
220
  def inline?
204
221
  @handler_mutex.synchronize { !@handler.nil? && @handler.equal?(@inline_handler) }
205
222
  end
206
223
 
207
- # Check if a request handler is registered.
224
+ # Returns whether a request handler is registered.
208
225
  #
209
- # @return [Boolean]
226
+ # @return [Boolean] `true` if a handler is registered.
210
227
  def handler_registered?
211
228
  @handler_mutex.synchronize { !@handler.nil? }
212
229
  end
213
230
 
214
- # Executes a request inline (synchronously, in-process) through
215
- # {SynchronousExecutor}, invoking the callback with the response or error
216
- # before returning.
217
- #
218
- # @param request [Request] the HTTP request to execute
219
- # @param callback [Class, String] the callback class or name
220
- # @param callback_args [Hash, nil] JSON-compatible callback arguments
221
- # @param raise_error_responses [Boolean, nil] when true, non-success responses are
222
- # reported as errors; defaults to the configuration's setting
223
- # @param config [Configuration, nil] configuration to execute the request against.
224
- # Defaults to {.default_configuration}, or a lazily created configuration that
225
- # includes any secrets registered with {.register_secret}.
226
- # @return [String] the request id
231
+ # Runs a request inline, on the calling thread, through
232
+ # {SynchronousExecutor}. The callback runs with the response or error
233
+ # before this method returns. The registered handler isn't used.
234
+ #
235
+ # @param request [Request] The HTTP request.
236
+ # @param callback [Class, String] The callback service class, or its name.
237
+ # @param callback_args [Hash, nil] The JSON-compatible arguments to pass to the
238
+ # callback.
239
+ # @param raise_error_responses [Boolean, nil] If `true`, non-2xx responses go
240
+ # to the `on_error` callback as an {HttpError}. If `nil`, the configuration
241
+ # value applies.
242
+ # @param config [Configuration, nil] The configuration for the request. If
243
+ # `nil`, {.configuration} applies.
244
+ # @return [String] The request ID.
227
245
  def execute_inline(request:, callback:, callback_args: nil, raise_error_responses: nil, config: nil)
228
- config ||= default_configuration || inline_configuration
246
+ config ||= configuration
229
247
  raise_error_responses = config.raise_error_responses if raise_error_responses.nil?
230
248
 
231
249
  task = RequestTask.new(
@@ -242,15 +260,17 @@ module PatientHttp
242
260
  task.id
243
261
  end
244
262
 
245
- # Executes the registered request handler with the given request parameters.
263
+ # Sends a request to the registered request handler.
246
264
  #
247
- # @param request [Request] the HTTP request to handle
248
- # @param callback [Class, String] the callback class or name
249
- # @param callback_args [Hash, nil] JSON-compatible callback arguments
250
- # @param raise_error_responses [Boolean, nil] when true, non-success responses are
251
- # reported as errors
252
- # @raise [RuntimeError] if no handler is registered
253
- # @return [Object] return value from the registered request handler
265
+ # @param request [Request] The HTTP request.
266
+ # @param callback [Class, String] The callback service class, or its name.
267
+ # @param callback_args [Hash, nil] The JSON-compatible arguments to pass to the
268
+ # callback.
269
+ # @param raise_error_responses [Boolean, nil] If `true`, non-2xx responses go
270
+ # to the `on_error` callback as an {HttpError}. If `nil`, the configuration
271
+ # value applies.
272
+ # @raise [RuntimeError] If no handler is registered.
273
+ # @return [Object] The value that the request handler returns.
254
274
  def execute(request:, callback:, callback_args: nil, raise_error_responses: nil)
255
275
  handler = @handler_mutex.synchronize { @handler }
256
276
 
@@ -266,100 +286,108 @@ module PatientHttp
266
286
  )
267
287
  end
268
288
 
269
- # Enqueues an HTTP GET request.
289
+ # Makes an async GET request.
270
290
  #
271
- # @param uri [String] absolute URL
272
- # @param callback [Class, String] callback class to handle the response
273
- # @param kwargs [Hash] forwarded to `request`
274
- # @return [Object] return value from the registered request handler
291
+ # @param uri [String] The absolute URL.
292
+ # @param callback [Class, String] The callback service class, or its name.
293
+ # @param kwargs [Hash] The request options. See {.request}.
294
+ # @return [Object] The value that the request handler returns.
275
295
  def get(uri, callback:, **kwargs)
276
296
  request(:get, uri, callback: callback, **kwargs)
277
297
  end
278
298
 
279
- # Enqueues an HTTP HEAD request.
299
+ # Makes an async HEAD request.
280
300
  #
281
- # @param uri [String] absolute URL
282
- # @param callback [Class, String] callback class to handle the response
283
- # @param kwargs [Hash] forwarded to `request`
284
- # @return [Object] return value from the registered request handler
301
+ # @param uri [String] The absolute URL.
302
+ # @param callback [Class, String] The callback service class, or its name.
303
+ # @param kwargs [Hash] The request options. See {.request}.
304
+ # @return [Object] The value that the request handler returns.
285
305
  def head(uri, callback:, **kwargs)
286
306
  request(:head, uri, callback: callback, **kwargs)
287
307
  end
288
308
 
289
- # Enqueues an HTTP POST request.
309
+ # Makes an async POST request.
290
310
  #
291
- # @param uri [String] absolute URL
292
- # @param callback [Class, String] callback class to handle the response
293
- # @param kwargs [Hash] forwarded to `request`
294
- # @return [Object] return value from the registered request handler
311
+ # @param uri [String] The absolute URL.
312
+ # @param callback [Class, String] The callback service class, or its name.
313
+ # @param kwargs [Hash] The request options. See {.request}.
314
+ # @return [Object] The value that the request handler returns.
295
315
  def post(uri, callback:, **kwargs)
296
316
  request(:post, uri, callback: callback, **kwargs)
297
317
  end
298
318
 
299
- # Enqueues an HTTP PUT request.
319
+ # Makes an async PUT request.
300
320
  #
301
- # @param uri [String] absolute URL
302
- # @param callback [Class, String] callback class to handle the response
303
- # @param kwargs [Hash] forwarded to `request`
304
- # @return [Object] return value from the registered request handler
321
+ # @param uri [String] The absolute URL.
322
+ # @param callback [Class, String] The callback service class, or its name.
323
+ # @param kwargs [Hash] The request options. See {.request}.
324
+ # @return [Object] The value that the request handler returns.
305
325
  def put(uri, callback:, **kwargs)
306
326
  request(:put, uri, callback: callback, **kwargs)
307
327
  end
308
328
 
309
- # Enqueues an HTTP PATCH request.
329
+ # Makes an async PATCH request.
310
330
  #
311
- # @param uri [String] absolute URL
312
- # @param callback [Class, String] callback class to handle the response
313
- # @param kwargs [Hash] forwarded to `request`
314
- # @return [Object] return value from the registered request handler
331
+ # @param uri [String] The absolute URL.
332
+ # @param callback [Class, String] The callback service class, or its name.
333
+ # @param kwargs [Hash] The request options. See {.request}.
334
+ # @return [Object] The value that the request handler returns.
315
335
  def patch(uri, callback:, **kwargs)
316
336
  request(:patch, uri, callback: callback, **kwargs)
317
337
  end
318
338
 
319
- # Enqueues an HTTP DELETE request.
339
+ # Makes an async DELETE request.
320
340
  #
321
- # @param uri [String] absolute URL
322
- # @param callback [Class, String] callback class to handle the response
323
- # @param kwargs [Hash] forwarded to `request`
324
- # @return [Object] return value from the registered request handler
341
+ # @param uri [String] The absolute URL.
342
+ # @param callback [Class, String] The callback service class, or its name.
343
+ # @param kwargs [Hash] The request options. See {.request}.
344
+ # @return [Object] The value that the request handler returns.
325
345
  def delete(uri, callback:, **kwargs)
326
346
  request(:delete, uri, callback: callback, **kwargs)
327
347
  end
328
348
 
329
- # Enqueues an HTTP QUERY request.
349
+ # Makes an async QUERY request.
330
350
  #
331
- # @param uri [String] absolute URL
332
- # @param callback [Class, String] callback class to handle the response
333
- # @param kwargs [Hash] forwarded to `request`
334
- # @return [Object] return value from the registered request handler
351
+ # @param uri [String] The absolute URL.
352
+ # @param callback [Class, String] The callback service class, or its name.
353
+ # @param kwargs [Hash] The request options. See {.request}.
354
+ # @return [Object] The value that the request handler returns.
335
355
  def query(uri, callback:, **kwargs)
336
356
  request(:query, uri, callback: callback, **kwargs)
337
357
  end
338
358
 
339
- # Builds and dispatches an HTTP request.
340
- #
341
- # @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
342
- # @param url [String] absolute URL
343
- # @param callback [Class, String] callback class to handle the response
344
- # @param headers [Hash, nil] request headers
345
- # @param body [String, nil] raw request body
346
- # @param json [Hash, Array, nil] JSON payload encoded by the request layer
347
- # @param params [Hash, nil] query parameters
348
- # @param timeout [Numeric, nil] timeout in seconds for this request
349
- # @param raise_error_responses [Boolean, nil] when true, non-success responses are
350
- # reported as errors
351
- # @param callback_args [Hash, nil] JSON-compatible callback arguments
352
- # @param max_redirects [Integer, nil] maximum redirects to follow (nil uses the configuration
353
- # default, 0 disables redirects)
354
- # @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
355
- # HTTP method (nil uses the configuration default)
356
- # @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
357
- # to strip from redirected requests, in addition to the configured names
358
- # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
359
- # registered on the configuration to apply to the request when it is sent
360
- # @param processor [String, Symbol, nil] name of the processor that should execute
361
- # the request; handlers that support named processors route on this value
362
- # @return [Object] return value from the registered request handler
359
+ # Makes an async HTTP request. The request goes to the registered request
360
+ # handler, and this method returns without waiting for the response.
361
+ #
362
+ # @param method [Symbol] The HTTP method: `:get`, `:head`, `:post`, `:put`,
363
+ # `:patch`, `:delete`, or `:query`.
364
+ # @param url [String] The absolute URL.
365
+ # @param callback [Class, String] The callback service class, or its name.
366
+ # @param headers [Hash, nil] The request headers.
367
+ # @param body [String, nil] The request body.
368
+ # @param json [Hash, Array, nil] An object to send as a JSON body. Can't be
369
+ # combined with `body`.
370
+ # @param params [Hash, nil] The query parameters to add to the URL.
371
+ # @param timeout [Numeric, nil] The request timeout in seconds.
372
+ # @param raise_error_responses [Boolean, nil] If `true`, non-2xx responses go
373
+ # to the `on_error` callback as an {HttpError}. If `nil`, the configuration
374
+ # value applies.
375
+ # @param callback_args [Hash, nil] The JSON-compatible arguments to pass to the
376
+ # callback.
377
+ # @param max_redirects [Integer, nil] The maximum number of redirects to
378
+ # follow. If `0`, redirects aren't followed. If `nil`, the configuration
379
+ # value applies.
380
+ # @param follow_method_changing_redirects [Boolean, nil] Whether to follow a
381
+ # redirect that changes the HTTP method. If `nil`, the configuration value
382
+ # applies.
383
+ # @param redirect_strip_headers [String, Array<String>, nil] The names of headers
384
+ # to remove from redirected requests, in addition to the configured names.
385
+ # Names are case insensitive.
386
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] The names of
387
+ # the registered preprocessors that run on the request before it's sent.
388
+ # @param processor [String, Symbol, nil] The name of the processor that runs
389
+ # the request. Handlers that support named processors use this value.
390
+ # @return [Object] The value that the request handler returns.
363
391
  def request(
364
392
  method,
365
393
  url,
@@ -399,31 +427,31 @@ module PatientHttp
399
427
  )
400
428
  end
401
429
 
402
- # Build a reference to a named secret for use as a sensitive header or query
403
- # parameter value when building a request.
430
+ # Returns a reference to a named secret. Use the reference as a header or
431
+ # query parameter value.
404
432
  #
405
- # The reference holds only the secret's name; the value is resolved on the
406
- # processor side at send time using the secrets registered on the configuration.
433
+ # The reference holds only the name of the secret. The processor resolves
434
+ # the value from the registered secrets when it sends the request, so the
435
+ # value isn't stored in the job queue.
407
436
  #
408
- # @param name [String, Symbol] the name of the secret to reference
409
- # @return [SecretReference] a reference to the named secret
437
+ # @param name [String, Symbol] The secret name.
438
+ # @return [SecretReference] The reference to the secret.
410
439
  # @see Configuration#register_secret
411
440
  def secret(name)
412
441
  SecretReference.new(name)
413
442
  end
414
443
 
415
- # Register a named secret at the module level, independent of any configuration.
444
+ # Registers a named secret at the module level.
416
445
  #
417
- # Secrets registered here are applied to the {.default_configuration} (immediately
418
- # if one is already set, or when one is set later) and to the configuration used
419
- # for inline execution. This makes boot order irrelevant: application code can
420
- # register secrets before or after the job-system integration gem configures the
421
- # processor.
446
+ # The secret is added to {.configuration} now if the configuration exists,
447
+ # or when it's created. As a result, load order doesn't matter. You can
448
+ # register secrets before or after the job system integration gem loads.
422
449
  #
423
- # @param name [String, Symbol] the secret name
424
- # @param value [Object, nil] the secret value (omit when providing a block)
425
- # @yield [name] a block that returns the secret value (omit when providing a value)
426
- # @raise [ArgumentError] if neither or both of value and block are provided
450
+ # @param name [String, Symbol] The secret name.
451
+ # @param value [Object, nil] The secret value. Omit it when you give a block.
452
+ # @yield [name] Returns the secret value. The block runs each time the secret
453
+ # is resolved. Omit it when you give a value.
454
+ # @raise [ArgumentError] If you give both a value and a block, or neither.
427
455
  # @return [void]
428
456
  # @see Configuration#register_secret
429
457
  def register_secret(name, value = nil, &block)
@@ -439,38 +467,144 @@ module PatientHttp
439
467
  secret_value = block || value
440
468
  @module_secrets[name.to_s] = secret_value
441
469
  @default_configuration&.register_secret(name, secret_value)
442
- @inline_configuration&.register_secret(name, secret_value)
443
470
  end
444
471
  end
445
472
 
446
- # Check if a secret name is registered, either at the module level via
447
- # {.register_secret} or on the {.default_configuration}.
473
+ # Returns whether a secret is registered, at the module level with
474
+ # {.register_secret} or on the {.configuration}.
448
475
  #
449
- # @param name [String, Symbol] the secret name
450
- # @return [Boolean]
476
+ # @param name [String, Symbol] The secret name.
477
+ # @return [Boolean] `true` if the secret is registered.
451
478
  def secret_registered?(name)
479
+ return true if @config_mutex.synchronize { @module_secrets.include?(name.to_s) }
480
+
481
+ config = default_configuration
482
+ !config.nil? && config.secret_manager.include?(name)
483
+ end
484
+
485
+ # Registers the object that builds the configuration for this process.
486
+ #
487
+ # Job system integration gems call this method when they load. Then
488
+ # {.configure} and {.configuration} use the integration's configuration
489
+ # class, which adds the options for that job system. Applications don't
490
+ # call this method.
491
+ #
492
+ # This module stores the configuration, so a process has one configuration
493
+ # object. If a configuration exists when the provider registers, it's
494
+ # discarded, and the provider builds a new one on next use.
495
+ #
496
+ # @param provider [#new_configuration, #configure] The integration module.
497
+ # @raise [ArgumentError] If the provider doesn't respond to
498
+ # `new_configuration` and `configure`.
499
+ # @return [Object] The registered provider.
500
+ # @api private
501
+ def register_configuration_provider(provider)
502
+ unless provider.respond_to?(:new_configuration) && provider.respond_to?(:configure)
503
+ raise ArgumentError.new("A configuration provider must respond to #new_configuration and #configure")
504
+ end
505
+
452
506
  @config_mutex.synchronize do
453
- return true if @module_secrets.include?(name.to_s)
507
+ previous = @configuration_provider
508
+
509
+ if previous && !previous.equal?(provider)
510
+ warn(
511
+ "PatientHttp: #{provider} is replacing #{previous} as the configuration " \
512
+ "provider. Loading more than one patient_http job-system integration in a process is not " \
513
+ "supported; keep only one of them in your Gemfile."
514
+ )
515
+ end
516
+
517
+ @configuration_provider = provider
518
+
519
+ # A configuration built before this provider was registered was not built
520
+ # by it, so it does not carry the provider's options. Discard it so the
521
+ # next read builds one through the provider.
522
+ if @default_configuration && !previous.equal?(provider)
523
+ warn(
524
+ "PatientHttp: discarding the configuration that was built before #{provider} was loaded; " \
525
+ "options set on it are lost. Configure PatientHttp after requiring the job-system integration."
526
+ )
527
+ @default_configuration = nil
528
+ end
529
+ end
530
+
531
+ provider
532
+ end
454
533
 
455
- !@default_configuration.nil? && @default_configuration.secret_manager.include?(name)
534
+ # Returns the registered configuration provider.
535
+ #
536
+ # @return [Object, nil] The provider, or `nil` if no job system integration
537
+ # gem is loaded.
538
+ # @api private
539
+ def configuration_provider
540
+ @config_mutex.synchronize { @configuration_provider }
541
+ end
542
+
543
+ # Returns the configuration for this process, and creates it on first use.
544
+ #
545
+ # If a job system integration gem is loaded, the configuration is an
546
+ # instance of that integration's configuration class, which adds the
547
+ # options for that job system. Otherwise, it's a {Configuration}. Secrets
548
+ # registered with {.register_secret} are added to it.
549
+ #
550
+ # @return [Configuration] The configuration.
551
+ def configuration
552
+ @config_mutex.synchronize do
553
+ @default_configuration ||= begin
554
+ provider = @configuration_provider
555
+ config = provider ? provider.new_configuration : Configuration.new
556
+ apply_module_secrets(config)
557
+ config
558
+ end
456
559
  end
457
560
  end
458
561
 
459
- # The default configuration used for inline execution when none is provided.
460
- # Job-system integration gems should set this at the end of their configure
461
- # step so that module-level secrets registered with {.register_secret} are
462
- # applied to the configuration the processor runs with.
562
+ # Yields the configuration to a block.
563
+ #
564
+ # Use this method to configure the gem with any job system. If an
565
+ # integration gem is loaded, the block receives that integration's
566
+ # configuration. Otherwise, it receives a {Configuration}.
567
+ #
568
+ # Every call yields the same configuration object, so options accumulate.
569
+ # Several initializers can each set options without overwriting one
570
+ # another.
571
+ #
572
+ # @example
573
+ # PatientHttp.configure do |config|
574
+ # config.max_connections = 512
575
+ # config.register_secret(:api_token) { ENV["API_TOKEN"] }
576
+ # end
577
+ #
578
+ # @yield [config] The block that sets configuration options.
579
+ # @yieldparam config [Configuration] The configuration.
580
+ # @return [Configuration] The configuration.
581
+ def configure(&block)
582
+ provider = configuration_provider
583
+ return provider.configure(&block) if provider
584
+
585
+ config = configuration
586
+ yield(config) if block
587
+ config
588
+ end
589
+
590
+ # Returns the configuration if it exists. Unlike {.configuration}, this
591
+ # method doesn't create the configuration.
463
592
  #
464
- # @return [Configuration, nil] the default configuration
593
+ # @return [Configuration, nil] The configuration, or `nil` if it doesn't
594
+ # exist yet.
465
595
  def default_configuration
466
596
  @config_mutex.synchronize { @default_configuration }
467
597
  end
468
598
 
469
- # Set the default configuration. Any secrets registered with {.register_secret}
470
- # are applied to it; the module-level registry is retained, so re-assigning a
471
- # new configuration re-applies the same secrets.
599
+ # Replaces the configuration. Intended for tests. Applications use
600
+ # {.configure} instead.
601
+ #
602
+ # Secrets registered with {.register_secret} are added to the new
603
+ # configuration. If `nil`, the configuration is discarded, and
604
+ # {.configuration} builds a new one on next use.
472
605
  #
473
- # @param config [Configuration, nil] the configuration to use as the default
606
+ # @param config [Configuration, nil] The configuration, or `nil` to build a
607
+ # new one on next use.
474
608
  # @return [void]
475
609
  def default_configuration=(config)
476
610
  @config_mutex.synchronize do
@@ -481,28 +615,19 @@ module PatientHttp
481
615
 
482
616
  private
483
617
 
484
- # The lazily created configuration used for inline execution when no explicit
485
- # or default configuration is available. Module-level secrets are applied to it.
486
- #
487
- # @return [Configuration]
488
- def inline_configuration
489
- @config_mutex.synchronize do
490
- @inline_configuration ||= Configuration.new.tap { |config| apply_module_secrets(config) }
491
- end
492
- end
493
-
494
- # Apply all module-level secrets to the given configuration.
618
+ # Adds the module-level secrets to a configuration.
495
619
  #
496
- # @param config [Configuration] the configuration to apply secrets to
620
+ # @param config [Configuration] The configuration.
497
621
  # @return [void]
498
622
  def apply_module_secrets(config)
499
623
  @module_secrets.each { |name, value| config.register_secret(name, value) }
500
624
  end
501
625
 
502
- # Validates that the handler accepts the required keyword arguments.
626
+ # Validates that a handler accepts the required keyword arguments.
503
627
  #
504
- # @param handler [#call] the handler to validate
505
- # @raise [ArgumentError] if the handler does not support the required keyword arguments
628
+ # @param handler [#call] The handler.
629
+ # @raise [ArgumentError] If the handler doesn't accept the required keyword
630
+ # arguments.
506
631
  # @return [void]
507
632
  def validate_handler_parameters!(handler)
508
633
  required_keywords = %i[request callback callback_args raise_error_responses]