patient_http 1.4.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
@@ -42,6 +58,7 @@ module PatientHttp
42
58
  @testing_callback = nil
43
59
  @http_client = Client.new(self)
44
60
  @observers = []
61
+ @completion_executor = nil
45
62
  end
46
63
 
47
64
  # Start the processor.
@@ -61,8 +78,20 @@ module PatientHttp
61
78
  @reactor_generation += 1
62
79
  end
63
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
+
64
93
  @reactor_thread = Thread.new do
65
- Thread.current.name = "patient-http-processor"
94
+ Thread.current.name = thread_name("patient-http-processor")
66
95
  run_reactor
67
96
  rescue => e
68
97
  @config.logger&.error("[PatientHttp] Processor error: #{e.message}\n#{e.backtrace.join("\n")}")
@@ -80,21 +109,33 @@ module PatientHttp
80
109
  # lock; re-enqueueing runs outside it. This is idempotent with stop()'s
81
110
  # reenqueue_pending_requests: whichever runs second snapshots an empty
82
111
  # set.
83
- current_generation = false
84
- orphaned_tasks = @tasks_lock.synchronize do
85
- if @reactor_generation == generation
86
- current_generation = true
87
- drain_tracked_tasks_locked
88
- else
89
- []
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
90
137
  end
91
138
  end
92
- reenqueue_tasks(orphaned_tasks)
93
- # Hand back tasks still sitting in the queue as well; a reactor that
94
- # exits without a stop() call is the last owner of those tasks.
95
- # stop() performs the same drain after reaping the reactor, and
96
- # whichever drain runs second finds nothing left.
97
- reenqueue_remaining_queue_items if current_generation
98
139
  end
99
140
 
100
141
  # The transition can fail if the reactor thread already failed and
@@ -137,14 +178,15 @@ module PatientHttp
137
178
  # Interrupt the reactor's queue wait by pushing a sentinel value
138
179
  @queue.push(nil)
139
180
 
140
- # 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.
141
183
  # Queue items are not checked here — they will be re-enqueued by
142
184
  # reenqueue_remaining_queue_items after the reactor thread exits.
143
185
  if timeout > 0
144
186
  deadline = monotonic_time + timeout
145
187
  @tasks_lock.synchronize do
146
188
  loop do
147
- break if @pending_tasks.empty? && @inflight_requests.empty?
189
+ break if @pending_tasks.empty? && @inflight_requests.empty? && completion_executor_settled?
148
190
  remaining = deadline - monotonic_time
149
191
  break if remaining <= 0
150
192
  @idle_condition.wait(@tasks_lock, remaining)
@@ -160,6 +202,10 @@ module PatientHttp
160
202
  # exits on its own once the callback returns (its loop sees the stopped
161
203
  # state) and its ensure block performs the same cleanup.
162
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.
163
209
  reactor.join(1) if reactor.alive?
164
210
  if reactor.alive?
165
211
  reactor.kill
@@ -172,6 +218,12 @@ module PatientHttp
172
218
  @reactor_thread = nil if @reactor_thread.equal?(reactor)
173
219
  end
174
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
+
175
227
  # Run a second pass now that the reactor has exited to catch any task
176
228
  # that slipped into pending/in-flight tracking after the first snapshot
177
229
  # (a task can be popped from the queue but not yet tracked when the
@@ -275,13 +327,39 @@ module PatientHttp
275
327
  @lifecycle.stopping?
276
328
  end
277
329
 
278
- # 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).
279
332
  #
280
333
  # @return [Boolean]
281
334
  def idle?
282
- @tasks_lock.synchronize do
335
+ executor = @completion_executor
336
+ tracking_empty = @tasks_lock.synchronize do
283
337
  @queue.empty? && @pending_tasks.empty? && @inflight_requests.empty?
284
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
285
363
  end
286
364
 
287
365
  # Get the number of in-flight requests (actively executing HTTP calls).
@@ -391,6 +469,16 @@ module PatientHttp
391
469
 
392
470
  private
393
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
+
394
482
  # Run the async reactor loop.
395
483
  #
396
484
  # @return [void]
@@ -501,8 +589,6 @@ module PatientHttp
501
589
 
502
590
  notify_observers { |observer| observer.request_start(task) }
503
591
 
504
- claimed = false
505
-
506
592
  begin
507
593
  response_data = @http_client.make_request(task.request, task.id)
508
594
 
@@ -510,24 +596,14 @@ module PatientHttp
510
596
  # shutdown sequence has re-enqueued the task; discard the response.
511
597
  return if stopped?
512
598
 
513
- # Check for redirect handling. handle_redirect claims the task itself
514
- # (atomically with enqueueing the redirect) and returns whether this
515
- # caller owns delivery, so the ensure block can finish the task.
516
599
  if should_follow_redirect?(task, response_data)
517
- claimed = handle_redirect(task, response_data)
518
- return
519
- end
520
-
521
- response = task.build_response(**response_data)
522
- claimed = claim_task(task)
523
- if claimed
524
- if task.raise_error_responses && !response.success?
525
- http_error = HttpError.new(response)
526
- notify_observers { |observer| observer.request_error(http_error) }
527
- handle_error(task, http_error)
528
- else
529
- handle_completion(task, response)
530
- 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)
531
607
  end
532
608
  rescue ResponseReader::ReadAbortedError
533
609
  # The processor stopped past its shutdown deadline while the response
@@ -535,24 +611,103 @@ module PatientHttp
535
611
  # there is nothing to deliver.
536
612
  nil
537
613
  rescue => e
538
- # A failure raised after the task was claimed came from the delivery
539
- # attempt itself (only reachable in testing mode; the delivery helpers
540
- # rescue their own failures in production). Re-raise rather than
541
- # claiming again — claimed must stay true so the ensure block still
542
- # finishes the task.
543
- raise if claimed
544
-
545
- claimed = claim_task(task)
546
- if claimed
547
- notify_observers { |observer| observer.request_error(e) }
548
- 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) }
549
674
  end
550
675
  ensure
551
- finish_task(task) if claimed
552
- # Only fire the testing hook for tasks this caller actually owns.
553
- # A task discarded during shutdown (claimed == false) is re-enqueued
554
- # by the shutdown sequence, not processed here.
555
- @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
556
711
  end
557
712
  end
558
713
 
@@ -576,12 +731,33 @@ module PatientHttp
576
731
  # @param task [RequestTask] the request task
577
732
  # @return [void]
578
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
579
745
  @tasks_lock.synchronize do
580
- if @pending_tasks.empty? && @inflight_requests.empty?
746
+ if @pending_tasks.empty? && @inflight_requests.empty? && (executor.nil? || executor.idle?)
581
747
  @idle_condition.broadcast
582
748
  end
583
749
  end
584
- 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?
585
761
  end
586
762
 
587
763
  # Handle successful response. The caller must have claimed the task via
@@ -589,32 +765,36 @@ module PatientHttp
589
765
  #
590
766
  # @param task [RequestTask] the request task
591
767
  # @param response [Response] the response object
592
- # @return [void]
768
+ # @return [Exception, nil] the delivery failure or nil on success
593
769
  def handle_completion(task, response)
594
- task.completed!(response)
770
+ failure = deliver_with_retries(task) { task.completed!(response) }
595
771
 
596
- @config.logger&.debug(
597
- "[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
598
- "enqueued callback #{task.callback}"
599
- )
600
- rescue => e
601
- @config.logger&.error(
602
- "[PatientHttp] Failed to enqueue completion callback for request #{task.id}: #{e.class} - #{e.message}"
603
- )
604
- 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
605
785
  end
606
786
 
607
- # Handle a redirect response.
787
+ # Handle a redirect response on the reactor thread.
608
788
  #
609
- # Claims the task before delivering an error or enqueueing the redirect so
610
- # the result is delivered exactly once. When following a redirect, the
611
- # original task is removed from in-flight tracking and the redirect task is
612
- # pushed onto the queue within a single {@tasks_lock} section, so a
613
- # 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.
614
794
  #
615
795
  # @param task [RequestTask] the request task
616
796
  # @param response_data [Hash] the response data with status, headers, body
617
- # @return [Boolean] true if this caller owns delivery of the task's result
797
+ # @return [void]
618
798
  def handle_redirect(task, response_data)
619
799
  status = response_data[:status]
620
800
  location = response_data[:headers]["location"]
@@ -622,11 +802,8 @@ module PatientHttp
622
802
  # Check for redirect errors
623
803
  error = check_redirect_error(task, response_data)
624
804
  if error
625
- return false unless claim_task(task)
626
-
627
- notify_observers { |observer| observer.request_error(error) }
628
- handle_error(task, error)
629
- return true
805
+ dispatch_completion(task, error: error)
806
+ return
630
807
  end
631
808
 
632
809
  # Create the redirect task, then atomically claim the original (remove it
@@ -634,14 +811,23 @@ module PatientHttp
634
811
  # shutdown sequence already re-enqueued the original, so drop the redirect.
635
812
  redirect_task = task.redirect_task(location: location, status: status)
636
813
 
637
- claimed = announce_and_enqueue(redirect_task) do
638
- !@inflight_requests.delete(task.id).nil?
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
639
823
  end
640
- return false unless claimed
824
+ return unless claimed
641
825
 
642
826
  redirect_url = resolve_redirect_url(task.request.url, location)
643
827
  @config.logger&.debug("[PatientHttp] Request #{task.id} redirected (#{status}) to #{redirect_url}")
644
- true
828
+
829
+ finish_task(task)
830
+ @testing_callback&.call(task) if PatientHttp.testing?
645
831
  end
646
832
 
647
833
  # Handle error response. The caller must have claimed the task via
@@ -649,19 +835,23 @@ module PatientHttp
649
835
  #
650
836
  # @param task [RequestTask] the request task
651
837
  # @param exception [Exception] the exception
652
- # @return [void]
838
+ # @return [Exception, nil] the delivery failure or nil on success
653
839
  def handle_error(task, exception)
654
- task.error!(exception)
840
+ failure = deliver_with_retries(task) { task.error!(exception) }
655
841
 
656
- @config.logger&.warn(
657
- "[PatientHttp] Request #{task.id} failed with #{exception.class.name}: #{exception.message}, " \
658
- "enqueued callback #{task.callback}\n#{exception.backtrace&.join("\n")}"
659
- )
660
- rescue => e
661
- @config.logger&.error(
662
- "[PatientHttp] Failed to enqueue error worker for request #{task.id}: #{e.class} - #{e.message}"
663
- )
664
- 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
665
855
  end
666
856
 
667
857
  # Announce a task to observers and make it visible to the reactor. The
@@ -2,9 +2,31 @@
2
2
 
3
3
  module PatientHttp
4
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.
5
+ # a Processor and receive events as requests are processed. Observers should be
6
+ # lightweight and not do processing other than recording metrics or similar.
7
+ #
8
+ # Hooks run on different threads depending on where the event originates:
9
+ # - request_enqueued, request_rejected: the thread calling Processor#enqueue
10
+ # (usually an application thread), and the reactor thread for each task
11
+ # created to follow a redirect. Work done in these hooks blocks the reactor
12
+ # for redirected requests, so keep it off the critical path or accept the
13
+ # delay it adds to every other in-flight request
14
+ # - capacity_exceeded: the thread calling Processor#enqueue (usually an
15
+ # application thread)
16
+ # - request_start: the reactor thread
17
+ # - request_end, request_error, completion_failed: a completion worker
18
+ # thread (request_end also fires on the reactor thread for followed
19
+ # redirects, and on the stopping thread for shutdown re-enqueues)
20
+ # - request_requeued: the stopping thread or the reactor thread
21
+ # - start, stop: the thread calling Processor#start / Processor#stop
22
+ #
23
+ # Observers must be thread-safe. Hooks are called from several threads, and
24
+ # the completion-time hooks run on any of the completion worker threads, so
25
+ # two of them can run at the same time and in an order unrelated to the
26
+ # order the requests completed. Guard any counter or buffer an observer
27
+ # shares between calls. Setting completion_threads to 1 serializes the
28
+ # completion-time hooks but does not serialize them against the hooks that
29
+ # fire on other threads.
8
30
  class ProcessorObserver
9
31
  # Called when the processor starts.
10
32
  #
@@ -79,5 +101,16 @@ module PatientHttp
79
101
  # @return [void]
80
102
  def request_error(error)
81
103
  end
104
+
105
+ # Called when a finished result could not be delivered to the task handler
106
+ # after all retries. request_end is NOT sent for the task, so durable
107
+ # tracking set up in request_enqueued stays in place and an external
108
+ # recovery process (e.g. an orphan collector) can re-enqueue the request.
109
+ #
110
+ # @param request_task [RequestTask] the request task whose result was not delivered
111
+ # @param error [StandardError] the delivery failure
112
+ # @return [void]
113
+ def completion_failed(request_task, error)
114
+ end
82
115
  end
83
116
  end
@@ -42,6 +42,11 @@ module PatientHttp
42
42
  # to apply to the request when it is sent
43
43
  attr_reader :preprocessors
44
44
 
45
+ # @return [String, nil] Name of the processor that should execute the request.
46
+ # Integrations use this to route the request to a named processor; nil
47
+ # uses the default processor.
48
+ attr_reader :processor
49
+
45
50
  class << self
46
51
  # Reconstruct a Request from a hash
47
52
  #
@@ -56,7 +61,8 @@ module PatientHttp
56
61
  params: load_secret_params(hash["secret_params"]),
57
62
  timeout: hash["timeout"],
58
63
  max_redirects: hash["max_redirects"],
59
- preprocessors: hash["preprocessors"]
64
+ preprocessors: hash["preprocessors"],
65
+ processor: hash["processor"]
60
66
  )
61
67
  end
62
68
 
@@ -91,6 +97,8 @@ module PatientHttp
91
97
  # @param max_redirects [Integer, nil] Maximum redirects to follow (nil uses config, 0 disables).
92
98
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Names of preprocessors
93
99
  # registered on the configuration to apply to the request when it is sent.
100
+ # @param processor [String, Symbol, nil] Name of the processor that should execute the
101
+ # request. Integrations use this to route the request to a named processor.
94
102
  def initialize(
95
103
  http_method,
96
104
  url,
@@ -100,7 +108,8 @@ module PatientHttp
100
108
  params: nil,
101
109
  timeout: nil,
102
110
  max_redirects: nil,
103
- preprocessors: nil
111
+ preprocessors: nil,
112
+ processor: nil
104
113
  )
105
114
  @http_method = http_method.is_a?(String) ? http_method.downcase.to_sym : http_method
106
115
 
@@ -117,6 +126,7 @@ module PatientHttp
117
126
  @timeout = timeout
118
127
  @max_redirects = max_redirects
119
128
  @preprocessors = normalized_preprocessors(preprocessors)
129
+ @processor = normalized_processor(processor)
120
130
 
121
131
  if json
122
132
  raise ArgumentError.new("Cannot provide both body and json") if @body
@@ -158,6 +168,7 @@ module PatientHttp
158
168
  end
159
169
 
160
170
  hash["preprocessors"] = @preprocessors if @preprocessors.any?
171
+ hash["processor"] = @processor if @processor
161
172
 
162
173
  hash
163
174
  end
@@ -171,6 +182,16 @@ module PatientHttp
171
182
  end
172
183
  end
173
184
 
185
+ # Normalize the processor name to a frozen string or nil.
186
+ def normalized_processor(processor)
187
+ return nil if processor.nil?
188
+
189
+ name = processor.to_s
190
+ raise ArgumentError.new("processor name cannot be empty") if name.empty?
191
+
192
+ name.freeze
193
+ end
194
+
174
195
  # Normalize preprocessor names to a frozen array of strings.
175
196
  def normalized_preprocessors(preprocessors)
176
197
  names = Array(preprocessors).map(&:to_s)