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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +496 -0
- data/CHANGELOG.md +16 -0
- data/MIT-LICENSE +20 -0
- data/README.md +620 -0
- data/VERSION +1 -0
- data/lib/patient_http/sidekiq/callback_worker.rb +96 -0
- data/lib/patient_http/sidekiq/configuration.rb +175 -0
- data/lib/patient_http/sidekiq/context.rb +61 -0
- data/lib/patient_http/sidekiq/lifecycle_hooks.rb +42 -0
- data/lib/patient_http/sidekiq/processor_observer.rb +49 -0
- data/lib/patient_http/sidekiq/request_executor.rb +104 -0
- data/lib/patient_http/sidekiq/request_worker.rb +57 -0
- data/lib/patient_http/sidekiq/stats.rb +119 -0
- data/lib/patient_http/sidekiq/task_handler.rb +81 -0
- data/lib/patient_http/sidekiq/task_monitor.rb +542 -0
- data/lib/patient_http/sidekiq/task_monitor_thread.rb +154 -0
- data/lib/patient_http/sidekiq/web_ui/assets/patient-http/css/patient_http.css +249 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ar.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/cs.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/da.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/de.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/el.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/en.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/es.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/fa.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/fr.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/gd.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/he.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/hi.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/it.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ja.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ko.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/lt.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/nb.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/nl.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/pl.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/pt-BR.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/pt.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ru.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/sv.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ta.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/tr.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/uk.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ur.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/vi.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/zh-CN.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/zh-TW.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/views/patient_http.html.erb +142 -0
- data/lib/patient_http/sidekiq/web_ui.rb +69 -0
- data/lib/patient_http/sidekiq.rb +328 -0
- data/lib/patient_http-sidekiq.rb +3 -0
- data/patient_http-sidekiq.gemspec +46 -0
- 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
|