patient_http 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 +322 -0
- data/CHANGELOG.md +30 -0
- data/MIT-LICENSE +20 -0
- data/README.md +653 -0
- data/VERSION +1 -0
- data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
- data/lib/patient_http/callback_args.rb +176 -0
- data/lib/patient_http/callback_validator.rb +52 -0
- data/lib/patient_http/class_helper.rb +26 -0
- data/lib/patient_http/client.rb +80 -0
- data/lib/patient_http/client_pool.rb +178 -0
- data/lib/patient_http/configuration.rb +365 -0
- data/lib/patient_http/encryptor.rb +69 -0
- data/lib/patient_http/error.rb +76 -0
- data/lib/patient_http/external_storage.rb +134 -0
- data/lib/patient_http/http_error.rb +106 -0
- data/lib/patient_http/http_headers.rb +99 -0
- data/lib/patient_http/lifecycle_manager.rb +174 -0
- data/lib/patient_http/payload.rb +160 -0
- data/lib/patient_http/payload_store/active_record_store.rb +102 -0
- data/lib/patient_http/payload_store/base.rb +150 -0
- data/lib/patient_http/payload_store/file_store.rb +92 -0
- data/lib/patient_http/payload_store/redis_store.rb +98 -0
- data/lib/patient_http/payload_store/s3_store.rb +94 -0
- data/lib/patient_http/payload_store.rb +11 -0
- data/lib/patient_http/processor.rb +538 -0
- data/lib/patient_http/processor_observer.rb +48 -0
- data/lib/patient_http/rails/engine.rb +21 -0
- data/lib/patient_http/redirect_error.rb +136 -0
- data/lib/patient_http/redirect_helper.rb +90 -0
- data/lib/patient_http/request.rb +158 -0
- data/lib/patient_http/request_error.rb +150 -0
- data/lib/patient_http/request_helper.rb +230 -0
- data/lib/patient_http/request_task.rb +308 -0
- data/lib/patient_http/request_template.rb +114 -0
- data/lib/patient_http/response.rb +183 -0
- data/lib/patient_http/response_reader.rb +135 -0
- data/lib/patient_http/synchronous_executor.rb +241 -0
- data/lib/patient_http/task_handler.rb +55 -0
- data/lib/patient_http/time_helper.rb +32 -0
- data/lib/patient_http.rb +313 -0
- data/patient_http.gemspec +48 -0
- metadata +161 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Core processor that handles async HTTP requests in a dedicated thread
|
|
5
|
+
class Processor
|
|
6
|
+
include TimeHelper
|
|
7
|
+
include RedirectHelper
|
|
8
|
+
|
|
9
|
+
# Timing constants for the reactor loop
|
|
10
|
+
DEQUEUE_TIMEOUT = 1.0 # Seconds to wait when dequeueing requests
|
|
11
|
+
|
|
12
|
+
# @return [Configuration] the configuration object for the processor
|
|
13
|
+
attr_reader :config
|
|
14
|
+
|
|
15
|
+
# Callback to invoke after each request. Only available in testing mode.
|
|
16
|
+
# @api private
|
|
17
|
+
attr_accessor :testing_callback
|
|
18
|
+
|
|
19
|
+
# Initialize the processor.
|
|
20
|
+
#
|
|
21
|
+
# @param config [Configuration] the configuration object
|
|
22
|
+
# @return [void]
|
|
23
|
+
def initialize(config)
|
|
24
|
+
@config = config
|
|
25
|
+
@lifecycle = LifecycleManager.new
|
|
26
|
+
@queue = Thread::Queue.new
|
|
27
|
+
@reactor_thread = nil
|
|
28
|
+
@inflight_requests = Concurrent::Hash.new
|
|
29
|
+
@pending_tasks = Concurrent::Hash.new
|
|
30
|
+
@tasks_lock = Mutex.new
|
|
31
|
+
@idle_condition = ConditionVariable.new
|
|
32
|
+
@testing_callback = nil
|
|
33
|
+
@http_client = Client.new(self)
|
|
34
|
+
@observers = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Start the processor.
|
|
38
|
+
#
|
|
39
|
+
# @return [void]
|
|
40
|
+
def start
|
|
41
|
+
@tasks_lock.synchronize do
|
|
42
|
+
return unless @lifecycle.start!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@reactor_thread = Thread.new do
|
|
46
|
+
Thread.current.name = "patient-http-processor"
|
|
47
|
+
run_reactor
|
|
48
|
+
rescue => e
|
|
49
|
+
@config.logger&.error("[PatientHttp] Processor error: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
50
|
+
|
|
51
|
+
raise if PatientHttp.testing?
|
|
52
|
+
ensure
|
|
53
|
+
@tasks_lock.synchronize { @lifecycle.stopped! } if @reactor_thread == Thread.current
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@tasks_lock.synchronize do
|
|
57
|
+
@lifecycle.running!
|
|
58
|
+
notify_observers { |observer| observer.start }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Block until the reactor is ready
|
|
62
|
+
@lifecycle.wait_for_reactor
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Stop the processor.
|
|
66
|
+
#
|
|
67
|
+
# @param timeout [Numeric, nil] how long to wait for in-flight requests (seconds)
|
|
68
|
+
# @return [void]
|
|
69
|
+
def stop(timeout: nil)
|
|
70
|
+
timeout ||= @config.shutdown_timeout
|
|
71
|
+
|
|
72
|
+
# Atomically transition to stopping state under lock to ensure consistency
|
|
73
|
+
# with other state-checking operations
|
|
74
|
+
@tasks_lock.synchronize do
|
|
75
|
+
return unless @lifecycle.stop!
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Interrupt the reactor's queue wait by pushing a sentinel value
|
|
79
|
+
@queue.push(nil)
|
|
80
|
+
|
|
81
|
+
# Wait for in-flight and pending requests to complete.
|
|
82
|
+
# Queue items are not checked here — they will be re-enqueued by
|
|
83
|
+
# reenqueue_remaining_queue_items after the reactor thread exits.
|
|
84
|
+
if timeout > 0
|
|
85
|
+
deadline = monotonic_time + timeout
|
|
86
|
+
@tasks_lock.synchronize do
|
|
87
|
+
loop do
|
|
88
|
+
break if @pending_tasks.empty? && @inflight_requests.empty?
|
|
89
|
+
remaining = deadline - monotonic_time
|
|
90
|
+
break if remaining <= 0
|
|
91
|
+
@idle_condition.wait(@tasks_lock, remaining)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
reenqueue_pending_requests
|
|
97
|
+
|
|
98
|
+
@reactor_thread.join(1) if @reactor_thread&.alive?
|
|
99
|
+
@reactor_thread.kill if @reactor_thread&.alive?
|
|
100
|
+
@reactor_thread = nil
|
|
101
|
+
|
|
102
|
+
# Drain any items left in the queue after the reactor has exited.
|
|
103
|
+
# This must happen after the reactor thread is done to avoid consuming
|
|
104
|
+
# the nil sentinel that wakes the reactor.
|
|
105
|
+
reenqueue_remaining_queue_items
|
|
106
|
+
|
|
107
|
+
notify_observers { |observer| observer.stop }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Drain the processor (stop accepting new requests).
|
|
111
|
+
#
|
|
112
|
+
# @return [void]
|
|
113
|
+
def drain
|
|
114
|
+
@tasks_lock.synchronize do
|
|
115
|
+
return unless @lifecycle.drain!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@config.logger&.info("[PatientHttp] Processor draining (no longer accepting new requests)")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Enqueue a request task for processing.
|
|
122
|
+
#
|
|
123
|
+
# @param task [RequestTask] the request task to enqueue
|
|
124
|
+
# @raise [NotRunningError] if processor is not running
|
|
125
|
+
# @raise [MaxCapacityError] if at max capacity
|
|
126
|
+
# @return [void]
|
|
127
|
+
def enqueue(task)
|
|
128
|
+
@tasks_lock.synchronize do
|
|
129
|
+
raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
|
|
130
|
+
|
|
131
|
+
# Check capacity - raise error if at max connections
|
|
132
|
+
total = @queue.size + @pending_tasks.size + @inflight_requests.size
|
|
133
|
+
if total >= @config.max_connections
|
|
134
|
+
notify_observers { |observer| observer.capacity_exceeded }
|
|
135
|
+
raise MaxCapacityError.new("Cannot enqueue request: already at max capacity (#{@config.max_connections} connections)")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
task.enqueued!
|
|
139
|
+
@queue.push(task)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get the current processor state.
|
|
144
|
+
#
|
|
145
|
+
# @return [Symbol] the current state
|
|
146
|
+
def state
|
|
147
|
+
@lifecycle.state
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Check if processor is starting.
|
|
151
|
+
#
|
|
152
|
+
# @return [Boolean]
|
|
153
|
+
def starting?
|
|
154
|
+
@lifecycle.starting?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Check if processor is running.
|
|
158
|
+
#
|
|
159
|
+
# @return [Boolean]
|
|
160
|
+
def running?
|
|
161
|
+
@lifecycle.running?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Check if processor is stopped.
|
|
165
|
+
#
|
|
166
|
+
# @return [Boolean]
|
|
167
|
+
def stopped?
|
|
168
|
+
@lifecycle.stopped?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Check if processor is draining.
|
|
172
|
+
#
|
|
173
|
+
# @return [Boolean]
|
|
174
|
+
def draining?
|
|
175
|
+
@lifecycle.draining?
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check if processor is drained (draining and idle).
|
|
179
|
+
#
|
|
180
|
+
# @return [Boolean]
|
|
181
|
+
def drained?
|
|
182
|
+
@lifecycle.draining? && idle?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Check if processor is stopping.
|
|
186
|
+
#
|
|
187
|
+
# @return [Boolean]
|
|
188
|
+
def stopping?
|
|
189
|
+
@lifecycle.stopping?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Check if processor is idle (no queued or in-flight requests).
|
|
193
|
+
#
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def idle?
|
|
196
|
+
@tasks_lock.synchronize do
|
|
197
|
+
@queue.empty? && @pending_tasks.empty? && @inflight_requests.empty?
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Get the number of in-flight requests (actively executing HTTP calls).
|
|
202
|
+
#
|
|
203
|
+
# This does not include queued or pending tasks. For the total pipeline
|
|
204
|
+
# count used by the capacity check, see {#total_count}.
|
|
205
|
+
#
|
|
206
|
+
# @return [Integer]
|
|
207
|
+
def inflight_count
|
|
208
|
+
@inflight_requests.size
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Get the total number of tasks in the pipeline (queued + pending + in-flight).
|
|
212
|
+
#
|
|
213
|
+
# This is the count used by {#enqueue} for capacity enforcement.
|
|
214
|
+
#
|
|
215
|
+
# @return [Integer]
|
|
216
|
+
def total_count
|
|
217
|
+
@tasks_lock.synchronize do
|
|
218
|
+
@queue.size + @pending_tasks.size + @inflight_requests.size
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Get the IDs of in-flight requests.
|
|
223
|
+
#
|
|
224
|
+
# @return [Array<String>]
|
|
225
|
+
def inflight_request_ids
|
|
226
|
+
@tasks_lock.synchronize do
|
|
227
|
+
@inflight_requests.keys
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Add an observer for processor events.
|
|
232
|
+
#
|
|
233
|
+
# @param observer [ProcessorObserver] the observer to add
|
|
234
|
+
# @return [void]
|
|
235
|
+
def observe(observer)
|
|
236
|
+
@tasks_lock.synchronize do
|
|
237
|
+
raise ArgumentError.new("Observer already added") if @observers.include?(observer)
|
|
238
|
+
|
|
239
|
+
@observers << observer
|
|
240
|
+
observer.start if starting? || running?
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Wait for the processor to start.
|
|
245
|
+
#
|
|
246
|
+
# @param timeout [Numeric] maximum time to wait in seconds (default: 5)
|
|
247
|
+
# @return [Boolean] true if started, false if timeout reached
|
|
248
|
+
# @api private
|
|
249
|
+
def wait_for_running(timeout: 5)
|
|
250
|
+
start
|
|
251
|
+
@lifecycle.wait_for_running(timeout: timeout)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Wait for the queue to be empty and all in-flight requests to complete.
|
|
255
|
+
# This is mainly for use in tests.
|
|
256
|
+
#
|
|
257
|
+
# @param timeout [Numeric] maximum time to wait in seconds (default: 5)
|
|
258
|
+
# @return [Boolean] true if processing completed, false if timeout reached
|
|
259
|
+
# @api private
|
|
260
|
+
def wait_for_idle(timeout: 1)
|
|
261
|
+
@lifecycle.wait_for_condition(timeout: timeout) { idle? }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Wait for at least one request to start processing. This is mainly for use in tests.
|
|
265
|
+
#
|
|
266
|
+
# @param timeout [Numeric] maximum time to wait in seconds (default: 5)
|
|
267
|
+
# @return [Boolean] true if a request started processing, false if timeout reached
|
|
268
|
+
# @api private
|
|
269
|
+
def wait_for_processing(timeout: 1)
|
|
270
|
+
@lifecycle.wait_for_condition(timeout: timeout) do
|
|
271
|
+
!@inflight_requests.empty? || !@pending_tasks.empty?
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Run the processor in a block. This is intended for use in tests to
|
|
276
|
+
# ensure the processor is started and stopped properly.
|
|
277
|
+
#
|
|
278
|
+
# @api private
|
|
279
|
+
def run
|
|
280
|
+
start
|
|
281
|
+
wait_for_running
|
|
282
|
+
yield
|
|
283
|
+
ensure
|
|
284
|
+
stop(timeout: 0)
|
|
285
|
+
wait_for_idle
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
private
|
|
289
|
+
|
|
290
|
+
# Run the async reactor loop.
|
|
291
|
+
#
|
|
292
|
+
# @return [void]
|
|
293
|
+
def run_reactor
|
|
294
|
+
Async do |task|
|
|
295
|
+
# Signal that the reactor is ready
|
|
296
|
+
@lifecycle.reactor_ready!
|
|
297
|
+
|
|
298
|
+
@config.logger&.info("[PatientHttp] Processor started")
|
|
299
|
+
|
|
300
|
+
# Main loop: monitor shutdown/drain and process requests
|
|
301
|
+
loop do
|
|
302
|
+
break if stopping? || stopped?
|
|
303
|
+
|
|
304
|
+
# Pop request task from queue with timeout to periodically check shutdown
|
|
305
|
+
request_task = dequeue_request(timeout: DEQUEUE_TIMEOUT)
|
|
306
|
+
next unless request_task
|
|
307
|
+
|
|
308
|
+
# Track as pending immediately to avoid race condition with stop()
|
|
309
|
+
@tasks_lock.synchronize do
|
|
310
|
+
@pending_tasks[request_task.id] = request_task
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# If we've dequeued a task, we must process it even if stopping
|
|
314
|
+
# to avoid losing the request (shutdown will handle re-enqueuing if incomplete)
|
|
315
|
+
|
|
316
|
+
# Spawn a new fiber to process this request task
|
|
317
|
+
task.async do
|
|
318
|
+
process_request(request_task)
|
|
319
|
+
rescue => e
|
|
320
|
+
@config.logger&.error("[PatientHttp] Error processing request: #{e.inspect}\n#{e.backtrace.join("\n")}")
|
|
321
|
+
|
|
322
|
+
warn(e.inspect, e.backtrace) if PatientHttp.testing?
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
@config.logger&.info("[PatientHttp] Processor stopped")
|
|
327
|
+
rescue Async::Stop
|
|
328
|
+
@config.logger&.info("[PatientHttp] Reactor received stop signal")
|
|
329
|
+
rescue => e
|
|
330
|
+
@config.logger&.error("[PatientHttp] Reactor loop error: #{e.inspect}\n#{e.backtrace.join("\n")}")
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Dequeue a request task with timeout.
|
|
335
|
+
#
|
|
336
|
+
# @param timeout [Numeric] timeout in seconds
|
|
337
|
+
# @return [RequestTask, nil] the request task or nil if timeout
|
|
338
|
+
def dequeue_request(timeout:)
|
|
339
|
+
@queue.pop(timeout: timeout)
|
|
340
|
+
rescue ThreadError
|
|
341
|
+
# Queue is empty and timeout expired
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Process a single HTTP request task.
|
|
346
|
+
#
|
|
347
|
+
# @param task [RequestTask] the request task to process
|
|
348
|
+
# @return [void]
|
|
349
|
+
def process_request(task)
|
|
350
|
+
# Move from pending to in-flight tracking
|
|
351
|
+
@tasks_lock.synchronize do
|
|
352
|
+
@pending_tasks.delete(task.id)
|
|
353
|
+
@inflight_requests[task.id] = task
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
notify_observers { |observer| observer.request_start(task) }
|
|
357
|
+
|
|
358
|
+
# Mark task as started
|
|
359
|
+
task.started!
|
|
360
|
+
response_handled = false
|
|
361
|
+
|
|
362
|
+
begin
|
|
363
|
+
response_data = @http_client.make_request(task.request, task.id)
|
|
364
|
+
|
|
365
|
+
# Return early because the body many not have been fully read.
|
|
366
|
+
return if stopping? || stopped?
|
|
367
|
+
|
|
368
|
+
# Check for redirect handling
|
|
369
|
+
if should_follow_redirect?(task, response_data)
|
|
370
|
+
handle_redirect(task, response_data)
|
|
371
|
+
response_handled = true
|
|
372
|
+
return
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
response = task.build_response(**response_data)
|
|
376
|
+
if task.raise_error_responses && !response.success?
|
|
377
|
+
http_error = HttpError.new(response)
|
|
378
|
+
notify_observers { |observer| observer.request_error(http_error) }
|
|
379
|
+
handle_error(task, http_error)
|
|
380
|
+
else
|
|
381
|
+
handle_completion(task, response)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
response_handled = true
|
|
385
|
+
rescue => e
|
|
386
|
+
notify_observers { |observer| observer.request_error(e) }
|
|
387
|
+
handle_error(task, e)
|
|
388
|
+
response_handled = true
|
|
389
|
+
ensure
|
|
390
|
+
cleanup_after_task(task, response_handled)
|
|
391
|
+
@testing_callback&.call(task) if PatientHttp.testing?
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Cleanup after request processing.
|
|
396
|
+
#
|
|
397
|
+
# @param task [RequestTask] the request task
|
|
398
|
+
# @return [void]
|
|
399
|
+
def cleanup_after_task(task, response_handled)
|
|
400
|
+
return if (stopping? || stopped?) && !response_handled
|
|
401
|
+
|
|
402
|
+
@tasks_lock.synchronize do
|
|
403
|
+
@inflight_requests.delete(task.id)
|
|
404
|
+
if @pending_tasks.empty? && @inflight_requests.empty?
|
|
405
|
+
@idle_condition.broadcast
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
notify_observers { |observer| observer.request_end(task) }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Handle successful response.
|
|
412
|
+
#
|
|
413
|
+
# @param task [RequestTask] the request task
|
|
414
|
+
# @param response [Response] the response object
|
|
415
|
+
# @return [void]
|
|
416
|
+
def handle_completion(task, response)
|
|
417
|
+
if stopped?
|
|
418
|
+
@config.logger&.warn("[PatientHttp] Request #{task.id} succeeded after processor was stopped")
|
|
419
|
+
return
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
task.completed!(response)
|
|
423
|
+
|
|
424
|
+
@config.logger&.debug(
|
|
425
|
+
"[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
|
|
426
|
+
"enqueued callback #{task.callback}"
|
|
427
|
+
)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Handle a redirect response.
|
|
431
|
+
#
|
|
432
|
+
# @param task [RequestTask] the request task
|
|
433
|
+
# @param response_data [Hash] the response data with status, headers, body
|
|
434
|
+
# @return [void]
|
|
435
|
+
def handle_redirect(task, response_data)
|
|
436
|
+
status = response_data[:status]
|
|
437
|
+
location = response_data[:headers]["location"]
|
|
438
|
+
|
|
439
|
+
# Check for redirect errors
|
|
440
|
+
error = check_redirect_error(task, response_data)
|
|
441
|
+
if error
|
|
442
|
+
notify_observers { |observer| observer.request_error(error) }
|
|
443
|
+
handle_error(task, error)
|
|
444
|
+
return
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Create redirect task and enqueue it
|
|
448
|
+
redirect_task = task.redirect_task(location: location, status: status)
|
|
449
|
+
redirect_task.enqueued!
|
|
450
|
+
@queue.push(redirect_task)
|
|
451
|
+
|
|
452
|
+
redirect_url = resolve_redirect_url(task.request.url, location)
|
|
453
|
+
@config.logger&.debug("[PatientHttp] Request #{task.id} redirected (#{status}) to #{redirect_url}")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# Handle error response.
|
|
457
|
+
#
|
|
458
|
+
# @param task [RequestTask] the request task
|
|
459
|
+
# @param exception [Exception] the exception
|
|
460
|
+
# @return [void]
|
|
461
|
+
def handle_error(task, exception)
|
|
462
|
+
if stopped?
|
|
463
|
+
@config.logger&.warn("[PatientHttp] Request #{task.id} failed after processor was stopped")
|
|
464
|
+
return
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
task.error!(exception)
|
|
468
|
+
|
|
469
|
+
@config.logger&.warn(
|
|
470
|
+
"[PatientHttp] Request #{task.id} failed with #{exception.class.name}: #{exception.message}, " \
|
|
471
|
+
"enqueued callback #{task.callback}\n#{exception.backtrace&.join("\n")}"
|
|
472
|
+
)
|
|
473
|
+
rescue => e
|
|
474
|
+
@config.logger&.error(
|
|
475
|
+
"[PatientHttp] Failed to enqueue error worker for request #{task.id}: #{e.class} - #{e.message}"
|
|
476
|
+
)
|
|
477
|
+
raise if PatientHttp.testing?
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def notify_observers(&block)
|
|
481
|
+
@observers.each do |observer|
|
|
482
|
+
yield(observer)
|
|
483
|
+
rescue => e
|
|
484
|
+
@config.logger&.error(
|
|
485
|
+
"[PatientHttp] Observer #{observer.class.name} error: #{e.class} - #{e.message}"
|
|
486
|
+
)
|
|
487
|
+
raise e if PatientHttp.testing?
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def reenqueue_pending_requests
|
|
492
|
+
# Re-enqueue any remaining in-flight, pending, and queued tasks
|
|
493
|
+
tasks_to_reenqueue = []
|
|
494
|
+
@tasks_lock.synchronize do
|
|
495
|
+
# Now that we have the lock again, atomically transition to stopped and clear collections
|
|
496
|
+
@lifecycle.stopped!
|
|
497
|
+
tasks_to_reenqueue = @inflight_requests.values + @pending_tasks.values
|
|
498
|
+
@inflight_requests.clear
|
|
499
|
+
@pending_tasks.clear
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
reenqueue_tasks(tasks_to_reenqueue)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def reenqueue_remaining_queue_items
|
|
506
|
+
tasks_to_reenqueue = []
|
|
507
|
+
|
|
508
|
+
# Drain remaining items from the queue (skip nil sentinels from stop)
|
|
509
|
+
until @queue.empty?
|
|
510
|
+
begin
|
|
511
|
+
task = @queue.pop(true)
|
|
512
|
+
tasks_to_reenqueue << task if task
|
|
513
|
+
rescue ThreadError
|
|
514
|
+
break
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
reenqueue_tasks(tasks_to_reenqueue)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def reenqueue_tasks(tasks_to_reenqueue)
|
|
522
|
+
tasks_to_reenqueue.each do |task|
|
|
523
|
+
task.retry
|
|
524
|
+
notify_observers { |observer| observer.request_end(task) }
|
|
525
|
+
|
|
526
|
+
@config.logger&.info(
|
|
527
|
+
"[PatientHttp] Retrying incomplete request #{task.id}"
|
|
528
|
+
)
|
|
529
|
+
rescue => e
|
|
530
|
+
@config.logger&.error(
|
|
531
|
+
"[PatientHttp] Failed to re-enqueue request #{task.id}: #{e.class} - #{e.message}"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
raise if PatientHttp.testing?
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Interface for observing request processing. A process observer can be registered with
|
|
5
|
+
# a Processor and receive events as requests are processed. Observers will run on the main
|
|
6
|
+
# processor thread and so should be lightweight and not do processing other than recording
|
|
7
|
+
# metrics or similar.
|
|
8
|
+
class ProcessorObserver
|
|
9
|
+
# Called when the processor starts.
|
|
10
|
+
#
|
|
11
|
+
# @return [void]
|
|
12
|
+
def start
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Called when the processor stops.
|
|
16
|
+
#
|
|
17
|
+
# @return [void]
|
|
18
|
+
def stop
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Called when a request cannot be enqueued because the processor is at capacity.
|
|
22
|
+
#
|
|
23
|
+
# @return [void]
|
|
24
|
+
def capacity_exceeded
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Called when a request starts processing.
|
|
28
|
+
#
|
|
29
|
+
# @param request_task [RequestTask] the request task that started
|
|
30
|
+
# @return [void]
|
|
31
|
+
def request_start(request_task)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Called when a request finishes processing.
|
|
35
|
+
#
|
|
36
|
+
# @param request_task [RequestTask] the request task that ended
|
|
37
|
+
# @return [void]
|
|
38
|
+
def request_end(request_task)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Called when a request encounters an error.
|
|
42
|
+
#
|
|
43
|
+
# @param error [StandardError] the error that occurred
|
|
44
|
+
# @return [void]
|
|
45
|
+
def request_error(error)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file must be explicitly required to enable Rails integration.
|
|
4
|
+
# Usage: require "patient_http/rails/engine"
|
|
5
|
+
#
|
|
6
|
+
# This will allow you to install migrations using:
|
|
7
|
+
# rails patient_http:install:migrations
|
|
8
|
+
|
|
9
|
+
require "rails/engine"
|
|
10
|
+
|
|
11
|
+
module PatientHttp
|
|
12
|
+
module Rails
|
|
13
|
+
class Engine < ::Rails::Engine
|
|
14
|
+
engine_name "patient_http"
|
|
15
|
+
|
|
16
|
+
# Migrations will be picked up automatically from db/migrate
|
|
17
|
+
# when the engine is loaded. Users can copy them using:
|
|
18
|
+
# rails patient_http:install:migrations
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|