patient_http 1.3.0 → 1.5.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.
@@ -9,9 +9,22 @@ module PatientHttp
9
9
  # Timing constants for the reactor loop
10
10
  DEQUEUE_TIMEOUT = 1.0 # Seconds to wait when dequeueing requests
11
11
 
12
+ # Base delay between attempts when delivering a completed result fails.
13
+ # The delay grows linearly with each attempt.
14
+ COMPLETION_RETRY_DELAY = 0.5
15
+
16
+ # Seconds allowed for the completion executor to drain during shutdown.
17
+ # The reactor's teardown and stop() share this budget so the reactor can
18
+ # never spend longer draining than stop() is willing to wait for it.
19
+ COMPLETION_SHUTDOWN_TIMEOUT = 5
20
+
12
21
  # @return [Configuration] the configuration object for the processor
13
22
  attr_reader :config
14
23
 
24
+ # @return [String] the processor's name; used in thread names so multiple
25
+ # named processors in one process are distinguishable
26
+ attr_reader :name
27
+
15
28
  # Callback to invoke after each request. Only available in testing mode.
16
29
  # @api private
17
30
  attr_accessor :testing_callback
@@ -19,9 +32,12 @@ module PatientHttp
19
32
  # Initialize the processor.
20
33
  #
21
34
  # @param config [Configuration] the configuration object
35
+ # @param name [String, Symbol] optional name to distinguish this processor
36
+ # when a process runs more than one
22
37
  # @return [void]
23
- def initialize(config)
38
+ def initialize(config, name: "default")
24
39
  @config = config
40
+ @name = name.to_s
25
41
  @lifecycle = LifecycleManager.new
26
42
  @queue = Thread::Queue.new
27
43
  @reactor_thread = nil
@@ -33,11 +49,16 @@ module PatientHttp
33
49
  @reactor_generation = 0
34
50
  @inflight_requests = Concurrent::Hash.new
35
51
  @pending_tasks = Concurrent::Hash.new
52
+ # Tasks pushed onto @queue but not yet popped by the reactor. Kept in a
53
+ # hash because Thread::Queue cannot be enumerated; used to report all
54
+ # tracked task ids (e.g. for heartbeat updates on queued tasks).
55
+ @queued_tasks = Concurrent::Hash.new
36
56
  @tasks_lock = Mutex.new
37
57
  @idle_condition = ConditionVariable.new
38
58
  @testing_callback = nil
39
59
  @http_client = Client.new(self)
40
60
  @observers = []
61
+ @completion_executor = nil
41
62
  end
42
63
 
43
64
  # Start the processor.
@@ -57,8 +78,20 @@ module PatientHttp
57
78
  @reactor_generation += 1
58
79
  end
59
80
 
81
+ # The completion executor delivers finished results on its own worker
82
+ # threads so the reactor thread never blocks on response decoding,
83
+ # serialization, or callback delivery. A new executor is created for
84
+ # each run, like the reactor thread.
85
+ executor = CompletionExecutor.new(
86
+ threads: @config.completion_threads,
87
+ logger: @config.logger,
88
+ thread_name_prefix: thread_name("patient-http-completion"),
89
+ on_finished: -> { signal_idle }
90
+ )
91
+ @tasks_lock.synchronize { @completion_executor = executor }
92
+
60
93
  @reactor_thread = Thread.new do
61
- Thread.current.name = "patient-http-processor"
94
+ Thread.current.name = thread_name("patient-http-processor")
62
95
  run_reactor
63
96
  rescue => e
64
97
  @config.logger&.error("[PatientHttp] Processor error: #{e.message}\n#{e.backtrace.join("\n")}")
@@ -76,14 +109,33 @@ module PatientHttp
76
109
  # lock; re-enqueueing runs outside it. This is idempotent with stop()'s
77
110
  # reenqueue_pending_requests: whichever runs second snapshots an empty
78
111
  # set.
79
- orphaned_tasks = @tasks_lock.synchronize do
80
- if @reactor_generation == generation
81
- drain_tracked_tasks_locked
82
- else
83
- []
112
+ current_generation = @tasks_lock.synchronize { @reactor_generation == generation }
113
+ if current_generation
114
+ begin
115
+ # Drain the completion executor before stealing tracked tasks so
116
+ # results already handed off are delivered rather than retried.
117
+ # Tasks whose completion job never ran stay in in-flight tracking
118
+ # and are re-enqueued below. The drain is bounded so it cannot
119
+ # outlast stop()'s own shutdown budget, and it runs in its own
120
+ # block so the re-enqueue still happens if a stop() that gave up
121
+ # waiting kills this thread mid-drain.
122
+ executor.shutdown(timeout: COMPLETION_SHUTDOWN_TIMEOUT)
123
+ ensure
124
+ orphaned_tasks = @tasks_lock.synchronize do
125
+ if @reactor_generation == generation
126
+ drain_tracked_tasks_locked
127
+ else
128
+ []
129
+ end
130
+ end
131
+ reenqueue_tasks(orphaned_tasks)
132
+ # Hand back tasks still sitting in the queue as well; a reactor
133
+ # that exits without a stop() call is the last owner of those
134
+ # tasks. stop() performs the same drain after reaping the
135
+ # reactor, and whichever drain runs second finds nothing left.
136
+ reenqueue_remaining_queue_items
84
137
  end
85
138
  end
86
- reenqueue_tasks(orphaned_tasks)
87
139
  end
88
140
 
89
141
  # The transition can fail if the reactor thread already failed and
@@ -126,14 +178,15 @@ module PatientHttp
126
178
  # Interrupt the reactor's queue wait by pushing a sentinel value
127
179
  @queue.push(nil)
128
180
 
129
- # Wait for in-flight and pending requests to complete.
181
+ # Wait for in-flight and pending requests to complete, including
182
+ # results still being delivered by the completion executor.
130
183
  # Queue items are not checked here — they will be re-enqueued by
131
184
  # reenqueue_remaining_queue_items after the reactor thread exits.
132
185
  if timeout > 0
133
186
  deadline = monotonic_time + timeout
134
187
  @tasks_lock.synchronize do
135
188
  loop do
136
- break if @pending_tasks.empty? && @inflight_requests.empty?
189
+ break if @pending_tasks.empty? && @inflight_requests.empty? && completion_executor_settled?
137
190
  remaining = deadline - monotonic_time
138
191
  break if remaining <= 0
139
192
  @idle_condition.wait(@tasks_lock, remaining)
@@ -149,6 +202,10 @@ module PatientHttp
149
202
  # exits on its own once the callback returns (its loop sees the stopped
150
203
  # state) and its ensure block performs the same cleanup.
151
204
  if reactor && !reactor.equal?(Thread.current)
205
+ # The join stays short: the reactor's teardown drains the completion
206
+ # executor, which can join this very thread when stop was called from
207
+ # a completion callback. Killing the reactor breaks that standoff and
208
+ # its teardown still re-enqueues from an ensure block.
152
209
  reactor.join(1) if reactor.alive?
153
210
  if reactor.alive?
154
211
  reactor.kill
@@ -161,6 +218,12 @@ module PatientHttp
161
218
  @reactor_thread = nil if @reactor_thread.equal?(reactor)
162
219
  end
163
220
 
221
+ # Shut down the completion executor. The reactor's own teardown
222
+ # normally drains it already; this pass reaps any worker that is still
223
+ # stuck past the deadline. Remaining queued jobs belong to tasks that
224
+ # were re-enqueued above, so they no-op when their claim fails.
225
+ @completion_executor&.shutdown(timeout: COMPLETION_SHUTDOWN_TIMEOUT)
226
+
164
227
  # Run a second pass now that the reactor has exited to catch any task
165
228
  # that slipped into pending/in-flight tracking after the first snapshot
166
229
  # (a task can be popped from the queue but not yet tracked when the
@@ -198,22 +261,18 @@ module PatientHttp
198
261
  # @raise [MaxCapacityError] if at max capacity
199
262
  # @return [void]
200
263
  def enqueue(task)
201
- at_capacity = false
264
+ raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
202
265
 
203
- @tasks_lock.synchronize do
266
+ accepted = announce_and_enqueue(task) do
267
+ # The pre-check above is advisory; re-check the running state under
268
+ # the lock since observers were notified outside of it.
204
269
  raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
205
270
 
206
- # Check capacity - raise error if at max connections
207
- total = @queue.size + @pending_tasks.size + @inflight_requests.size
208
- if total >= @config.max_connections
209
- at_capacity = true
210
- else
211
- task.enqueued!
212
- @queue.push(task)
213
- end
271
+ # Check capacity - the task is only accepted below max connections.
272
+ @queue.size + @pending_tasks.size + @inflight_requests.size < @config.max_connections
214
273
  end
215
274
 
216
- if at_capacity
275
+ unless accepted
217
276
  notify_observers { |observer| observer.capacity_exceeded }
218
277
  raise MaxCapacityError.new("Cannot enqueue request: already at max capacity (#{@config.max_connections} connections)")
219
278
  end
@@ -268,13 +327,39 @@ module PatientHttp
268
327
  @lifecycle.stopping?
269
328
  end
270
329
 
271
- # Check if processor is idle (no queued or in-flight requests).
330
+ # Check if processor is idle (no queued or in-flight requests, and no
331
+ # results still being delivered by the completion executor).
272
332
  #
273
333
  # @return [Boolean]
274
334
  def idle?
275
- @tasks_lock.synchronize do
335
+ executor = @completion_executor
336
+ tracking_empty = @tasks_lock.synchronize do
276
337
  @queue.empty? && @pending_tasks.empty? && @inflight_requests.empty?
277
338
  end
339
+
340
+ tracking_empty && (executor.nil? || executor.idle?)
341
+ end
342
+
343
+ # Check how many more requests the processor can accept before reaching
344
+ # max capacity. This is an advisory value: the authoritative check happens
345
+ # inside {#enqueue}, so a concurrent enqueue can still hit
346
+ # {MaxCapacityError}. It performs no observer notifications and no durable
347
+ # registration, so it is cheap to call before paying enqueue costs.
348
+ #
349
+ # @return [Integer] remaining capacity (never negative)
350
+ def remaining_capacity
351
+ @tasks_lock.synchronize do
352
+ remaining = @config.max_connections - (@queue.size + @pending_tasks.size + @inflight_requests.size)
353
+ (remaining > 0) ? remaining : 0
354
+ end
355
+ end
356
+
357
+ # Check if the processor can accept at least one more request. Advisory
358
+ # only; see {#remaining_capacity}.
359
+ #
360
+ # @return [Boolean]
361
+ def capacity_available?
362
+ remaining_capacity > 0
278
363
  end
279
364
 
280
365
  # Get the number of in-flight requests (actively executing HTTP calls).
@@ -307,6 +392,17 @@ module PatientHttp
307
392
  end
308
393
  end
309
394
 
395
+ # Get the IDs of all tasks in the pipeline (queued, pending, and in-flight).
396
+ # Use this to keep durable tracking (e.g. heartbeats) alive for tasks the
397
+ # processor has accepted but not yet started.
398
+ #
399
+ # @return [Array<String>]
400
+ def tracked_request_ids
401
+ @tasks_lock.synchronize do
402
+ (@queued_tasks.keys + @pending_tasks.keys + @inflight_requests.keys).uniq
403
+ end
404
+ end
405
+
310
406
  # Add an observer for processor events.
311
407
  #
312
408
  # @param observer [ProcessorObserver] the observer to add
@@ -373,6 +469,16 @@ module PatientHttp
373
469
 
374
470
  private
375
471
 
472
+ # Build a thread name for this processor. The default processor keeps the
473
+ # bare prefix; named processors append their name so multiple processors
474
+ # in one process are distinguishable.
475
+ #
476
+ # @param prefix [String] the base thread name
477
+ # @return [String]
478
+ def thread_name(prefix)
479
+ (@name == "default") ? prefix : "#{prefix}-#{@name}"
480
+ end
481
+
376
482
  # Run the async reactor loop.
377
483
  #
378
484
  # @return [void]
@@ -393,6 +499,7 @@ module PatientHttp
393
499
 
394
500
  # Track as pending immediately to avoid race condition with stop()
395
501
  @tasks_lock.synchronize do
502
+ @queued_tasks.delete(request_task.id)
396
503
  @pending_tasks[request_task.id] = request_task
397
504
  end
398
505
 
@@ -473,14 +580,15 @@ module PatientHttp
473
580
 
474
581
  @pending_tasks.delete(task.id)
475
582
  @inflight_requests[task.id] = task
583
+ # Mark the task started in the same locked section that tracks it so
584
+ # a shutdown snapshot always sees a consistent started state. The
585
+ # shutdown re-enqueue path uses started? to pair request_end with
586
+ # request_start.
587
+ task.started!
476
588
  end
477
589
 
478
590
  notify_observers { |observer| observer.request_start(task) }
479
591
 
480
- # Mark task as started
481
- task.started!
482
- claimed = false
483
-
484
592
  begin
485
593
  response_data = @http_client.make_request(task.request, task.id)
486
594
 
@@ -488,24 +596,14 @@ module PatientHttp
488
596
  # shutdown sequence has re-enqueued the task; discard the response.
489
597
  return if stopped?
490
598
 
491
- # Check for redirect handling. handle_redirect claims the task itself
492
- # (atomically with enqueueing the redirect) and returns whether this
493
- # caller owns delivery, so the ensure block can finish the task.
494
599
  if should_follow_redirect?(task, response_data)
495
- claimed = handle_redirect(task, response_data)
496
- return
497
- end
498
-
499
- response = task.build_response(**response_data)
500
- claimed = claim_task(task)
501
- if claimed
502
- if task.raise_error_responses && !response.success?
503
- http_error = HttpError.new(response)
504
- notify_observers { |observer| observer.request_error(http_error) }
505
- handle_error(task, http_error)
506
- else
507
- handle_completion(task, response)
508
- end
600
+ handle_redirect(task, response_data)
601
+ else
602
+ # Hand the result to the completion executor without claiming the
603
+ # task. The task stays in in-flight tracking until a completion
604
+ # worker claims it, so the shutdown re-enqueue protocol covers
605
+ # results that are queued but not yet delivered.
606
+ dispatch_completion(task, response_data: response_data)
509
607
  end
510
608
  rescue ResponseReader::ReadAbortedError
511
609
  # The processor stopped past its shutdown deadline while the response
@@ -513,24 +611,103 @@ module PatientHttp
513
611
  # there is nothing to deliver.
514
612
  nil
515
613
  rescue => e
516
- # A failure raised after the task was claimed came from the delivery
517
- # attempt itself (only reachable in testing mode; the delivery helpers
518
- # rescue their own failures in production). Re-raise rather than
519
- # claiming again — claimed must stay true so the ensure block still
520
- # finishes the task.
521
- raise if claimed
522
-
523
- claimed = claim_task(task)
524
- if claimed
525
- notify_observers { |observer| observer.request_error(e) }
526
- handle_error(task, e)
614
+ dispatch_completion(task, error: e)
615
+ end
616
+ end
617
+
618
+ # Hand off a finished HTTP exchange to the completion executor.
619
+ #
620
+ # @param task [RequestTask] the request task
621
+ # @param response_data [Hash, nil] raw response data on success
622
+ # @param error [Exception, nil] the error on failure
623
+ # @return [void]
624
+ def dispatch_completion(task, response_data: nil, error: nil)
625
+ executor = @completion_executor
626
+ executor.enqueue(-> { run_completion(task, response_data: response_data, error: error) })
627
+ rescue ClosedQueueError
628
+ # The executor is already shut down. The task is still tracked, so the
629
+ # shutdown sequence re-enqueues it.
630
+ nil
631
+ end
632
+
633
+ # Deliver a finished result on a completion worker thread: decode the
634
+ # response, claim the task, run the result callbacks, and notify
635
+ # observers. When delivery fails after all retries, request_end is NOT
636
+ # fired so durable tracking (crash-recovery records) stays in place and
637
+ # the request can be recovered instead of silently lost.
638
+ #
639
+ # @param task [RequestTask] the request task
640
+ # @param response_data [Hash, nil] raw response data on success
641
+ # @param error [Exception, nil] the error on failure
642
+ # @return [void]
643
+ def run_completion(task, response_data: nil, error: nil)
644
+ response = nil
645
+
646
+ if error.nil?
647
+ begin
648
+ response = task.build_response(**@http_client.decode_response(response_data))
649
+ if task.raise_error_responses && !response.success?
650
+ error = HttpError.new(response)
651
+ end
652
+ rescue => e
653
+ error = e
654
+ end
655
+ end
656
+
657
+ # A claim failure means the shutdown sequence already re-enqueued the
658
+ # task; the result must not be delivered.
659
+ return unless claim_task(task)
660
+
661
+ failure = nil
662
+ begin
663
+ if error
664
+ notify_observers { |observer| observer.request_error(error) }
665
+ failure = handle_error(task, error)
666
+ else
667
+ failure = handle_completion(task, response)
668
+ end
669
+
670
+ if failure.nil?
671
+ finish_task(task)
672
+ else
673
+ notify_observers { |observer| observer.completion_failed(task, failure) }
527
674
  end
528
675
  ensure
529
- finish_task(task) if claimed
530
- # Only fire the testing hook for tasks this caller actually owns.
531
- # A task discarded during shutdown (claimed == false) is re-enqueued
532
- # by the shutdown sequence, not processed here.
533
- @testing_callback&.call(task) if claimed && PatientHttp.testing?
676
+ @testing_callback&.call(task) if PatientHttp.testing?
677
+ end
678
+
679
+ raise failure if failure && PatientHttp.testing?
680
+ end
681
+
682
+ # Run a delivery block with bounded retries. Returns nil when the block
683
+ # succeeds or the final exception when all attempts fail. Retries back off
684
+ # linearly; sleeping is safe here because delivery runs on a completion
685
+ # worker thread, not the reactor.
686
+ #
687
+ # The block calls into the task handler, so a retry calls the handler
688
+ # again. A handler that raises after its side effect therefore repeats that
689
+ # side effect; handlers must be idempotent, or completion_retries must be
690
+ # set to zero.
691
+ #
692
+ # @param task [RequestTask] the request task (for log context)
693
+ # @return [Exception, nil] the final failure or nil on success
694
+ def deliver_with_retries(task)
695
+ attempts = 0
696
+
697
+ begin
698
+ yield
699
+ nil
700
+ rescue => e
701
+ attempts += 1
702
+ if attempts <= @config.completion_retries
703
+ @config.logger&.warn(
704
+ "[PatientHttp] Retrying result delivery for request #{task.id} " \
705
+ "(attempt #{attempts + 1}): #{e.class} - #{e.message}"
706
+ )
707
+ sleep(COMPLETION_RETRY_DELAY * attempts) unless PatientHttp.testing?
708
+ retry
709
+ end
710
+ e
534
711
  end
535
712
  end
536
713
 
@@ -554,12 +731,33 @@ module PatientHttp
554
731
  # @param task [RequestTask] the request task
555
732
  # @return [void]
556
733
  def finish_task(task)
734
+ signal_idle
735
+ notify_observers { |observer| observer.request_end(task) }
736
+ end
737
+
738
+ # Broadcast the idle condition when the pipeline is empty. Called after a
739
+ # claimed task finishes and by the completion executor after each job, so
740
+ # stop() waiters wake once the last delivery completes.
741
+ #
742
+ # @return [void]
743
+ def signal_idle
744
+ executor = @completion_executor
557
745
  @tasks_lock.synchronize do
558
- if @pending_tasks.empty? && @inflight_requests.empty?
746
+ if @pending_tasks.empty? && @inflight_requests.empty? && (executor.nil? || executor.idle?)
559
747
  @idle_condition.broadcast
560
748
  end
561
749
  end
562
- notify_observers { |observer| observer.request_end(task) }
750
+ end
751
+
752
+ # Check whether stop() should keep waiting on the completion executor.
753
+ # When stop is called from a completion worker itself (via a result
754
+ # callback), its own in-progress job would never settle, so it is treated
755
+ # as settled to avoid waiting out the full timeout.
756
+ #
757
+ # @return [Boolean]
758
+ def completion_executor_settled?
759
+ executor = @completion_executor
760
+ executor.nil? || executor.worker_thread? || executor.idle?
563
761
  end
564
762
 
565
763
  # Handle successful response. The caller must have claimed the task via
@@ -567,32 +765,36 @@ module PatientHttp
567
765
  #
568
766
  # @param task [RequestTask] the request task
569
767
  # @param response [Response] the response object
570
- # @return [void]
768
+ # @return [Exception, nil] the delivery failure or nil on success
571
769
  def handle_completion(task, response)
572
- task.completed!(response)
770
+ failure = deliver_with_retries(task) { task.completed!(response) }
573
771
 
574
- @config.logger&.debug(
575
- "[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
576
- "enqueued callback #{task.callback}"
577
- )
578
- rescue => e
579
- @config.logger&.error(
580
- "[PatientHttp] Failed to enqueue completion callback for request #{task.id}: #{e.class} - #{e.message}"
581
- )
582
- raise if PatientHttp.testing?
772
+ if failure
773
+ @config.logger&.error(
774
+ "[PatientHttp] Failed to enqueue completion callback for request #{task.id}: " \
775
+ "#{failure.class} - #{failure.message}"
776
+ )
777
+ else
778
+ @config.logger&.debug(
779
+ "[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
780
+ "enqueued callback #{task.callback}"
781
+ )
782
+ end
783
+
784
+ failure
583
785
  end
584
786
 
585
- # Handle a redirect response.
787
+ # Handle a redirect response on the reactor thread.
586
788
  #
587
- # Claims the task before delivering an error or enqueueing the redirect so
588
- # the result is delivered exactly once. When following a redirect, the
589
- # original task is removed from in-flight tracking and the redirect task is
590
- # pushed onto the queue within a single {@tasks_lock} section, so a
591
- # concurrent {#idle?} never observes a moment where neither is tracked.
789
+ # Redirect errors are handed to the completion executor for delivery.
790
+ # When following a redirect, the original task is removed from in-flight
791
+ # tracking and the redirect task is pushed onto the queue within a single
792
+ # {@tasks_lock} section, so a concurrent {#idle?} never observes a moment
793
+ # where neither is tracked.
592
794
  #
593
795
  # @param task [RequestTask] the request task
594
796
  # @param response_data [Hash] the response data with status, headers, body
595
- # @return [Boolean] true if this caller owns delivery of the task's result
797
+ # @return [void]
596
798
  def handle_redirect(task, response_data)
597
799
  status = response_data[:status]
598
800
  location = response_data[:headers]["location"]
@@ -600,30 +802,32 @@ module PatientHttp
600
802
  # Check for redirect errors
601
803
  error = check_redirect_error(task, response_data)
602
804
  if error
603
- return false unless claim_task(task)
604
-
605
- notify_observers { |observer| observer.request_error(error) }
606
- handle_error(task, error)
607
- return true
805
+ dispatch_completion(task, error: error)
806
+ return
608
807
  end
609
808
 
610
809
  # Create the redirect task, then atomically claim the original (remove it
611
810
  # from in-flight) and enqueue the redirect. If the claim fails the
612
811
  # shutdown sequence already re-enqueued the original, so drop the redirect.
613
812
  redirect_task = task.redirect_task(location: location, status: status)
614
- redirect_task.enqueued!
615
-
616
- claimed = @tasks_lock.synchronize do
617
- next false if @inflight_requests.delete(task.id).nil?
618
813
 
619
- @queue.push(redirect_task)
620
- true
814
+ begin
815
+ claimed = announce_and_enqueue(redirect_task) do
816
+ !@inflight_requests.delete(task.id).nil?
817
+ end
818
+ rescue => e
819
+ # The redirect could not be registered (e.g. durable tracking setup
820
+ # failed). Deliver the failure as the original task's result.
821
+ dispatch_completion(task, error: e)
822
+ return
621
823
  end
622
- return false unless claimed
824
+ return unless claimed
623
825
 
624
826
  redirect_url = resolve_redirect_url(task.request.url, location)
625
827
  @config.logger&.debug("[PatientHttp] Request #{task.id} redirected (#{status}) to #{redirect_url}")
626
- true
828
+
829
+ finish_task(task)
830
+ @testing_callback&.call(task) if PatientHttp.testing?
627
831
  end
628
832
 
629
833
  # Handle error response. The caller must have claimed the task via
@@ -631,19 +835,64 @@ module PatientHttp
631
835
  #
632
836
  # @param task [RequestTask] the request task
633
837
  # @param exception [Exception] the exception
634
- # @return [void]
838
+ # @return [Exception, nil] the delivery failure or nil on success
635
839
  def handle_error(task, exception)
636
- task.error!(exception)
840
+ failure = deliver_with_retries(task) { task.error!(exception) }
637
841
 
638
- @config.logger&.warn(
639
- "[PatientHttp] Request #{task.id} failed with #{exception.class.name}: #{exception.message}, " \
640
- "enqueued callback #{task.callback}\n#{exception.backtrace&.join("\n")}"
641
- )
642
- rescue => e
643
- @config.logger&.error(
644
- "[PatientHttp] Failed to enqueue error worker for request #{task.id}: #{e.class} - #{e.message}"
645
- )
646
- raise if PatientHttp.testing?
842
+ if failure
843
+ @config.logger&.error(
844
+ "[PatientHttp] Failed to enqueue error worker for request #{task.id}: " \
845
+ "#{failure.class} - #{failure.message}"
846
+ )
847
+ else
848
+ @config.logger&.warn(
849
+ "[PatientHttp] Request #{task.id} failed with #{exception.class.name}: #{exception.message}, " \
850
+ "enqueued callback #{task.callback}\n#{exception.backtrace&.join("\n")}"
851
+ )
852
+ end
853
+
854
+ failure
855
+ end
856
+
857
+ # Announce a task to observers and make it visible to the reactor. The
858
+ # task is announced before it can start, finish, or be re-enqueued, so
859
+ # observers can set up durable tracking first. Errors from the
860
+ # request_enqueued announcement propagate and reject the task, because a
861
+ # failed tracking setup must not let the task be accepted as if it were
862
+ # durable. The block runs while {@tasks_lock} is held and decides whether
863
+ # the task is accepted; when it returns false or raises, observers receive
864
+ # request_rejected so they can tear down anything they set up for the
865
+ # request_enqueued announcement. The rejection notification never replaces
866
+ # an exception that is already being raised.
867
+ #
868
+ # @param task [RequestTask] the request task to announce and enqueue
869
+ # @return [Boolean] true if the task was accepted
870
+ def announce_and_enqueue(task)
871
+ task.enqueued!
872
+ accepted = false
873
+
874
+ begin
875
+ notify_observers! { |observer| observer.request_enqueued(task) }
876
+
877
+ @tasks_lock.synchronize do
878
+ if yield
879
+ @queued_tasks[task.id] = task
880
+ @queue.push(task)
881
+ accepted = true
882
+ end
883
+ end
884
+ ensure
885
+ unless accepted
886
+ pending_error = $!
887
+ begin
888
+ notify_observers { |observer| observer.request_rejected(task) }
889
+ rescue
890
+ raise unless pending_error
891
+ end
892
+ end
893
+ end
894
+
895
+ accepted
647
896
  end
648
897
 
649
898
  # Notify all observers of an event. Observers are called outside of any
@@ -655,6 +904,16 @@ module PatientHttp
655
904
  end
656
905
  end
657
906
 
907
+ # Notify all observers of an event and let observer errors propagate.
908
+ # Used for notifications the caller must be able to react to, such as
909
+ # durable tracking setup in request_enqueued.
910
+ def notify_observers!
911
+ observers = @tasks_lock.synchronize { @observers.dup }
912
+ observers.each do |observer|
913
+ yield(observer)
914
+ end
915
+ end
916
+
658
917
  def notify_observer(observer)
659
918
  yield(observer)
660
919
  rescue => e
@@ -697,16 +956,27 @@ module PatientHttp
697
956
  end
698
957
 
699
958
  def reenqueue_remaining_queue_items
700
- tasks_to_reenqueue = []
701
-
702
- # Drain remaining items from the queue (skip nil sentinels from stop)
703
- until @queue.empty?
704
- begin
705
- task = @queue.pop(true)
706
- tasks_to_reenqueue << task if task
707
- rescue ThreadError
708
- break
959
+ tasks_to_reenqueue = @tasks_lock.synchronize do
960
+ tasks = []
961
+
962
+ # Drain remaining items from the queue (skip nil sentinels from stop)
963
+ until @queue.empty?
964
+ begin
965
+ task = @queue.pop(true)
966
+ tasks << task if task
967
+ rescue ThreadError
968
+ break
969
+ end
709
970
  end
971
+
972
+ tasks.each { |task| @queued_tasks.delete(task.id) }
973
+ # The reactor has exited and no new tasks can be accepted, so any id
974
+ # still tracked as queued belongs to a task that left the queue
975
+ # without reaching pending or in-flight tracking. Reclaim those tasks
976
+ # as well so they are not tracked forever.
977
+ tasks.concat(@queued_tasks.values)
978
+ @queued_tasks.clear
979
+ tasks
710
980
  end
711
981
 
712
982
  reenqueue_tasks(tasks_to_reenqueue)
@@ -715,6 +985,10 @@ module PatientHttp
715
985
  def reenqueue_tasks(tasks_to_reenqueue)
716
986
  tasks_to_reenqueue.each do |task|
717
987
  task.retry
988
+ # The task handler's job system owns the request again; let observers
989
+ # tear down any durable tracking for the task. Only sent after a
990
+ # successful retry so a failed retry leaves the tracking in place.
991
+ notify_observers { |observer| observer.request_requeued(task) }
718
992
  # Only emit request_end for tasks that actually started, so observers
719
993
  # that pair request_start/request_end (e.g. an in-flight gauge) stay
720
994
  # balanced. Queued-but-never-started tasks emit neither.