patient_http 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 393cbf361701d9e1dc190b80f571d78059a21dae85f225750198a1ab173e8bb8
4
- data.tar.gz: 3d5349e55335d4cd262699481517d3c44825c27449a2b3b11d9559a7b4f468b0
3
+ metadata.gz: ba7400987a1a1483e88f8c0e3c3fb3579544a02f5f3bdb6da7752f5864792fcb
4
+ data.tar.gz: cc1ab11246151f2b9843e81c730653e922a34db6f4a57600267637fbe72d7f49
5
5
  SHA512:
6
- metadata.gz: '06894a234fca9e2c12aa77b69cf5347824de6f6be9e3b912dcc3e7834e2137231144e69ba58bef85fb026b200369ad976befc4e540ba9f2779a31fe3e68991a0'
7
- data.tar.gz: ac60b71f532664b3083697dd1c225f4a5a787f07de04d2458d3f849c3b3962d68062740b7e0f03b981edf7aa9ab6fac98aadff98ef9549a0efe15c0b3b968de1
6
+ metadata.gz: e021b76930cc0c1d6c0741723d2db084947f04b8daee1564bc1b04d56d24399443dc6567fe153a99795d5c322810ab56f9cd218f1b8ddafb3a127785352f0bb7
7
+ data.tar.gz: 5d920b85e033e9fbfcd551ca2f7702d260da8ddbea04f105173e591c07fab7b66df2c6d6a85303463fa6faa6d2c8e6357296c317e6ccec8e7599dbbd97b1f842
data/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 1.3.0
8
+
9
+ ### Added
10
+
11
+ - `PatientHttp.inline!` registers a request handler that executes requests inline (synchronously, in-process) through `SynchronousExecutor`, for consoles, tests, and development environments with no job-system integration. `PatientHttp.inline?` checks whether the inline handler is currently registered, and `PatientHttp.execute_inline` executes a single request inline without registering a handler.
12
+ - `PatientHttp.register_secret` registers named secrets at the module level, independent of any `Configuration`. Module-level secrets are applied to the new `PatientHttp.default_configuration` (immediately if set, or when it is set later), making registration order between application code and integration gem configuration irrelevant. `PatientHttp.secret_registered?` checks whether a secret is registered at the module level or on the default configuration.
13
+ - `PatientHttp::RequestHelper`'s `request_template` and `async_request` now accept `preprocessors:`, matching `PatientHttp.request` and `RequestTemplate`.
14
+ - `PatientHttp.handler_registered?` checks whether a request handler is registered.
15
+
7
16
  ## 1.2.0
8
17
 
9
18
  ### Added
data/README.md CHANGED
@@ -194,6 +194,30 @@ PatientHttp.get(
194
194
 
195
195
  If you are using the [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) gem or the [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gem, the appropriate handler will automatically be registered for you.
196
196
 
197
+ ### Inline Execution
198
+
199
+ For consoles, tests, and development environments where no job system is configured, you can register a handler that executes requests inline — synchronously, in-process — instead of dispatching them to a queue:
200
+
201
+ ```ruby
202
+ PatientHttp.inline!
203
+ ```
204
+
205
+ Now every request made through the `PatientHttp` interface (or the `RequestHelper` mixin) runs immediately through the full request lifecycle (timeouts, redirects, error wrapping) and invokes its callback on the calling thread before returning. Callbacks can make further requests; those execute inline as well.
206
+
207
+ ```ruby
208
+ PatientHttp.inline!
209
+ PatientHttp.get("https://api.example.com/users/123", callback: FetchUserCallback)
210
+ # FetchUserCallback#on_complete has already been invoked by this point
211
+ ```
212
+
213
+ Inline requests run against `PatientHttp.default_configuration` by default (or a lazily created default configuration that includes any secrets registered with `PatientHttp.register_secret` — see [Secrets](#secrets)). You can also pass an explicit configuration:
214
+
215
+ ```ruby
216
+ PatientHttp.inline!(config: PatientHttp::Configuration.new(raise_error_responses: true))
217
+ ```
218
+
219
+ Use `PatientHttp.inline?` to check whether the inline handler is the currently registered handler. To execute a single request inline without registering a handler, use `PatientHttp.execute_inline(request:, callback:)`.
220
+
197
221
  ### RequestHelper Mixin
198
222
 
199
223
  Use `PatientHttp::RequestHelper` when you want a simple API for creating and dispatching async HTTP requests directly from your class.
@@ -505,6 +529,23 @@ config.register_secret(:api_key) { ENV["MY_API_KEY"] } # lazy block
505
529
 
506
530
  If a secret is not found when resolving a request, a `PatientHttp::SecretManager::SecretNotFoundError` is raised, which surfaces through the normal request error path.
507
531
 
532
+ #### Module-level registration
533
+
534
+ If the `Configuration` is owned by an integration gem (patient_http-sidekiq, patient_http-solid_queue), your application code may not have a convenient reference to it — or may load before it exists. In that case, register secrets at the module level instead:
535
+
536
+ ```ruby
537
+ PatientHttp.register_secret(:authorization, "Bearer #{ENV['API_TOKEN']}")
538
+ PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
539
+ ```
540
+
541
+ Module-level secrets are applied to `PatientHttp.default_configuration` — immediately if one is already set, or as soon as one is set later — so registration order between your application code and the integration gem's configuration does not matter. Integration gems set the default configuration at the end of their configure step; you can also set it yourself:
542
+
543
+ ```ruby
544
+ PatientHttp.default_configuration = config
545
+ ```
546
+
547
+ Use `PatientHttp.secret_registered?(name)` to check whether a secret is available, either at the module level or on the default configuration.
548
+
508
549
  ### Referencing secrets when building a request
509
550
 
510
551
  Use `PatientHttp.secret(name)` anywhere you would put a sensitive header or query parameter value. No value is needed (or available) at build time:
@@ -567,12 +608,29 @@ PatientHttp.post(
567
608
  )
568
609
  ```
569
610
 
570
- Multiple preprocessors can be given as an array; they run in order, each seeing the changes made by the ones before it. `RequestTemplate` also accepts `preprocessors:` as a template-wide default.
611
+ Multiple preprocessors can be given as an array; they run in order, each seeing the changes made by the ones before it. `RequestTemplate` and the `RequestHelper` mixin's `request_template` also accept `preprocessors:` as a template-wide default, and the mixin's `async_*` helpers accept `preprocessors:` per request.
571
612
 
572
613
  If a request references a preprocessor name that is not registered, a `PatientHttp::RequestPreparer::PreprocessorNotFoundError` is raised, which surfaces through the normal request error path.
573
614
 
574
615
  When redirects are followed, preprocessors are re-run against each redirect URL so signatures stay valid. On cross-origin redirects they are dropped entirely, consistent with the stripping of `Authorization` and `Cookie` headers, so signed credentials are never sent to an unexpected origin.
575
616
 
617
+ ## Troubleshooting
618
+
619
+ ### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
620
+
621
+ On some Ruby versions you may see a warning like this in your logs:
622
+
623
+ ```
624
+ warn: Async::Task: Async::Pool::Controller Gardener [...]
625
+ | Task may have ended with unhandled exception.
626
+ | ThreadError: Attempt to unlock a mutex which is not locked
627
+ | → .../async-pool-x.y.z/lib/async/pool/controller.rb:132 in `synchronize'
628
+ ```
629
+
630
+ This is caused by [Ruby bug #20907](https://bugs.ruby-lang.org/issues/20907) (see also [socketry/async#424](https://github.com/socketry/async/issues/424)): under the fiber scheduler, a fiber interrupted while waiting on a `ConditionVariable` fails to re-acquire its mutex before unwinding, raising a spurious `ThreadError`. It appears whenever a pooled HTTP client is closed while its connection pool's background "gardener" task is idle — for example when a connection is evicted after a connection error, when the least recently used client is evicted because the pool is full, or when the processor shuts down.
631
+
632
+ The warning is harmless — connections are still closed correctly; only the log noise is wrong. The fix is to upgrade Ruby: the bug is fixed in Ruby 3.2.7+, 3.3.7+, and 3.4+.
633
+
576
634
  ## Configuration
577
635
 
578
636
  ```ruby
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # No-op task handler used for inline request execution.
5
+ #
6
+ # The {SynchronousExecutor} invokes the user callback directly, so the
7
+ # completion and error hooks here are never exercised in practice; they are
8
+ # defined as no-ops to satisfy the {TaskHandler} contract. Inline requests
9
+ # have no job queue, so retrying is not supported.
10
+ #
11
+ # @api private
12
+ class InlineTaskHandler < TaskHandler
13
+ # @param response [Response] the HTTP response object
14
+ # @param callback [String] callback class name
15
+ # @return [void]
16
+ def on_complete(response, callback)
17
+ end
18
+
19
+ # @param error [Error] the error object
20
+ # @param callback [String] callback class name
21
+ # @return [void]
22
+ def on_error(error, callback)
23
+ end
24
+
25
+ # @raise [NotImplementedError] inline requests cannot be retried
26
+ def retry
27
+ raise NotImplementedError, "Inline requests cannot be retried"
28
+ end
29
+ end
30
+ end
@@ -432,10 +432,15 @@ module PatientHttp
432
432
  @config.logger&.error("[PatientHttp] Reactor loop error: #{e.inspect}\n#{e.backtrace.join("\n")}")
433
433
  ensure
434
434
  # Close the HTTP connection pools while still inside the reactor so the
435
- # pools' background gardener tasks shut down gracefully. Otherwise the
436
- # reactor stops with open pools and async-pool force-cancels each
437
- # gardener mid-wait, emitting a noisy (but harmless) ThreadError:
438
- # "Attempt to unlock a mutex which is not locked".
435
+ # pools shut down in an orderly fashion: in-flight responses have been
436
+ # delivered above, and each pool's background gardener task is stopped
437
+ # by the pool itself rather than force-cancelled by the dying reactor.
438
+ #
439
+ # Note: on Ruby < 3.2.7 / < 3.3.7, stopping a gardener still logs a
440
+ # spurious (harmless) ThreadError: "Attempt to unlock a mutex which is
441
+ # not locked" — a fiber interrupted in ConditionVariable#wait fails to
442
+ # re-acquire its mutex (https://bugs.ruby-lang.org/issues/20907, fixed
443
+ # in Ruby 3.2.7+, 3.3.7+, and 3.4+).
439
444
  begin
440
445
  @http_client.close
441
446
  rescue => e
@@ -114,13 +114,16 @@ module PatientHttp
114
114
  # @param headers [Hash] default headers for requests
115
115
  # @param params [Hash, nil] default query parameters for requests
116
116
  # @param timeout [Float] default timeout in seconds
117
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] default names of
118
+ # preprocessors registered on the configuration to apply to requests
117
119
  # @return [void]
118
- def request_template(base_url: nil, headers: {}, params: nil, timeout: 30)
120
+ def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
119
121
  @patient_http_request_template = RequestTemplate.new(
120
122
  base_url: base_url,
121
123
  headers: headers,
122
124
  params: params,
123
- timeout: timeout
125
+ timeout: timeout,
126
+ preprocessors: preprocessors
124
127
  )
125
128
  end
126
129
 
@@ -140,6 +143,8 @@ module PatientHttp
140
143
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
141
144
  # reported as errors
142
145
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
146
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
147
+ # registered on the configuration to apply to the request when it is sent
143
148
  # @return [Object] return value from the registered request handler
144
149
  def async_request(
145
150
  method,
@@ -151,10 +156,11 @@ module PatientHttp
151
156
  params: nil,
152
157
  timeout: nil,
153
158
  raise_error_responses: nil,
154
- callback_args: nil
159
+ callback_args: nil,
160
+ preprocessors: nil
155
161
  )
156
162
  template = async_request_template
157
- kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout}
163
+ kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors}
158
164
  request = if template
159
165
  template.request(method, url, **kwargs)
160
166
  else
@@ -198,6 +204,8 @@ module PatientHttp
198
204
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
199
205
  # reported as errors
200
206
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
207
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
208
+ # registered on the configuration to apply to the request when it is sent
201
209
  # @return [Object] return value from the registered request handler
202
210
  def async_request(
203
211
  method,
@@ -209,7 +217,8 @@ module PatientHttp
209
217
  params: nil,
210
218
  timeout: nil,
211
219
  raise_error_responses: nil,
212
- callback_args: nil
220
+ callback_args: nil,
221
+ preprocessors: nil
213
222
  )
214
223
  self.class.async_request(
215
224
  method,
@@ -221,7 +230,8 @@ module PatientHttp
221
230
  params: params,
222
231
  timeout: timeout,
223
232
  raise_error_responses: raise_error_responses,
224
- callback_args: callback_args
233
+ callback_args: callback_args,
234
+ preprocessors: preprocessors
225
235
  )
226
236
  end
227
237
 
data/lib/patient_http.rb CHANGED
@@ -51,6 +51,7 @@ module PatientHttp
51
51
  autoload :ExternalStorage, File.join(__dir__, "patient_http/external_storage")
52
52
  autoload :HttpError, File.join(__dir__, "patient_http/http_error")
53
53
  autoload :HttpHeaders, File.join(__dir__, "patient_http/http_headers")
54
+ autoload :InlineTaskHandler, File.join(__dir__, "patient_http/inline_task_handler")
54
55
  autoload :LifecycleManager, File.join(__dir__, "patient_http/lifecycle_manager")
55
56
  autoload :OutgoingRequest, File.join(__dir__, "patient_http/outgoing_request")
56
57
  autoload :Payload, File.join(__dir__, "patient_http/payload")
@@ -78,6 +79,11 @@ module PatientHttp
78
79
  @testing = %w[RAILS_ENV RACK_ENV APP_ENV].any? { |var| ENV[var] == "test" }
79
80
  @handler = nil
80
81
  @handler_mutex = Monitor.new
82
+ @inline_handler = nil
83
+ @default_configuration = nil
84
+ @inline_configuration = nil
85
+ @module_secrets = {}
86
+ @config_mutex = Monitor.new
81
87
 
82
88
  class << self
83
89
  # Check if running in testing mode.
@@ -153,6 +159,81 @@ module PatientHttp
153
159
  end
154
160
  end
155
161
 
162
+ # Registers a request handler that executes requests inline (synchronously,
163
+ # in-process) instead of dispatching them to a job system.
164
+ #
165
+ # This is intended for consoles, tests, and development environments where no
166
+ # job-system integration gem is configured. Each request runs through
167
+ # {SynchronousExecutor} and the callback is invoked on the calling thread
168
+ # before the handler returns.
169
+ #
170
+ # @param config [Configuration, nil] configuration to execute requests against.
171
+ # Defaults to {.default_configuration}, or a lazily created configuration that
172
+ # includes any secrets registered with {.register_secret}.
173
+ # @return [void]
174
+ def inline!(config: nil)
175
+ handler = lambda do |request:, callback:, callback_args: nil, raise_error_responses: nil|
176
+ execute_inline(
177
+ request: request,
178
+ callback: callback,
179
+ callback_args: callback_args,
180
+ raise_error_responses: raise_error_responses,
181
+ config: config
182
+ )
183
+ end
184
+
185
+ @handler_mutex.synchronize do
186
+ register_handler(handler)
187
+ @inline_handler = handler
188
+ end
189
+ end
190
+
191
+ # Check if the currently registered handler is the inline handler registered
192
+ # by {.inline!}.
193
+ #
194
+ # @return [Boolean]
195
+ def inline?
196
+ @handler_mutex.synchronize { !@handler.nil? && @handler.equal?(@inline_handler) }
197
+ end
198
+
199
+ # Check if a request handler is registered.
200
+ #
201
+ # @return [Boolean]
202
+ def handler_registered?
203
+ @handler_mutex.synchronize { !@handler.nil? }
204
+ end
205
+
206
+ # Executes a request inline (synchronously, in-process) through
207
+ # {SynchronousExecutor}, invoking the callback with the response or error
208
+ # before returning.
209
+ #
210
+ # @param request [Request] the HTTP request to execute
211
+ # @param callback [Class, String] the callback class or name
212
+ # @param callback_args [Hash, nil] JSON-compatible callback arguments
213
+ # @param raise_error_responses [Boolean, nil] when true, non-success responses are
214
+ # reported as errors; defaults to the configuration's setting
215
+ # @param config [Configuration, nil] configuration to execute the request against.
216
+ # Defaults to {.default_configuration}, or a lazily created configuration that
217
+ # includes any secrets registered with {.register_secret}.
218
+ # @return [String] the request id
219
+ def execute_inline(request:, callback:, callback_args: nil, raise_error_responses: nil, config: nil)
220
+ config ||= default_configuration || inline_configuration
221
+ raise_error_responses = config.raise_error_responses if raise_error_responses.nil?
222
+
223
+ task = RequestTask.new(
224
+ request: request,
225
+ task_handler: InlineTaskHandler.new,
226
+ callback: callback,
227
+ callback_args: callback_args,
228
+ raise_error_responses: raise_error_responses,
229
+ default_max_redirects: config.max_redirects
230
+ )
231
+
232
+ SynchronousExecutor.new(task, config: config).call
233
+
234
+ task.id
235
+ end
236
+
156
237
  # Executes the registered request handler with the given request parameters.
157
238
  #
158
239
  # @param request [Request] the HTTP request to handle
@@ -287,8 +368,93 @@ module PatientHttp
287
368
  SecretReference.new(name)
288
369
  end
289
370
 
371
+ # Register a named secret at the module level, independent of any configuration.
372
+ #
373
+ # Secrets registered here are applied to the {.default_configuration} (immediately
374
+ # if one is already set, or when one is set later) and to the configuration used
375
+ # for inline execution. This makes boot order irrelevant: application code can
376
+ # register secrets before or after the job-system integration gem configures the
377
+ # processor.
378
+ #
379
+ # @param name [String, Symbol] the secret name
380
+ # @param value [Object, nil] the secret value (omit when providing a block)
381
+ # @yield [name] a block that returns the secret value (omit when providing a value)
382
+ # @raise [ArgumentError] if neither or both of value and block are provided
383
+ # @return [void]
384
+ # @see Configuration#register_secret
385
+ def register_secret(name, value = nil, &block)
386
+ if value.nil? && block.nil?
387
+ raise ArgumentError.new("register_secret requires a value or a block")
388
+ end
389
+
390
+ if !value.nil? && block
391
+ raise ArgumentError.new("register_secret accepts either a value or a block, not both")
392
+ end
393
+
394
+ @config_mutex.synchronize do
395
+ secret_value = block || value
396
+ @module_secrets[name.to_s] = secret_value
397
+ @default_configuration&.register_secret(name, secret_value)
398
+ @inline_configuration&.register_secret(name, secret_value)
399
+ end
400
+ end
401
+
402
+ # Check if a secret name is registered, either at the module level via
403
+ # {.register_secret} or on the {.default_configuration}.
404
+ #
405
+ # @param name [String, Symbol] the secret name
406
+ # @return [Boolean]
407
+ def secret_registered?(name)
408
+ @config_mutex.synchronize do
409
+ return true if @module_secrets.include?(name.to_s)
410
+
411
+ !@default_configuration.nil? && @default_configuration.secret_manager.include?(name)
412
+ end
413
+ end
414
+
415
+ # The default configuration used for inline execution when none is provided.
416
+ # Job-system integration gems should set this at the end of their configure
417
+ # step so that module-level secrets registered with {.register_secret} are
418
+ # applied to the configuration the processor runs with.
419
+ #
420
+ # @return [Configuration, nil] the default configuration
421
+ def default_configuration
422
+ @config_mutex.synchronize { @default_configuration }
423
+ end
424
+
425
+ # Set the default configuration. Any secrets registered with {.register_secret}
426
+ # are applied to it; the module-level registry is retained, so re-assigning a
427
+ # new configuration re-applies the same secrets.
428
+ #
429
+ # @param config [Configuration, nil] the configuration to use as the default
430
+ # @return [void]
431
+ def default_configuration=(config)
432
+ @config_mutex.synchronize do
433
+ @default_configuration = config
434
+ apply_module_secrets(config) if config
435
+ end
436
+ end
437
+
290
438
  private
291
439
 
440
+ # The lazily created configuration used for inline execution when no explicit
441
+ # or default configuration is available. Module-level secrets are applied to it.
442
+ #
443
+ # @return [Configuration]
444
+ def inline_configuration
445
+ @config_mutex.synchronize do
446
+ @inline_configuration ||= Configuration.new.tap { |config| apply_module_secrets(config) }
447
+ end
448
+ end
449
+
450
+ # Apply all module-level secrets to the given configuration.
451
+ #
452
+ # @param config [Configuration] the configuration to apply secrets to
453
+ # @return [void]
454
+ def apply_module_secrets(config)
455
+ @module_secrets.each { |name, value| config.register_secret(name, value) }
456
+ end
457
+
292
458
  # Validates that the handler accepts the required keyword arguments.
293
459
  #
294
460
  # @param handler [#call] the handler to validate
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -109,6 +109,7 @@ files:
109
109
  - lib/patient_http/external_storage.rb
110
110
  - lib/patient_http/http_error.rb
111
111
  - lib/patient_http/http_headers.rb
112
+ - lib/patient_http/inline_task_handler.rb
112
113
  - lib/patient_http/lifecycle_manager.rb
113
114
  - lib/patient_http/outgoing_request.rb
114
115
  - lib/patient_http/payload.rb