patient_http-sidekiq 1.0.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 (54) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +496 -0
  3. data/CHANGELOG.md +16 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +620 -0
  6. data/VERSION +1 -0
  7. data/lib/patient_http/sidekiq/callback_worker.rb +96 -0
  8. data/lib/patient_http/sidekiq/configuration.rb +175 -0
  9. data/lib/patient_http/sidekiq/context.rb +61 -0
  10. data/lib/patient_http/sidekiq/lifecycle_hooks.rb +42 -0
  11. data/lib/patient_http/sidekiq/processor_observer.rb +49 -0
  12. data/lib/patient_http/sidekiq/request_executor.rb +104 -0
  13. data/lib/patient_http/sidekiq/request_worker.rb +57 -0
  14. data/lib/patient_http/sidekiq/stats.rb +119 -0
  15. data/lib/patient_http/sidekiq/task_handler.rb +81 -0
  16. data/lib/patient_http/sidekiq/task_monitor.rb +542 -0
  17. data/lib/patient_http/sidekiq/task_monitor_thread.rb +154 -0
  18. data/lib/patient_http/sidekiq/web_ui/assets/patient-http/css/patient_http.css +249 -0
  19. data/lib/patient_http/sidekiq/web_ui/locales/ar.yml +26 -0
  20. data/lib/patient_http/sidekiq/web_ui/locales/cs.yml +26 -0
  21. data/lib/patient_http/sidekiq/web_ui/locales/da.yml +26 -0
  22. data/lib/patient_http/sidekiq/web_ui/locales/de.yml +26 -0
  23. data/lib/patient_http/sidekiq/web_ui/locales/el.yml +26 -0
  24. data/lib/patient_http/sidekiq/web_ui/locales/en.yml +26 -0
  25. data/lib/patient_http/sidekiq/web_ui/locales/es.yml +26 -0
  26. data/lib/patient_http/sidekiq/web_ui/locales/fa.yml +26 -0
  27. data/lib/patient_http/sidekiq/web_ui/locales/fr.yml +26 -0
  28. data/lib/patient_http/sidekiq/web_ui/locales/gd.yml +26 -0
  29. data/lib/patient_http/sidekiq/web_ui/locales/he.yml +26 -0
  30. data/lib/patient_http/sidekiq/web_ui/locales/hi.yml +26 -0
  31. data/lib/patient_http/sidekiq/web_ui/locales/it.yml +26 -0
  32. data/lib/patient_http/sidekiq/web_ui/locales/ja.yml +26 -0
  33. data/lib/patient_http/sidekiq/web_ui/locales/ko.yml +26 -0
  34. data/lib/patient_http/sidekiq/web_ui/locales/lt.yml +26 -0
  35. data/lib/patient_http/sidekiq/web_ui/locales/nb.yml +26 -0
  36. data/lib/patient_http/sidekiq/web_ui/locales/nl.yml +26 -0
  37. data/lib/patient_http/sidekiq/web_ui/locales/pl.yml +26 -0
  38. data/lib/patient_http/sidekiq/web_ui/locales/pt-BR.yml +26 -0
  39. data/lib/patient_http/sidekiq/web_ui/locales/pt.yml +26 -0
  40. data/lib/patient_http/sidekiq/web_ui/locales/ru.yml +26 -0
  41. data/lib/patient_http/sidekiq/web_ui/locales/sv.yml +26 -0
  42. data/lib/patient_http/sidekiq/web_ui/locales/ta.yml +26 -0
  43. data/lib/patient_http/sidekiq/web_ui/locales/tr.yml +26 -0
  44. data/lib/patient_http/sidekiq/web_ui/locales/uk.yml +26 -0
  45. data/lib/patient_http/sidekiq/web_ui/locales/ur.yml +26 -0
  46. data/lib/patient_http/sidekiq/web_ui/locales/vi.yml +26 -0
  47. data/lib/patient_http/sidekiq/web_ui/locales/zh-CN.yml +26 -0
  48. data/lib/patient_http/sidekiq/web_ui/locales/zh-TW.yml +26 -0
  49. data/lib/patient_http/sidekiq/web_ui/views/patient_http.html.erb +142 -0
  50. data/lib/patient_http/sidekiq/web_ui.rb +69 -0
  51. data/lib/patient_http/sidekiq.rb +328 -0
  52. data/lib/patient_http-sidekiq.rb +3 -0
  53. data/patient_http-sidekiq.gemspec +46 -0
  54. metadata +140 -0
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # Sidekiq worker that invokes callback services for HTTP request results.
6
+ #
7
+ # This worker receives serialized Response or Error data and invokes the
8
+ # appropriate callback service method (+on_complete+ or +on_error+).
9
+ #
10
+ # Callback services are plain Ruby classes that define +on_complete+ and +on_error+
11
+ # instance methods:
12
+ #
13
+ # @example Callback service
14
+ # class MyCallback
15
+ # def on_complete(response)
16
+ # # Handle successful response
17
+ # User.find(response.callback_args[:user_id]).update!(data: response.json)
18
+ # end
19
+ #
20
+ # def on_error(error)
21
+ # # Handle request error
22
+ # Rails.logger.error("Request failed: #{error.message}")
23
+ # end
24
+ # end
25
+ #
26
+ # @api private
27
+ class CallbackWorker
28
+ include ::Sidekiq::Job
29
+
30
+ # Clean up externally stored payloads when job exhausts all retries.
31
+ # This prevents orphaned payload files when callbacks fail permanently.
32
+ # Also invokes the on_retries_exhausted handler if configured.
33
+ sidekiq_retries_exhausted do |job, _exception|
34
+ data = job["args"][0]
35
+ result_type = job["args"][1]
36
+
37
+ begin
38
+ handler = PatientHttp::Sidekiq.configuration.on_retries_exhausted
39
+ if handler && result_type == "error"
40
+ actual_data = if Sidekiq.external_storage.storage_ref?(data)
41
+ Sidekiq.external_storage.fetch(data)
42
+ else
43
+ data
44
+ end
45
+ actual_data = Sidekiq.decrypt(actual_data)
46
+ error = PatientHttp::Error.load(actual_data)
47
+ handler.call(error)
48
+ end
49
+ rescue => e
50
+ PatientHttp::Sidekiq.configuration.logger&.warn(
51
+ "[PatientHttp::Sidekiq] on_retries_exhausted handler failed: #{e.class.name} #{e.message}".strip
52
+ )
53
+ end
54
+
55
+ begin
56
+ ExternalStorage.delete(data)
57
+ rescue => e
58
+ PatientHttp::Sidekiq.configuration.logger&.warn(
59
+ "[PatientHttp::Sidekiq] Failed to delete stored payload for dead job: #{e.class.name} #{e.message}".strip
60
+ )
61
+ end
62
+ end
63
+
64
+ # Perform the callback invocation.
65
+ #
66
+ # @param data [Hash] Response or Error data (possibly a storage reference)
67
+ # @param result_type [String] "response" or "error" indicating the type of result
68
+ # @param callback_service_name [String] Fully qualified callback service class name
69
+ def perform(data, result_type, callback_service_name)
70
+ callback_service_class = PatientHttp::ClassHelper.resolve_class_name(callback_service_name)
71
+ callback_service = callback_service_class.new
72
+
73
+ # Fetch from external storage if needed
74
+ ref_data = Sidekiq.external_storage.storage_ref?(data) ? data : nil
75
+ actual_data = ref_data ? Sidekiq.external_storage.fetch(data) : data
76
+ actual_data = Sidekiq.decrypt(actual_data)
77
+
78
+ begin
79
+ if result_type == "response"
80
+ response = PatientHttp::Response.load(actual_data)
81
+ PatientHttp::Sidekiq.invoke_completion_callbacks(response)
82
+ callback_service.on_complete(response)
83
+ elsif result_type == "error"
84
+ error = PatientHttp::Error.load(actual_data)
85
+ PatientHttp::Sidekiq.invoke_error_callbacks(error)
86
+ callback_service.on_error(error)
87
+ else
88
+ raise ArgumentError, "Unknown result_type: #{result_type}"
89
+ end
90
+ ensure
91
+ Sidekiq.external_storage.delete(ref_data) if ref_data
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # Configuration for the Sidekiq Async HTTP gem.
6
+ #
7
+ # Wraps PatientHttp::Configuration with Sidekiq-aware defaults and adds
8
+ # Sidekiq-specific options like worker queue/retry settings.
9
+ #
10
+ # Access the underlying pool configuration via the +http_pool+ attribute.
11
+ class Configuration < PatientHttp::Configuration
12
+ # Default threshold in bytes above which payloads are stored externally
13
+ DEFAULT_PAYLOAD_STORE_THRESHOLD = 64 * 1024 # 64KB
14
+
15
+ # @return [Integer] Size threshold in bytes for external payload storage
16
+ attr_reader :payload_store_threshold
17
+
18
+ # @return [Numeric] Orphan detection threshold in seconds
19
+ attr_reader :orphan_threshold
20
+
21
+ # @return [Numeric] Heartbeat update interval in seconds
22
+ attr_reader :heartbeat_interval
23
+
24
+ # @return [Hash, nil] Sidekiq options to apply to RequestWorker and CallbackWorker
25
+ attr_reader :sidekiq_options
26
+
27
+ # @return [#call, nil] Handler invoked when a CallbackWorker job exhausts all retries.
28
+ # @overload on_retries_exhausted
29
+ # Returns the current handler.
30
+ # @return [#call, nil]
31
+ # @overload on_retries_exhausted(&block)
32
+ # Sets a block as the handler.
33
+ # @yield [error] block to execute when retries are exhausted
34
+ # @yieldparam error [PatientHttp::Error] information about the error
35
+ def on_retries_exhausted(&block)
36
+ if block
37
+ @on_retries_exhausted = block
38
+ else
39
+ @on_retries_exhausted
40
+ end
41
+ end
42
+
43
+ # @return [Array<PatientHttp::ProcessorObserver>] Registered processor observers
44
+ # Observers will be registered with the processor when it is started, allowing them to
45
+ # receive lifecycle callbacks for PatientHttp requests.
46
+ attr_reader :observers
47
+
48
+ # Initializes a new Configuration with the specified options.
49
+ #
50
+ # @param heartbeat_interval [Integer] Interval for updating inflight request heartbeats in seconds
51
+ # @param orphan_threshold [Integer] Age threshold for detecting orphaned requests in seconds
52
+ # @param sidekiq_options [Hash, nil] Sidekiq options to apply to RequestWorker and CallbackWorker
53
+ # @param on_retries_exhausted [#call, nil] Handler called when a CallbackWorker job exhausts retries
54
+ # @param pool_options [Hash] Options passed through to PatientHttp::Configuration.
55
+ # Sidekiq-aware defaults are applied for shutdown_timeout and logger
56
+ # if not explicitly provided.
57
+ def initialize(
58
+ heartbeat_interval: 60,
59
+ orphan_threshold: 300,
60
+ sidekiq_options: nil,
61
+ payload_store_threshold: DEFAULT_PAYLOAD_STORE_THRESHOLD,
62
+ on_retries_exhausted: nil,
63
+ **pool_options
64
+ )
65
+ pool_options[:shutdown_timeout] ||= (::Sidekiq.default_configuration[:timeout] || 25) - 2
66
+ pool_options[:logger] ||= ::Sidekiq.logger
67
+
68
+ super(**pool_options)
69
+
70
+ @observers = []
71
+ self.sidekiq_options = sidekiq_options
72
+ self.heartbeat_interval = heartbeat_interval
73
+ self.orphan_threshold = orphan_threshold
74
+ self.payload_store_threshold = payload_store_threshold || DEFAULT_PAYLOAD_STORE_THRESHOLD
75
+ self.on_retries_exhausted = on_retries_exhausted
76
+ end
77
+
78
+ # Set the on_retries_exhausted handler.
79
+ #
80
+ # This handler is called when a CallbackWorker job exhausts all retries.
81
+ # It receives the same arguments as the on_error callback.
82
+ #
83
+ # @param value [#call, nil] A callable object or nil to clear the handler
84
+ # @raise [ArgumentError] If value is not callable and not nil
85
+ def on_retries_exhausted=(value)
86
+ if value && !value.respond_to?(:call)
87
+ raise ArgumentError.new("on_retries_exhausted must respond to #call, got: #{value.class}")
88
+ end
89
+
90
+ @on_retries_exhausted = value
91
+ end
92
+
93
+ # Set the threshold size for external payload storage.
94
+ #
95
+ # Payloads larger than this size (in bytes) will be stored externally
96
+ # when a payload store is configured.
97
+ #
98
+ # @param value [Integer] Threshold in bytes
99
+ # @raise [ArgumentError] If value is not a positive integer
100
+ def payload_store_threshold=(value)
101
+ validate_positive_integer(:payload_store_threshold, value)
102
+ @payload_store_threshold = value
103
+ end
104
+
105
+ # Set the heartbeat interval for crash recovery.
106
+ #
107
+ # @param value [Numeric] interval in seconds (must be positive)
108
+ # @raise [ArgumentError] if value is not positive or not less than orphan_threshold
109
+ def heartbeat_interval=(value)
110
+ raise ArgumentError.new("heartbeat_interval must be positive, got: #{value.inspect}") unless value.positive?
111
+
112
+ @heartbeat_interval = value
113
+ validate_heartbeat_and_threshold
114
+ end
115
+
116
+ # Set the orphan detection threshold for crash recovery.
117
+ #
118
+ # @param value [Numeric] threshold in seconds (must be positive and greater than heartbeat_interval)
119
+ # @raise [ArgumentError] if value is not positive or not greater than heartbeat_interval
120
+ def orphan_threshold=(value)
121
+ raise ArgumentError.new("orphan_threshold must be positive, got: #{value.inspect}") unless value.positive?
122
+
123
+ @orphan_threshold = value
124
+ validate_heartbeat_and_threshold
125
+ end
126
+
127
+ # Set Sidekiq worker options and apply them to RequestWorker and CallbackWorker.
128
+ # The options will be applied to both workers. If you want to customize just
129
+ # one of them, set the options directly on that worker class.
130
+ #
131
+ # @param options [Hash, nil] Sidekiq options hash
132
+ # @return [void]
133
+ def sidekiq_options=(options)
134
+ if options.nil?
135
+ @sidekiq_options = nil
136
+ return
137
+ end
138
+
139
+ unless options.is_a?(Hash)
140
+ raise ArgumentError.new("sidekiq_options must be a Hash, got: #{options.class}")
141
+ end
142
+
143
+ @sidekiq_options = options
144
+ apply_sidekiq_options(options)
145
+ end
146
+
147
+ # Convert to hash for inspection
148
+ # @return [Hash] hash representation with string keys
149
+ def to_h
150
+ super.merge(
151
+ "payload_store_threshold" => payload_store_threshold,
152
+ "heartbeat_interval" => heartbeat_interval,
153
+ "orphan_threshold" => orphan_threshold,
154
+ "sidekiq_options" => sidekiq_options,
155
+ "on_retries_exhausted" => on_retries_exhausted ? "defined" : nil
156
+ )
157
+ end
158
+
159
+ private
160
+
161
+ def apply_sidekiq_options(options)
162
+ PatientHttp::Sidekiq::RequestWorker.sidekiq_options(options)
163
+ PatientHttp::Sidekiq::CallbackWorker.sidekiq_options(options)
164
+ end
165
+
166
+ def validate_heartbeat_and_threshold
167
+ return unless @heartbeat_interval && @orphan_threshold
168
+
169
+ return unless @heartbeat_interval >= @orphan_threshold
170
+
171
+ raise ArgumentError.new("heartbeat_interval (#{@heartbeat_interval}) must be less than orphan_threshold (#{@orphan_threshold})")
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # Provides thread-safe context for Sidekiq jobs.
6
+ #
7
+ # This class manages the current Sidekiq job context using a thread-id keyed hash,
8
+ # allowing async HTTP requests to access job information without it being passed explicitly.
9
+ # Only RequestWorker needs this context for re-enqueueing jobs.
10
+ class Context
11
+ # Thread-safe hash keyed by thread object_id
12
+ @jobs = Concurrent::Map.new
13
+
14
+ # Sidekiq server middleware that sets the current job context.
15
+ #
16
+ # This middleware only activates for RequestWorker, which is the only
17
+ # worker that needs access to the job context for re-enqueueing.
18
+ class Middleware
19
+ include ::Sidekiq::ServerMiddleware
20
+
21
+ def call(worker, job, queue)
22
+ # Only set context for RequestWorker (the only worker that needs it)
23
+ if job["class"] == PatientHttp::Sidekiq::RequestWorker.name
24
+ PatientHttp::Sidekiq::Context.with_job(job) do
25
+ yield
26
+ end
27
+ else
28
+ yield
29
+ end
30
+ end
31
+ end
32
+
33
+ class << self
34
+ # Returns the current Sidekiq job hash from context.
35
+ #
36
+ # @return [Hash, nil] the current job hash or nil if no job context is set
37
+ def current_job
38
+ @jobs[Thread.current.object_id]
39
+ end
40
+
41
+ # Sets the current job context for the duration of the block.
42
+ #
43
+ # @param job [Hash] the Sidekiq job hash
44
+ # @yield executes the block with the job context set
45
+ # @return [Object] the return value of the block
46
+ def with_job(job)
47
+ thread_id = Thread.current.object_id
48
+ previous_job = @jobs[thread_id]
49
+ @jobs[thread_id] = job
50
+ yield
51
+ ensure
52
+ if previous_job
53
+ @jobs[thread_id] = previous_job
54
+ else
55
+ @jobs.delete(thread_id)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sidekiq server lifecycle hooks for automatic Sidekiq processor management.
4
+ #
5
+ # This class registers lifecycle hooks with Sidekiq to automatically start, drain,
6
+ # and stop the Sidekiq processor along with the Sidekiq server.
7
+ ##
8
+ # The hooks will:
9
+ # - Start the processor when Sidekiq server starts (:startup event)
10
+ # - Drain the processor when Sidekiq receives TSTP signal (:quiet event)
11
+ # - Stop the processor when Sidekiq shuts down (:shutdown event)
12
+ module PatientHttp
13
+ module Sidekiq
14
+ class LifecycleHooks
15
+ @registered = false
16
+
17
+ class << self
18
+ def register
19
+ return if @registered
20
+
21
+ PatientHttp::Sidekiq.append_middleware
22
+
23
+ ::Sidekiq.configure_server do |config|
24
+ config.on(:startup) do
25
+ PatientHttp::Sidekiq.start
26
+ end
27
+
28
+ config.on(:quiet) do
29
+ PatientHttp::Sidekiq.quiet
30
+ end
31
+
32
+ config.on(:shutdown) do
33
+ PatientHttp::Sidekiq.stop
34
+ end
35
+
36
+ @registered = true
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # Procesor Observer that collect stats in Redis for the WebUI and
6
+ # monitors for crashed processes in order to re-enqueue workers.
7
+ class ProcessorObserver < PatientHttp::ProcessorObserver
8
+ attr_reader :task_monitor
9
+
10
+ def initialize(processor)
11
+ @processor = processor
12
+ @stats = Stats.new(processor.config)
13
+ @task_monitor = TaskMonitor.new(processor.config)
14
+ @monitor_thread = TaskMonitorThread.new(
15
+ processor.config,
16
+ @task_monitor,
17
+ -> { @processor.inflight_request_ids }
18
+ )
19
+ end
20
+
21
+ def start
22
+ @monitor_thread.start
23
+ end
24
+
25
+ def stop
26
+ @monitor_thread.stop
27
+ task_monitor.remove_process
28
+ end
29
+
30
+ def capacity_exceeded
31
+ @stats.record_capacity_exceeded
32
+ end
33
+
34
+ def request_start(request_task)
35
+ task_monitor.register(request_task)
36
+ end
37
+
38
+ def request_end(request_task)
39
+ task_monitor.unregister(request_task)
40
+ @stats.record_request(request_task.response&.status, request_task.duration)
41
+ end
42
+
43
+ def request_error(error)
44
+ error_type = error.is_a?(PatientHttp::Error) ? error.error_type : :exception
45
+ @stats.record_error(error_type)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # Helper methods for executing HTTP requests asynchronously.
6
+ class RequestExecutor
7
+ class << self
8
+ # Execute the request directly on the async processor.
9
+ #
10
+ # This method enqueues the request directly to the async processor. It must be
11
+ # called from within a Sidekiq job context (the sidekiq_job parameter is required).
12
+ # Used internally by RequestWorker.
13
+ #
14
+ # When the request completes, the callback's +on_complete+ method is called with
15
+ # a Response object. If an error occurs (network error, timeout, or non-2xx response
16
+ # if raise_error_responses is true), the +on_error+ method is called with an Error object.
17
+ #
18
+ # @param request [Request] the HTTP request to execute
19
+ # @param callback [Class, String] Callback service class with +on_complete+ and +on_error+
20
+ # instance methods, or its fully qualified class name.
21
+ # @param sidekiq_job [Hash, nil] Sidekiq job hash with "class" and "args" keys.
22
+ # If not provided, uses PatientHttp::Sidekiq::Context.current_job.
23
+ # This requires the PatientHttp::Sidekiq::Context::Middleware to be added
24
+ # to the Sidekiq server middleware chain.
25
+ # @param synchronous [Boolean] If true, runs the request inline (for testing).
26
+ # @param callback_args [#to_h, nil] Arguments to pass to callback via the
27
+ # Response/Error object. Must respond to +to_h+ and contain only JSON-native types
28
+ # (nil, true, false, String, Integer, Float, Array, Hash). All hash keys will be
29
+ # converted to strings for serialization. Access via +response.callback_args+ or
30
+ # +error.callback_args+ using symbol or string keys.
31
+ # @param raise_error_responses [Boolean] If true, treats non-2xx responses as errors
32
+ # and calls +on_error+ instead of +on_complete+. Defaults to false.
33
+ # @param request_id [String, nil] Unique request ID for tracking. If nil, a new UUID
34
+ # will be generated.
35
+ # @return [String] the request ID
36
+ # @api private
37
+ def execute(
38
+ request,
39
+ callback:,
40
+ sidekiq_job: nil,
41
+ synchronous: false,
42
+ callback_args: nil,
43
+ raise_error_responses: false,
44
+ request_id: nil
45
+ )
46
+ sidekiq_job = validate_sidekiq_job(sidekiq_job)
47
+ config = PatientHttp::Sidekiq.configuration
48
+ task_handler = TaskHandler.new(sidekiq_job)
49
+
50
+ task = PatientHttp::RequestTask.new(
51
+ request: request,
52
+ task_handler: task_handler,
53
+ callback: callback,
54
+ callback_args: callback_args,
55
+ raise_error_responses: raise_error_responses,
56
+ id: request_id,
57
+ default_max_redirects: config.max_redirects
58
+ )
59
+
60
+ # Run the request inline if Sidekiq::Testing.inline! is enabled
61
+ if synchronous || async_disabled?
62
+ PatientHttp::SynchronousExecutor.new(
63
+ task,
64
+ config: config,
65
+ on_complete: ->(response) { PatientHttp::Sidekiq.invoke_completion_callbacks(response) },
66
+ on_error: ->(error) { PatientHttp::Sidekiq.invoke_error_callbacks(error) }
67
+ ).call
68
+ return task.id
69
+ end
70
+
71
+ # Check if processor is running
72
+ processor = PatientHttp::Sidekiq.processor
73
+ unless processor&.running?
74
+ raise PatientHttp::NotRunningError.new("Cannot enqueue request: processor is not running")
75
+ end
76
+
77
+ processor.enqueue(task)
78
+
79
+ task.id
80
+ end
81
+
82
+ private
83
+
84
+ def validate_sidekiq_job(sidekiq_job)
85
+ sidekiq_job ||= PatientHttp::Sidekiq::Context.current_job
86
+
87
+ raise ArgumentError.new("sidekiq_job is required") if sidekiq_job.nil?
88
+
89
+ raise ArgumentError.new("sidekiq_job must be a Hash, got: #{sidekiq_job.class}") unless sidekiq_job.is_a?(Hash)
90
+
91
+ raise ArgumentError.new("sidekiq_job must have 'class' key") unless sidekiq_job.key?("class")
92
+
93
+ raise ArgumentError.new("sidekiq_job must have 'args' array") unless sidekiq_job["args"].is_a?(Array)
94
+
95
+ sidekiq_job
96
+ end
97
+
98
+ def async_disabled?
99
+ defined?(::Sidekiq::Testing) && ::Sidekiq::Testing.inline?
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module Sidekiq
5
+ # Sidekiq worker for executing HTTP requests asynchronously.
6
+ #
7
+ # This worker is enqueued when calling +PatientHttp::Sidekiq.get+, +PatientHttp::Sidekiq.post+,
8
+ # etc. It allows HTTP requests to be made from anywhere in your code (not just Sidekiq jobs)
9
+ # while still processing them through the async HTTP processor.
10
+ #
11
+ # When the request completes, the specified callback service's +on_complete+ or +on_error+
12
+ # method is invoked via CallbackWorker.
13
+ #
14
+ # @api private
15
+ class RequestWorker
16
+ include ::Sidekiq::Job
17
+
18
+ # Perform the HTTP request.
19
+ #
20
+ # @param data [Hash] Request data (possibly a storage reference) with keys:
21
+ # - "http_method" [String] HTTP method (get, post, put, patch, delete)
22
+ # - "url" [String] The request URL
23
+ # - "headers" [Hash] Request headers
24
+ # - "body" [String, nil] Request body
25
+ # - "timeout" [Numeric, nil] Request timeout
26
+ # - "max_redirects" [Integer, nil] Maximum redirects to follow
27
+ # @param callback_service_name [String] Fully qualified callback service class name
28
+ # @param raise_error_responses [Boolean, nil] Whether to treat non-2xx responses as errors;
29
+ # defaults to the global config if nil
30
+ # @param callback_args [Hash, nil] Arguments to pass to the callback
31
+ # @param request_id [String, nil] Unique request ID for tracking
32
+ # @return [void]
33
+ def perform(data, callback_service_name, raise_error_responses, callback_args, request_id)
34
+ # Fetch from external storage if needed
35
+ ref_data = PatientHttp::ExternalStorage.storage_ref?(data) ? data : nil
36
+ actual_data = ref_data ? Sidekiq.external_storage.fetch(data) : data
37
+ actual_data = Sidekiq.decrypt(actual_data)
38
+
39
+ request = PatientHttp::Request.load(actual_data)
40
+ sidekiq_job = Sidekiq::Context.current_job
41
+
42
+ begin
43
+ RequestExecutor.execute(
44
+ request,
45
+ callback: callback_service_name,
46
+ raise_error_responses: raise_error_responses,
47
+ callback_args: callback_args,
48
+ sidekiq_job: sidekiq_job,
49
+ request_id: request_id
50
+ )
51
+ ensure
52
+ Sidekiq.external_storage.delete(ref_data) if ref_data
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end