patient_http-sidekiq 1.3.0 → 1.4.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/CHANGELOG.md +22 -0
  3. data/README.md +105 -2
  4. data/VERSION +1 -1
  5. data/lib/patient_http/sidekiq/configuration.rb +231 -1
  6. data/lib/patient_http/sidekiq/direct_task_handler.rb +3 -1
  7. data/lib/patient_http/sidekiq/processor_observer.rb +151 -24
  8. data/lib/patient_http/sidekiq/redis_pool.rb +88 -0
  9. data/lib/patient_http/sidekiq/request_executor.rb +33 -5
  10. data/lib/patient_http/sidekiq/request_worker.rb +5 -2
  11. data/lib/patient_http/sidekiq/stats.rb +230 -33
  12. data/lib/patient_http/sidekiq/task_handler.rb +9 -3
  13. data/lib/patient_http/sidekiq/task_monitor.rb +410 -121
  14. data/lib/patient_http/sidekiq/task_monitor_thread.rb +36 -3
  15. data/lib/patient_http/sidekiq/web_ui/assets/patient-http/css/patient_http.css +29 -71
  16. data/lib/patient_http/sidekiq/web_ui/locales/ar.yml +10 -5
  17. data/lib/patient_http/sidekiq/web_ui/locales/cs.yml +10 -5
  18. data/lib/patient_http/sidekiq/web_ui/locales/da.yml +10 -5
  19. data/lib/patient_http/sidekiq/web_ui/locales/de.yml +10 -5
  20. data/lib/patient_http/sidekiq/web_ui/locales/el.yml +10 -5
  21. data/lib/patient_http/sidekiq/web_ui/locales/en.yml +10 -5
  22. data/lib/patient_http/sidekiq/web_ui/locales/es.yml +10 -5
  23. data/lib/patient_http/sidekiq/web_ui/locales/fa.yml +10 -5
  24. data/lib/patient_http/sidekiq/web_ui/locales/fr.yml +10 -5
  25. data/lib/patient_http/sidekiq/web_ui/locales/gd.yml +10 -5
  26. data/lib/patient_http/sidekiq/web_ui/locales/he.yml +10 -5
  27. data/lib/patient_http/sidekiq/web_ui/locales/hi.yml +10 -5
  28. data/lib/patient_http/sidekiq/web_ui/locales/it.yml +10 -5
  29. data/lib/patient_http/sidekiq/web_ui/locales/ja.yml +10 -5
  30. data/lib/patient_http/sidekiq/web_ui/locales/ko.yml +10 -5
  31. data/lib/patient_http/sidekiq/web_ui/locales/lt.yml +10 -5
  32. data/lib/patient_http/sidekiq/web_ui/locales/nb.yml +10 -5
  33. data/lib/patient_http/sidekiq/web_ui/locales/nl.yml +10 -5
  34. data/lib/patient_http/sidekiq/web_ui/locales/pl.yml +10 -5
  35. data/lib/patient_http/sidekiq/web_ui/locales/pt-BR.yml +10 -5
  36. data/lib/patient_http/sidekiq/web_ui/locales/pt.yml +10 -5
  37. data/lib/patient_http/sidekiq/web_ui/locales/ru.yml +10 -5
  38. data/lib/patient_http/sidekiq/web_ui/locales/sv.yml +10 -5
  39. data/lib/patient_http/sidekiq/web_ui/locales/ta.yml +10 -5
  40. data/lib/patient_http/sidekiq/web_ui/locales/tr.yml +10 -5
  41. data/lib/patient_http/sidekiq/web_ui/locales/uk.yml +10 -5
  42. data/lib/patient_http/sidekiq/web_ui/locales/ur.yml +10 -5
  43. data/lib/patient_http/sidekiq/web_ui/locales/vi.yml +10 -5
  44. data/lib/patient_http/sidekiq/web_ui/locales/zh-CN.yml +10 -5
  45. data/lib/patient_http/sidekiq/web_ui/locales/zh-TW.yml +10 -5
  46. data/lib/patient_http/sidekiq/web_ui/views/patient_http.html.erb +99 -44
  47. data/lib/patient_http/sidekiq/web_ui.rb +53 -1
  48. data/lib/patient_http/sidekiq.rb +236 -31
  49. data/patient_http-sidekiq.gemspec +1 -1
  50. metadata +5 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47d12842c42be5d8953e2e54afcaa7f702ead028c6aef09c9f01bb16e48d0193
4
- data.tar.gz: 1ab374564b701fd7201b36a208bdd31a24e60829eaa4522a4cdbcae27380721e
3
+ metadata.gz: 0f463cc156486ac04ea624baf032e3ebd37d219149a720fd5044a864a9abf201
4
+ data.tar.gz: 0b294c6cb0df108ca53fd872c71d284fd82ab0a42284769eb2af7f04ca25bd76
5
5
  SHA512:
6
- metadata.gz: b26e488319bde0bc5d778e19281897b8244e7e70a82df90f8c7824329fe449b2d1cc52664ffb090d62c6671edd99e6c98c9e12c669c7888560ad082b8d1db2b6
7
- data.tar.gz: 0ff5a1cfe9e6ce4ee41e4a3a50ce8af4b88ec47020be8d3d6117ebe96a0cb7b402f0ec77e0911b7dd2e803ea5b70fb1e1acd939dcd69b15e865855e4a1a35f42
6
+ metadata.gz: b9ab7cebbc773fb92baba7d58212a8fd3733392b4a93d995bdfa7ad3b03d70e1cd3637d8ebddc68c7178ca400b1adc1219ab65ef8cd3c7b9e4b852a0245b11e3
7
+ data.tar.gz: 994e51c5f8d7df504f6be4ee32bdd1e32607e62672bf7f0d228d9758af8f2e1b473844063c20951726b4d2ccc37f5eae7549ce4a3796633004195a82af1bcc23
data/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ 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.4.0
8
+
9
+ ### Added
10
+
11
+ - Dedicated Redis connection pool for the gem's own threads (`redis_pool_size`, default automatic; `redis_pool_timeout`, default 5 seconds). Registry writes, stats, and job pushes made from the processor's completion worker threads and the monitor thread no longer go through Sidekiq's small internal pool, which was a serialization point under load and could time out and lose completions.
12
+ - Named processors: declare profiles with `config.processor(:llm, max_connections: 200)` and route requests with `PatientHttp::Sidekiq.execute(request, processor: :llm)`, a `processor:` option on the request itself, or `with_sidekiq_options("processor" => "llm")`. Each profile runs as an independent processor with its own capacity, timeouts, and threads, so one workload class cannot starve another. The processor name is serialized into the job arguments, so retries and crash recovery keep their routing. Jobs from older gem versions run on the `:default` processor.
13
+ - Local stats aggregation (`stats_flush_interval`, default 5 seconds; 0 restores synchronous writes). Request metrics accumulate in memory and flush to Redis in one pipelined write per interval, removing the per-request write to the shared totals hash key. `get_totals` reports per-processor requests, duration, errors, and capacity rejections when more than one profile is configured.
14
+ - A result that can never be delivered no longer keeps its crash-recovery record. Delivery is retried and then recovered as before, but a failure that means the result cannot be serialized (`JSON::GeneratorError` and the `Encoding` errors, in the failure or in its cause chain) is permanent: the request is counted as an `undeliverable_result` error and its job is moved to the Sidekiq dead set. Such a request previously stayed in the registry, counted as in flight, and was re-enqueued and failed again after every process restart.
15
+ - The Web UI dashboard reports the high-water mark of requests in flight for each processor. The count only rises when a processor accepts a request, so the mark is recorded exactly rather than sampled, and it costs one comparison in memory. It is the most requests one process held at once, so it compares with `max_connections`, which is also per process. Like the other statistics it covers everything since they were last cleared.
16
+ - The Web UI dashboard lists the requests that have been in flight the longest, with the URL, HTTP method, processor, and age of each. The details are written next to the crash-recovery record, so a request left behind by a process that died stays listed until the orphan collector re-enqueues it. The URL is sanitized first: the user name, password, query string, and fragment are removed. Use `config.inflight_url_sanitizer` to redact more, or `config.inflight_details = false` to record nothing.
17
+ - The Web UI dashboard breaks the statistics down by processor when more than one profile is configured, reporting each processor's inflight requests, capacity, utilization, requests, errors, capacity rejections, and average duration. Each process publishes the capacity of its processors with its heartbeat, which the monitor thread now sends on every pass so the inflight counts stay current.
18
+ - Capacity fast path: requests are rejected with a cheap in-memory capacity check before any Redis registration. A rejection previously cost three Redis round trips (register, unregister, stat); it now costs none.
19
+ - The `completion_failed` processor event is handled by keeping the crash-recovery registry entry, so a request whose result could not be delivered is re-enqueued by the orphan collector instead of being silently lost, unless the failure is permanent (see above). Requires patient_http 1.5.0.
20
+ - A warning is logged at startup when the hiredis Redis driver is detected, because its blocking I/O can stall the reactor thread if application code calls Redis from processor callbacks.
21
+
22
+ ### Changed
23
+
24
+ - The orphan collector removes orphans in batches of 100 with a single Lua call per batch (invoked by `EVALSHA`), instead of one `EVAL` with the full script body per orphan. Releasing the collector's lock is now a single compare-and-delete script call instead of a WATCH/GET/MULTI sequence.
25
+ - The shutdown re-enqueue path no longer unregisters a task twice or records a completion stat for a request that never completed.
26
+ - The task monitor thread and stats are now shared across all processors in the process; `ProcessorObserver.new` takes `stats:` and `task_monitor:` keyword arguments.
27
+ - The `patient_http` dependency floor is now 1.5.0.
28
+
7
29
  ## 1.3.0
8
30
 
9
31
  ### Added
data/README.md CHANGED
@@ -260,6 +260,39 @@ When a request is made in a process where the processor is running (normally a S
260
260
 
261
261
  You can turn this off with `config.direct_execution = false`. Do this if you route all requests to a dedicated queue with `config.sidekiq_options`, if you need Sidekiq client or server middleware to run for every request, or if you want every request to be visible as an enqueued job in Sidekiq metrics and the Web UI.
262
262
 
263
+ ### Named Processors
264
+
265
+ By default all requests share one processor and one `max_connections` cap. When one process serves workload classes with very different profiles (for example, large slow API calls and small fast webhook deliveries), a burst of one class can consume all of the capacity the other class needs. Named processor profiles isolate them:
266
+
267
+ ```ruby
268
+ PatientHttp::Sidekiq.configure do |config|
269
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
270
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
271
+ end
272
+ ```
273
+
274
+ Each profile runs as an independent processor in the process, with its own capacity, timeouts, and threads. Profile options override the top-level configuration; anything not overridden (secrets, preprocessors, payload stores, encryption, logger) is shared. The `:default` processor always exists; declare `config.processor(:default, ...)` to override its options.
275
+
276
+ Route a request to a processor in any of these ways:
277
+
278
+ ```ruby
279
+ # Explicit option on execute
280
+ PatientHttp::Sidekiq.execute(request, callback: MyCallback, processor: :llm)
281
+
282
+ # On the request itself (survives serialization, retries, and crash recovery)
283
+ request = PatientHttp::Request.new(:get, url, processor: :llm)
284
+
285
+ # Through a request template
286
+ template = PatientHttp::RequestTemplate.new(base_url: url, processor: :llm)
287
+
288
+ # Scoped for a block
289
+ PatientHttp::Sidekiq.with_sidekiq_options("processor" => "webhooks") do
290
+ PatientHttp::Sidekiq.execute(request, callback: MyCallback)
291
+ end
292
+ ```
293
+
294
+ The processor name is serialized into the job arguments, so Sidekiq retries and crash recovery keep their routing. A job that names a processor that is not configured in the executing process raises `PatientHttp::UnknownProcessorError` and goes through the normal Sidekiq retry mechanism; this makes new profile names safe to roll out gradually. Jobs enqueued by older gem versions run on the `:default` processor.
295
+
263
296
  ### Using Request Templates
264
297
 
265
298
  For repeated requests to the same API, use `PatientHttp::RequestTemplate` to share configuration:
@@ -477,12 +510,42 @@ PatientHttp::Sidekiq.configure do |config|
477
510
  # (use PatientHttp::Sidekiq.with_sidekiq_options to override per request)
478
511
  config.sidekiq_options = {queue: "patient_http", retry: 5}
479
512
 
513
+ # Whether the URL, HTTP method, and processor of each in-flight request are
514
+ # recorded so the Web UI can list them (default: true).
515
+ config.inflight_details = true
516
+
517
+ # Sanitizer applied to a URL before it is recorded. Without one, the user
518
+ # name, password, query string, and fragment are removed.
519
+ config.inflight_url_sanitizer { |url| url.sub(%r{/users/\d+}, "/users/:id") }
520
+
480
521
  # Whether requests made in a process with a running processor skip the
481
522
  # Sidekiq queue and go straight to the processor (default: true).
482
523
  # Sidekiq options, including a queue, do not apply to direct-executed
483
524
  # requests; set this to false to route every request through the queue.
484
525
  config.direct_execution = true
485
526
 
527
+ # Size of the gem's dedicated Redis pool used by its own threads
528
+ # (default: nil, sized automatically from completion_threads with a
529
+ # floor of 10)
530
+ config.redis_pool_size = nil
531
+
532
+ # Checkout timeout in seconds for the dedicated Redis pool (default: 5)
533
+ config.redis_pool_timeout = 5
534
+
535
+ # Seconds between flushes of locally aggregated stats to Redis
536
+ # (default: 5; 0 writes every event synchronously)
537
+ config.stats_flush_interval = 5
538
+
539
+ # Number of threads that decode responses and deliver results (default: 2)
540
+ config.completion_threads = 2
541
+
542
+ # Maximum connections per host (default: nil, unlimited)
543
+ config.max_connections_per_host = 32
544
+
545
+ # Named processor profiles for workload isolation (see Named Processors)
546
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
547
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
548
+
486
549
  # Handler called when a callback job exhausts all Sidekiq retries
487
550
  config.on_retries_exhausted { |error| MyAlertService.notify(error) }
488
551
 
@@ -507,6 +570,13 @@ See the [Configuration](lib/patient_http/sidekiq/configuration.rb) class for all
507
570
  - `connection_timeout`: Set this if you need to fail fast on connection establishment. Useful for detecting network issues quickly.
508
571
  - `retries`: Number of times to retry a failed request before calling the error callback.
509
572
  - `max_response_size`: Set this to limit the maximum size of HTTP responses. This helps prevent excessive memory usage from unexpectedly large responses. Responses need to be serialized to Redis as Sidekiq jobs and very large responses may cause performance issues in Redis. If a response body is text content, it will be compressed to save space in Redis. However, binary content needs to be Base64 encoded which increases size by ~33%.
573
+ - `max_connections_per_host`: Bounds sockets per host. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections; raise the limit if needed.
574
+ - `shutdown_timeout`: Must be below the process supervisor's termination window so the drain finishes before a hard kill. The default derives it from Sidekiq's own shutdown timeout; check any additional supervisor (container orchestrator, init system) stop timeout as well.
575
+ - `completion_threads`: Increase when result callbacks do heavier work (serialization, encryption) and completions back up behind them.
576
+ - `redis_pool_size`: The automatic size covers the gem's own threads. Increase it when a high request rate makes registration or completion pushes wait on checkouts.
577
+
578
+ > [!WARNING]
579
+ > Do not install `hiredis-client` in processes that run the async processor. The hiredis driver performs blocking I/O that does not yield to the fiber scheduler, so a Redis call made on the reactor thread (for example, from a custom processor observer) stalls every in-flight HTTP request. The gem logs a warning at startup when it detects the hiredis driver.
510
580
 
511
581
  > [!IMPORTANT]
512
582
  >
@@ -530,10 +600,41 @@ mount Sidekiq::Web => "/sidekiq"
530
600
  ```
531
601
 
532
602
  The Web UI shows:
533
- - Total requests, errors, and average duration
534
- - Current capacity utilization
603
+ - Total requests, errors, average duration, and current capacity utilization
604
+ - Per-processor capacity, utilization, requests, errors, average duration, and the high-water mark of
605
+ requests in flight, when more than one [named processor](#named-processors) is configured
606
+ - The requests that have been in flight the longest, with their URL, HTTP method, processor, and age
535
607
  - Per-process inflight request counts
536
608
 
609
+ The per-processor numbers come from the capacity each process publishes with its heartbeat, so they
610
+ cover the processes that are currently running and can lag by a few seconds.
611
+
612
+ The high-water mark is the most requests one process held on that processor at once, so compare it
613
+ with `max_connections`, which is also per process, rather than with the capacity column, which is the
614
+ sum across the running processes. The count only rises when a processor accepts a request, so the
615
+ mark is exact rather than sampled. It covers everything since the statistics were last cleared.
616
+
617
+ #### In-Flight Requests
618
+
619
+ The URL, HTTP method, and processor of each in-flight request are recorded next to its
620
+ crash-recovery record, and the dashboard lists the 50 oldest. A request left behind by a process
621
+ that died stays listed until the orphan collector re-enqueues it, so this is also where you see what
622
+ a process was working on when it stopped.
623
+
624
+ The URL is sanitized before it is recorded: the user name, password, query string, and fragment are
625
+ removed and the scheme, host, and path are kept. Paths can still carry identifiers, so you can
626
+ redact more, or record nothing at all:
627
+
628
+ ```ruby
629
+ PatientHttp::Sidekiq.configure do |config|
630
+ # Redact more of the URL.
631
+ config.inflight_url_sanitizer { |url| url.sub(%r{/users/\d+}, "/users/:id") }
632
+
633
+ # Or keep URLs out of Redis entirely.
634
+ config.inflight_details = false
635
+ end
636
+ ```
637
+
537
638
  ### Callbacks for Custom Monitoring
538
639
 
539
640
  You can register callbacks to integrate with your monitoring system using the `after_completion` and `after_error` hooks:
@@ -609,6 +710,8 @@ This ensures that if a Sidekiq process crashes, its in-flight requests will be r
609
710
 
610
711
  Crash recovery gives at-least-once delivery. If a process crashes at the wrong moment (for example, between a re-enqueue and the removal of the registry entry), a request can execute more than once and its callback can fire more than once. Make your callbacks idempotent. A request is durable once the call that submits it returns; a crash during the call behaves like a failed enqueue, and the caller never received an acknowledgment.
611
712
 
713
+ A request whose result cannot be handed to a callback job keeps its registry entry as well, so the same recovery re-enqueues it. When the failure is one that trying again cannot fix, because the result cannot be serialized, the request is not kept: it is counted as an `undeliverable_result` error and its job is moved to the Sidekiq dead set, where you can inspect it and retry it by hand.
714
+
612
715
  ## Testing
613
716
 
614
717
  The gem supports `Sidekiq::Testing.inline!` mode for synchronous testing. When in inline mode, async HTTP requests are executed immediately within the worker thread, blocking until completion. This allows you to write tests that verify the full request/response cycle without needing the async processor to be running.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.3.0
1
+ 1.4.0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "delegate"
4
+
3
5
  module PatientHttp
4
6
  module Sidekiq
5
7
  # Configuration for the Sidekiq integration.
@@ -28,6 +30,21 @@ module PatientHttp
28
30
  # current process instead of being enqueued through Sidekiq
29
31
  attr_reader :direct_execution
30
32
 
33
+ # @return [Boolean] Whether the URL, HTTP method, and processor of each in-flight
34
+ # request are recorded so the Web UI can list them
35
+ attr_reader :inflight_details
36
+
37
+ # @return [Integer, nil] Size of the gem's dedicated Redis pool; nil sizes it
38
+ # automatically from completion_threads
39
+ attr_reader :redis_pool_size
40
+
41
+ # @return [Numeric] Checkout timeout in seconds for the gem's dedicated Redis pool
42
+ attr_reader :redis_pool_timeout
43
+
44
+ # @return [Numeric] Seconds between flushes of locally aggregated stats to Redis
45
+ # (0 flushes synchronously on every recorded event)
46
+ attr_reader :stats_flush_interval
47
+
31
48
  # @return [#call, nil] Handler invoked when a CallbackWorker job exhausts all retries.
32
49
  # @overload on_retries_exhausted
33
50
  # Returns the current handler.
@@ -67,6 +84,10 @@ module PatientHttp
67
84
  payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD,
68
85
  on_retries_exhausted: nil,
69
86
  direct_execution: true,
87
+ redis_pool_size: nil,
88
+ redis_pool_timeout: 5,
89
+ stats_flush_interval: 5,
90
+ inflight_details: true,
70
91
  **pool_options
71
92
  )
72
93
  pool_options[:shutdown_timeout] ||= (::Sidekiq.default_configuration[:timeout] || 25) - 2
@@ -75,12 +96,17 @@ module PatientHttp
75
96
  super(**pool_options)
76
97
 
77
98
  @observers = []
99
+ @processor_profiles = {default: {}}
78
100
  self.sidekiq_options = sidekiq_options
79
101
  self.heartbeat_interval = heartbeat_interval
80
102
  self.orphan_threshold = orphan_threshold
81
103
  self.payload_store_threshold = payload_store_threshold || DEFAULT_PAYLOAD_STORE_THRESHOLD
82
104
  self.on_retries_exhausted = on_retries_exhausted
83
105
  self.direct_execution = direct_execution
106
+ self.redis_pool_size = redis_pool_size
107
+ self.redis_pool_timeout = redis_pool_timeout
108
+ self.stats_flush_interval = stats_flush_interval
109
+ self.inflight_details = inflight_details
84
110
  end
85
111
 
86
112
  # Set the on_retries_exhausted handler.
@@ -170,6 +196,161 @@ module PatientHttp
170
196
  @direct_execution
171
197
  end
172
198
 
199
+ # Set the size of the gem's dedicated Redis pool.
200
+ #
201
+ # @param value [Integer, nil] pool size; nil sizes the pool automatically
202
+ # @return [void]
203
+ def redis_pool_size=(value)
204
+ if value.nil?
205
+ @redis_pool_size = nil
206
+ return
207
+ end
208
+
209
+ validate_positive_integer(:redis_pool_size, value)
210
+ @redis_pool_size = value
211
+ end
212
+
213
+ # Set the checkout timeout for the gem's dedicated Redis pool.
214
+ #
215
+ # @param value [Numeric] timeout in seconds
216
+ # @return [void]
217
+ def redis_pool_timeout=(value)
218
+ unless value.is_a?(Numeric) && value.positive?
219
+ raise ArgumentError.new("redis_pool_timeout must be a positive number, got: #{value.inspect}")
220
+ end
221
+
222
+ @redis_pool_timeout = value
223
+ end
224
+
225
+ # Set the flush interval for locally aggregated stats.
226
+ #
227
+ # @param value [Numeric] seconds between flushes; 0 flushes synchronously on
228
+ # every recorded event
229
+ # @return [void]
230
+ def stats_flush_interval=(value)
231
+ unless value.is_a?(Numeric) && value >= 0
232
+ raise ArgumentError.new("stats_flush_interval must be a non-negative number, got: #{value.inspect}")
233
+ end
234
+
235
+ @stats_flush_interval = value
236
+ end
237
+
238
+ # Set whether the details of each in-flight request are recorded.
239
+ #
240
+ # The URL (see +inflight_url_sanitizer+), HTTP method, and processor name are
241
+ # written next to the crash-recovery record so the Web UI can list the requests
242
+ # that are in flight. Turn this off to keep URLs out of Redis entirely.
243
+ #
244
+ # @param value [Boolean] true to record the details
245
+ # @return [void]
246
+ def inflight_details=(value)
247
+ @inflight_details = !!value
248
+ end
249
+
250
+ # Check if in-flight request details are recorded.
251
+ #
252
+ # @return [Boolean]
253
+ def inflight_details?
254
+ @inflight_details
255
+ end
256
+
257
+ # Set the sanitizer applied to a request URL before it is recorded for the
258
+ # Web UI, or read the current one.
259
+ #
260
+ # The sanitizer receives the full URL and returns what to display. Without
261
+ # one, the user name, password, query string, and fragment are removed and
262
+ # the scheme, host, and path are kept. Use it to redact more, such as an
263
+ # identifier in the path.
264
+ #
265
+ # @example
266
+ # config.inflight_url_sanitizer { |url| url.sub(%r{/users/\d+}, "/users/:id") }
267
+ #
268
+ # @overload inflight_url_sanitizer
269
+ # @return [#call, nil] the current sanitizer
270
+ # @overload inflight_url_sanitizer(&block)
271
+ # @yield [url] block that returns the URL to display
272
+ # @yieldparam url [String] the full request URL
273
+ def inflight_url_sanitizer(&block)
274
+ if block
275
+ @inflight_url_sanitizer = block
276
+ else
277
+ @inflight_url_sanitizer
278
+ end
279
+ end
280
+
281
+ # Set the sanitizer applied to a request URL before it is recorded.
282
+ #
283
+ # @param value [#call, nil] a callable object, or nil to use the default
284
+ # @raise [ArgumentError] If value is not callable and not nil
285
+ def inflight_url_sanitizer=(value)
286
+ if value && !value.respond_to?(:call)
287
+ raise ArgumentError.new("inflight_url_sanitizer must respond to #call, got: #{value.class}")
288
+ end
289
+
290
+ @inflight_url_sanitizer = value
291
+ end
292
+
293
+ # Declare a named processor profile, or read one back.
294
+ #
295
+ # Each profile becomes an independent processor with its own capacity,
296
+ # timeouts, and threads. Options are overrides applied on top of this
297
+ # configuration's HTTP pool options. Requests select a processor with the
298
+ # +processor:+ option on +PatientHttp::Sidekiq.execute+ (or on the
299
+ # request itself). The +:default+ profile always exists; declaring it
300
+ # overrides options for the default processor.
301
+ #
302
+ # @example
303
+ # PatientHttp::Sidekiq.configure do |config|
304
+ # config.processor(:llm, max_connections: 200, request_timeout: 120)
305
+ # config.processor(:webhooks, max_connections: 64, request_timeout: 10)
306
+ # end
307
+ #
308
+ # @param name [Symbol, String] the processor name
309
+ # @param options [Hash] overrides for PatientHttp::Configuration options
310
+ # @return [Hash] the stored options for the profile
311
+ def processor(name, **options)
312
+ key = normalize_processor_name(name)
313
+
314
+ if options.any?
315
+ validate_profile_options!(options)
316
+ @processor_profiles[key] = options
317
+ end
318
+
319
+ @processor_profiles[key]
320
+ end
321
+
322
+ # All declared processor profiles. Always includes :default.
323
+ #
324
+ # @return [Hash{Symbol => Hash}] profile options by processor name
325
+ def processor_profiles
326
+ @processor_profiles.dup
327
+ end
328
+
329
+ # Whether more than one processor profile is declared.
330
+ #
331
+ # @return [Boolean]
332
+ def multiple_processors?
333
+ @processor_profiles.size > 1
334
+ end
335
+
336
+ # Build the effective configuration for a named processor. The default
337
+ # profile with no overrides is this configuration itself; other profiles
338
+ # get a view of this configuration with their overrides applied, so they
339
+ # share secrets, preprocessors, payload stores, and encryption.
340
+ #
341
+ # @param name [Symbol, String] the processor name
342
+ # @return [PatientHttp::Configuration] the configuration for the processor
343
+ # @raise [ArgumentError] if the profile is not declared
344
+ def processor_config(name)
345
+ key = normalize_processor_name(name)
346
+ profile = @processor_profiles[key]
347
+ raise ArgumentError.new("Unknown processor profile: #{name.inspect}") unless profile
348
+
349
+ return self if profile.empty?
350
+
351
+ ProfileConfiguration.new(self, profile)
352
+ end
353
+
173
354
  # Convert to hash for inspection
174
355
  # @return [Hash] hash representation with string keys
175
356
  def to_h
@@ -179,12 +360,61 @@ module PatientHttp
179
360
  "orphan_threshold" => orphan_threshold,
180
361
  "sidekiq_options" => sidekiq_options,
181
362
  "direct_execution" => direct_execution,
182
- "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil
363
+ "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil,
364
+ "redis_pool_size" => redis_pool_size,
365
+ "redis_pool_timeout" => redis_pool_timeout,
366
+ "stats_flush_interval" => stats_flush_interval,
367
+ "inflight_details" => inflight_details,
368
+ "processor_profiles" => processor_profiles.keys.map(&:to_s)
183
369
  )
184
370
  end
185
371
 
372
+ # View of a base configuration with a profile's option overrides applied.
373
+ # Everything not overridden (secrets, preprocessors, payload stores,
374
+ # logging) delegates to the base configuration, so all processors share
375
+ # those registries.
376
+ #
377
+ # Overrides are applied through a real PatientHttp::Configuration so that
378
+ # each option's writer performs its own normalization. Readers whose
379
+ # value a writer derives from an overridden option are taken from that
380
+ # configuration as well, so an override cannot be half applied.
381
+ class ProfileConfiguration < SimpleDelegator
382
+ # Readers populated as a side effect of another option's writer.
383
+ DERIVED_READERS = {
384
+ encryption_key: [:encryption, :decryption, :encryptor]
385
+ }.freeze
386
+
387
+ def initialize(base_configuration, overrides)
388
+ super(base_configuration)
389
+ normalized = PatientHttp::Configuration.new(**overrides)
390
+ readers = overrides.keys.flat_map { |key| [key.to_sym, *DERIVED_READERS[key.to_sym]] }.uniq
391
+ readers.each do |reader|
392
+ next unless normalized.respond_to?(reader)
393
+
394
+ define_singleton_method(reader) do |*args, &block|
395
+ normalized.public_send(reader, *args, &block)
396
+ end
397
+ end
398
+ end
399
+ end
400
+
186
401
  private
187
402
 
403
+ # Profile options must be valid PatientHttp::Configuration options. A
404
+ # throwaway configuration exercises each option's own validation.
405
+ def validate_profile_options!(options)
406
+ PatientHttp::Configuration.new(**options)
407
+ rescue ArgumentError => e
408
+ raise ArgumentError.new("Invalid processor profile options: #{e.message}")
409
+ end
410
+
411
+ def normalize_processor_name(name)
412
+ key = name.to_s
413
+ raise ArgumentError.new("processor name cannot be empty") if key.empty?
414
+
415
+ key.to_sym
416
+ end
417
+
188
418
  def apply_sidekiq_options(options)
189
419
  PatientHttp::Sidekiq::RequestWorker.sidekiq_options(options)
190
420
  PatientHttp::Sidekiq::CallbackWorker.sidekiq_options(options)
@@ -23,7 +23,9 @@ module PatientHttp
23
23
  #
24
24
  # @return [String] the job ID
25
25
  def retry
26
- RequestWorker.perform_async(*@args)
26
+ PatientHttp::Sidekiq.with_redis_pool do
27
+ RequestWorker.perform_async(*@args)
28
+ end
27
29
  end
28
30
 
29
31
  private