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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +322 -0
  3. data/CHANGELOG.md +30 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +653 -0
  6. data/VERSION +1 -0
  7. data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
  8. data/lib/patient_http/callback_args.rb +176 -0
  9. data/lib/patient_http/callback_validator.rb +52 -0
  10. data/lib/patient_http/class_helper.rb +26 -0
  11. data/lib/patient_http/client.rb +80 -0
  12. data/lib/patient_http/client_pool.rb +178 -0
  13. data/lib/patient_http/configuration.rb +365 -0
  14. data/lib/patient_http/encryptor.rb +69 -0
  15. data/lib/patient_http/error.rb +76 -0
  16. data/lib/patient_http/external_storage.rb +134 -0
  17. data/lib/patient_http/http_error.rb +106 -0
  18. data/lib/patient_http/http_headers.rb +99 -0
  19. data/lib/patient_http/lifecycle_manager.rb +174 -0
  20. data/lib/patient_http/payload.rb +160 -0
  21. data/lib/patient_http/payload_store/active_record_store.rb +102 -0
  22. data/lib/patient_http/payload_store/base.rb +150 -0
  23. data/lib/patient_http/payload_store/file_store.rb +92 -0
  24. data/lib/patient_http/payload_store/redis_store.rb +98 -0
  25. data/lib/patient_http/payload_store/s3_store.rb +94 -0
  26. data/lib/patient_http/payload_store.rb +11 -0
  27. data/lib/patient_http/processor.rb +538 -0
  28. data/lib/patient_http/processor_observer.rb +48 -0
  29. data/lib/patient_http/rails/engine.rb +21 -0
  30. data/lib/patient_http/redirect_error.rb +136 -0
  31. data/lib/patient_http/redirect_helper.rb +90 -0
  32. data/lib/patient_http/request.rb +158 -0
  33. data/lib/patient_http/request_error.rb +150 -0
  34. data/lib/patient_http/request_helper.rb +230 -0
  35. data/lib/patient_http/request_task.rb +308 -0
  36. data/lib/patient_http/request_template.rb +114 -0
  37. data/lib/patient_http/response.rb +183 -0
  38. data/lib/patient_http/response_reader.rb +135 -0
  39. data/lib/patient_http/synchronous_executor.rb +241 -0
  40. data/lib/patient_http/task_handler.rb +55 -0
  41. data/lib/patient_http/time_helper.rb +32 -0
  42. data/lib/patient_http.rb +313 -0
  43. data/patient_http.gemspec +48 -0
  44. 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