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