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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +19 -7
- data/CHANGELOG.md +27 -0
- data/README.md +13 -4
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +27 -2
- data/lib/patient_http/client_pool.rb +14 -7
- data/lib/patient_http/completion_executor.rb +139 -0
- data/lib/patient_http/configuration.rb +46 -1
- data/lib/patient_http/payload.rb +37 -15
- data/lib/patient_http/processor.rb +284 -94
- data/lib/patient_http/processor_observer.rb +36 -3
- data/lib/patient_http/request.rb +23 -2
- data/lib/patient_http/request_helper.rb +15 -6
- data/lib/patient_http/request_preparer.rb +7 -0
- data/lib/patient_http/request_task.rb +2 -1
- data/lib/patient_http/request_template.rb +8 -3
- data/lib/patient_http/response_reader.rb +225 -17
- data/lib/patient_http/synchronous_executor.rb +16 -33
- data/lib/patient_http.rb +13 -2
- metadata +2 -1
|
@@ -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 =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
#
|
|
610
|
-
#
|
|
611
|
-
#
|
|
612
|
-
#
|
|
613
|
-
#
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
data/lib/patient_http/request.rb
CHANGED
|
@@ -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)
|