patient_http 1.1.2 → 1.3.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/CHANGELOG.md +31 -0
- data/README.md +116 -3
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +7 -10
- data/lib/patient_http/configuration.rb +56 -1
- data/lib/patient_http/http_headers.rb +7 -0
- data/lib/patient_http/inline_task_handler.rb +30 -0
- data/lib/patient_http/lifecycle_manager.rb +20 -8
- data/lib/patient_http/outgoing_request.rb +72 -0
- data/lib/patient_http/payload.rb +1 -1
- data/lib/patient_http/payload_store/redis_store.rb +4 -1
- data/lib/patient_http/processor.rb +299 -113
- data/lib/patient_http/redirect_error.rb +5 -0
- data/lib/patient_http/request.rb +26 -3
- data/lib/patient_http/request_error.rb +1 -1
- data/lib/patient_http/request_helper.rb +16 -6
- data/lib/patient_http/request_preparer.rb +52 -0
- data/lib/patient_http/request_task.rb +16 -7
- data/lib/patient_http/request_template.rb +9 -3
- data/lib/patient_http/response.rb +3 -0
- data/lib/patient_http/response_reader.rb +16 -4
- data/lib/patient_http/synchronous_executor.rb +53 -45
- data/lib/patient_http.rb +182 -2
- metadata +5 -2
|
@@ -25,6 +25,12 @@ module PatientHttp
|
|
|
25
25
|
@lifecycle = LifecycleManager.new
|
|
26
26
|
@queue = Thread::Queue.new
|
|
27
27
|
@reactor_thread = nil
|
|
28
|
+
# Serializes start/stop so a start cannot interleave with a stop that is
|
|
29
|
+
# still reaping its reactor thread (and vice versa).
|
|
30
|
+
@lifecycle_mutex = Mutex.new
|
|
31
|
+
# Incremented once per reactor run; lets a reactor's teardown detect
|
|
32
|
+
# whether it is still the current run before mutating shared state.
|
|
33
|
+
@reactor_generation = 0
|
|
28
34
|
@inflight_requests = Concurrent::Hash.new
|
|
29
35
|
@pending_tasks = Concurrent::Hash.new
|
|
30
36
|
@tasks_lock = Mutex.new
|
|
@@ -38,28 +44,64 @@ module PatientHttp
|
|
|
38
44
|
#
|
|
39
45
|
# @return [void]
|
|
40
46
|
def start
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
observers_to_notify = nil
|
|
48
|
+
|
|
49
|
+
# Hold the lifecycle mutex across the whole start so a concurrent stop
|
|
50
|
+
# cannot interleave with (and reap) the reactor thread we are creating.
|
|
51
|
+
@lifecycle_mutex.synchronize do
|
|
52
|
+
# Claim this reactor run's generation atomically with the state
|
|
53
|
+
# transition. The reactor thread captures it below and its teardown
|
|
54
|
+
# only mutates shared state while it is still the current generation.
|
|
55
|
+
generation = @tasks_lock.synchronize do
|
|
56
|
+
return unless @lifecycle.start!
|
|
57
|
+
@reactor_generation += 1
|
|
58
|
+
end
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
60
|
+
@reactor_thread = Thread.new do
|
|
61
|
+
Thread.current.name = "patient-http-processor"
|
|
62
|
+
run_reactor
|
|
63
|
+
rescue => e
|
|
64
|
+
@config.logger&.error("[PatientHttp] Processor error: #{e.message}\n#{e.backtrace.join("\n")}")
|
|
65
|
+
|
|
66
|
+
raise if PatientHttp.testing?
|
|
67
|
+
ensure
|
|
68
|
+
# Mark the processor stopped when the reactor exits and re-enqueue any
|
|
69
|
+
# tasks still being tracked, so a reactor that exits without a stop()
|
|
70
|
+
# call (e.g. an unhandled error) does not lose in-flight/pending
|
|
71
|
+
# requests or leak stale tracking entries into a later run.
|
|
72
|
+
#
|
|
73
|
+
# Only act while this is still the current generation: a newer start
|
|
74
|
+
# (after a stop) owns the processor state and a stale reactor from a
|
|
75
|
+
# prior run must not clobber it. Snapshot and clear happen under the
|
|
76
|
+
# lock; re-enqueueing runs outside it. This is idempotent with stop()'s
|
|
77
|
+
# reenqueue_pending_requests: whichever runs second snapshots an empty
|
|
78
|
+
# set.
|
|
79
|
+
orphaned_tasks = @tasks_lock.synchronize do
|
|
80
|
+
if @reactor_generation == generation
|
|
81
|
+
drain_tracked_tasks_locked
|
|
82
|
+
else
|
|
83
|
+
[]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
reenqueue_tasks(orphaned_tasks)
|
|
87
|
+
end
|
|
50
88
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
89
|
+
# The transition can fail if the reactor thread already failed and
|
|
90
|
+
# marked the processor stopped. Capture the observer snapshot under the
|
|
91
|
+
# same lock as the transition so an observer registered concurrently via
|
|
92
|
+
# #observe is notified of start by exactly one path (here or in #observe).
|
|
93
|
+
started, observers = @tasks_lock.synchronize do
|
|
94
|
+
[@lifecycle.running!, @observers.dup]
|
|
95
|
+
end
|
|
96
|
+
observers_to_notify = observers if started
|
|
55
97
|
|
|
56
|
-
|
|
57
|
-
@lifecycle.
|
|
58
|
-
notify_observers { |observer| observer.start }
|
|
98
|
+
# Block until the reactor is ready
|
|
99
|
+
@lifecycle.wait_for_reactor(timeout: 5)
|
|
59
100
|
end
|
|
60
101
|
|
|
61
|
-
#
|
|
62
|
-
|
|
102
|
+
# Notify observers outside the lifecycle mutex so an observer callback
|
|
103
|
+
# that re-enters the processor cannot deadlock.
|
|
104
|
+
observers_to_notify&.each { |observer| notify_observer(observer) { |o| o.start } }
|
|
63
105
|
end
|
|
64
106
|
|
|
65
107
|
# Stop the processor.
|
|
@@ -68,43 +110,74 @@ module PatientHttp
|
|
|
68
110
|
# @return [void]
|
|
69
111
|
def stop(timeout: nil)
|
|
70
112
|
timeout ||= @config.shutdown_timeout
|
|
113
|
+
should_notify_stop = false
|
|
114
|
+
|
|
115
|
+
# Hold the lifecycle mutex across the whole stop so a concurrent start
|
|
116
|
+
# cannot begin (and reassign @reactor_thread) while we are tearing down.
|
|
117
|
+
@lifecycle_mutex.synchronize do
|
|
118
|
+
# Atomically transition to stopping and capture the reactor thread for
|
|
119
|
+
# this run. Joining/killing the captured reference rather than the ivar
|
|
120
|
+
# means we can never tear down a reactor from a different run.
|
|
121
|
+
reactor = @tasks_lock.synchronize do
|
|
122
|
+
return unless @lifecycle.stop!
|
|
123
|
+
@reactor_thread
|
|
124
|
+
end
|
|
71
125
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@tasks_lock.synchronize do
|
|
75
|
-
return unless @lifecycle.stop!
|
|
76
|
-
end
|
|
126
|
+
# Interrupt the reactor's queue wait by pushing a sentinel value
|
|
127
|
+
@queue.push(nil)
|
|
77
128
|
|
|
78
|
-
|
|
79
|
-
|
|
129
|
+
# Wait for in-flight and pending requests to complete.
|
|
130
|
+
# Queue items are not checked here — they will be re-enqueued by
|
|
131
|
+
# reenqueue_remaining_queue_items after the reactor thread exits.
|
|
132
|
+
if timeout > 0
|
|
133
|
+
deadline = monotonic_time + timeout
|
|
134
|
+
@tasks_lock.synchronize do
|
|
135
|
+
loop do
|
|
136
|
+
break if @pending_tasks.empty? && @inflight_requests.empty?
|
|
137
|
+
remaining = deadline - monotonic_time
|
|
138
|
+
break if remaining <= 0
|
|
139
|
+
@idle_condition.wait(@tasks_lock, remaining)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
80
143
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
144
|
+
reenqueue_pending_requests
|
|
145
|
+
|
|
146
|
+
# Reap the reactor thread — unless stop was called from the reactor
|
|
147
|
+
# thread itself (e.g. from a task callback or observer), where joining
|
|
148
|
+
# the current thread would raise ThreadError. In that case the reactor
|
|
149
|
+
# exits on its own once the callback returns (its loop sees the stopped
|
|
150
|
+
# state) and its ensure block performs the same cleanup.
|
|
151
|
+
if reactor && !reactor.equal?(Thread.current)
|
|
152
|
+
reactor.join(1) if reactor.alive?
|
|
153
|
+
if reactor.alive?
|
|
154
|
+
reactor.kill
|
|
155
|
+
# Wait for the killed thread's ensure blocks so a stale lifecycle
|
|
156
|
+
# transition cannot fire during a subsequent start.
|
|
157
|
+
reactor.join(1)
|
|
92
158
|
end
|
|
93
159
|
end
|
|
94
|
-
|
|
160
|
+
@tasks_lock.synchronize do
|
|
161
|
+
@reactor_thread = nil if @reactor_thread.equal?(reactor)
|
|
162
|
+
end
|
|
95
163
|
|
|
96
|
-
|
|
164
|
+
# Run a second pass now that the reactor has exited to catch any task
|
|
165
|
+
# that slipped into pending/in-flight tracking after the first snapshot
|
|
166
|
+
# (a task can be popped from the queue but not yet tracked when the
|
|
167
|
+
# snapshot is taken).
|
|
168
|
+
reenqueue_pending_requests
|
|
97
169
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
170
|
+
# Drain any items left in the queue after the reactor has exited.
|
|
171
|
+
# This must happen after the reactor thread is done to avoid consuming
|
|
172
|
+
# the nil sentinel that wakes the reactor.
|
|
173
|
+
reenqueue_remaining_queue_items
|
|
101
174
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# the nil sentinel that wakes the reactor.
|
|
105
|
-
reenqueue_remaining_queue_items
|
|
175
|
+
should_notify_stop = true
|
|
176
|
+
end
|
|
106
177
|
|
|
107
|
-
|
|
178
|
+
# Notify observers outside the lifecycle mutex so an observer callback
|
|
179
|
+
# that re-enters the processor cannot deadlock.
|
|
180
|
+
notify_observers { |observer| observer.stop } if should_notify_stop
|
|
108
181
|
end
|
|
109
182
|
|
|
110
183
|
# Drain the processor (stop accepting new requests).
|
|
@@ -125,18 +198,24 @@ module PatientHttp
|
|
|
125
198
|
# @raise [MaxCapacityError] if at max capacity
|
|
126
199
|
# @return [void]
|
|
127
200
|
def enqueue(task)
|
|
201
|
+
at_capacity = false
|
|
202
|
+
|
|
128
203
|
@tasks_lock.synchronize do
|
|
129
204
|
raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
|
|
130
205
|
|
|
131
206
|
# Check capacity - raise error if at max connections
|
|
132
207
|
total = @queue.size + @pending_tasks.size + @inflight_requests.size
|
|
133
208
|
if total >= @config.max_connections
|
|
134
|
-
|
|
135
|
-
|
|
209
|
+
at_capacity = true
|
|
210
|
+
else
|
|
211
|
+
task.enqueued!
|
|
212
|
+
@queue.push(task)
|
|
136
213
|
end
|
|
214
|
+
end
|
|
137
215
|
|
|
138
|
-
|
|
139
|
-
|
|
216
|
+
if at_capacity
|
|
217
|
+
notify_observers { |observer| observer.capacity_exceeded }
|
|
218
|
+
raise MaxCapacityError.new("Cannot enqueue request: already at max capacity (#{@config.max_connections} connections)")
|
|
140
219
|
end
|
|
141
220
|
end
|
|
142
221
|
|
|
@@ -233,12 +312,19 @@ module PatientHttp
|
|
|
233
312
|
# @param observer [ProcessorObserver] the observer to add
|
|
234
313
|
# @return [void]
|
|
235
314
|
def observe(observer)
|
|
315
|
+
notify_start = false
|
|
316
|
+
|
|
236
317
|
@tasks_lock.synchronize do
|
|
237
318
|
raise ArgumentError.new("Observer already added") if @observers.include?(observer)
|
|
238
319
|
|
|
239
320
|
@observers << observer
|
|
240
|
-
|
|
321
|
+
# Only self-notify when already running. An observer added while the
|
|
322
|
+
# processor is still starting is picked up by start's atomic observer
|
|
323
|
+
# snapshot, so notifying here too would deliver start twice.
|
|
324
|
+
notify_start = running?
|
|
241
325
|
end
|
|
326
|
+
|
|
327
|
+
notify_observer(observer) { |o| o.start } if notify_start
|
|
242
328
|
end
|
|
243
329
|
|
|
244
330
|
# Wait for the processor to start.
|
|
@@ -323,6 +409,22 @@ module PatientHttp
|
|
|
323
409
|
end
|
|
324
410
|
end
|
|
325
411
|
|
|
412
|
+
# Wait for in-flight request fibers to finish so responses that
|
|
413
|
+
# complete during the graceful shutdown window are delivered before
|
|
414
|
+
# the connection pools are closed below. Transient tasks (such as the
|
|
415
|
+
# connection pools' gardener tasks) are excluded; they are shut down
|
|
416
|
+
# by closing the HTTP client.
|
|
417
|
+
loop do
|
|
418
|
+
children = task.children&.to_a&.reject(&:transient?)
|
|
419
|
+
break if children.nil? || children.empty?
|
|
420
|
+
|
|
421
|
+
children.each do |child|
|
|
422
|
+
child.wait
|
|
423
|
+
rescue => e
|
|
424
|
+
@config.logger&.error("[PatientHttp] Error waiting for in-flight request: #{e.inspect}")
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
326
428
|
@config.logger&.info("[PatientHttp] Processor stopped")
|
|
327
429
|
rescue Async::Stop
|
|
328
430
|
@config.logger&.info("[PatientHttp] Reactor received stop signal")
|
|
@@ -330,10 +432,15 @@ module PatientHttp
|
|
|
330
432
|
@config.logger&.error("[PatientHttp] Reactor loop error: #{e.inspect}\n#{e.backtrace.join("\n")}")
|
|
331
433
|
ensure
|
|
332
434
|
# Close the HTTP connection pools while still inside the reactor so the
|
|
333
|
-
# pools
|
|
334
|
-
#
|
|
335
|
-
#
|
|
336
|
-
#
|
|
435
|
+
# pools shut down in an orderly fashion: in-flight responses have been
|
|
436
|
+
# delivered above, and each pool's background gardener task is stopped
|
|
437
|
+
# by the pool itself rather than force-cancelled by the dying reactor.
|
|
438
|
+
#
|
|
439
|
+
# Note: on Ruby < 3.2.7 / < 3.3.7, stopping a gardener still logs a
|
|
440
|
+
# spurious (harmless) ThreadError: "Attempt to unlock a mutex which is
|
|
441
|
+
# not locked" — a fiber interrupted in ConditionVariable#wait fails to
|
|
442
|
+
# re-acquire its mutex (https://bugs.ruby-lang.org/issues/20907, fixed
|
|
443
|
+
# in Ruby 3.2.7+, 3.3.7+, and 3.4+).
|
|
337
444
|
begin
|
|
338
445
|
@http_client.close
|
|
339
446
|
rescue => e
|
|
@@ -358,8 +465,12 @@ module PatientHttp
|
|
|
358
465
|
# @param task [RequestTask] the request task to process
|
|
359
466
|
# @return [void]
|
|
360
467
|
def process_request(task)
|
|
361
|
-
# Move from pending to in-flight tracking
|
|
468
|
+
# Move from pending to in-flight tracking. If the shutdown deadline has
|
|
469
|
+
# already passed, the shutdown sequence re-enqueues the task, so leave
|
|
470
|
+
# it in pending tracking and don't execute it.
|
|
362
471
|
@tasks_lock.synchronize do
|
|
472
|
+
return if stopped?
|
|
473
|
+
|
|
363
474
|
@pending_tasks.delete(task.id)
|
|
364
475
|
@inflight_requests[task.id] = task
|
|
365
476
|
end
|
|
@@ -368,50 +479,82 @@ module PatientHttp
|
|
|
368
479
|
|
|
369
480
|
# Mark task as started
|
|
370
481
|
task.started!
|
|
371
|
-
|
|
482
|
+
claimed = false
|
|
372
483
|
|
|
373
484
|
begin
|
|
374
485
|
response_data = @http_client.make_request(task.request, task.id)
|
|
375
486
|
|
|
376
|
-
#
|
|
377
|
-
|
|
487
|
+
# If the shutdown deadline passed while the request was in flight, the
|
|
488
|
+
# shutdown sequence has re-enqueued the task; discard the response.
|
|
489
|
+
return if stopped?
|
|
378
490
|
|
|
379
|
-
# Check for redirect handling
|
|
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.
|
|
380
494
|
if should_follow_redirect?(task, response_data)
|
|
381
|
-
handle_redirect(task, response_data)
|
|
382
|
-
response_handled = true
|
|
495
|
+
claimed = handle_redirect(task, response_data)
|
|
383
496
|
return
|
|
384
497
|
end
|
|
385
498
|
|
|
386
499
|
response = task.build_response(**response_data)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
|
393
509
|
end
|
|
394
|
-
|
|
395
|
-
|
|
510
|
+
rescue ResponseReader::ReadAbortedError
|
|
511
|
+
# The processor stopped past its shutdown deadline while the response
|
|
512
|
+
# body was being read. The shutdown sequence re-enqueues the task, so
|
|
513
|
+
# there is nothing to deliver.
|
|
514
|
+
nil
|
|
396
515
|
rescue => e
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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)
|
|
527
|
+
end
|
|
400
528
|
ensure
|
|
401
|
-
|
|
402
|
-
|
|
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?
|
|
403
534
|
end
|
|
404
535
|
end
|
|
405
536
|
|
|
406
|
-
#
|
|
537
|
+
# Atomically take ownership of delivering a task's result by removing it
|
|
538
|
+
# from in-flight tracking.
|
|
539
|
+
#
|
|
540
|
+
# Returns false when the task has already been claimed by the shutdown
|
|
541
|
+
# sequence (re-enqueued for retry), in which case the result must not be
|
|
542
|
+
# delivered.
|
|
407
543
|
#
|
|
408
544
|
# @param task [RequestTask] the request task
|
|
409
|
-
# @return [
|
|
410
|
-
def
|
|
411
|
-
|
|
545
|
+
# @return [Boolean] true if this caller owns delivery of the task's result
|
|
546
|
+
def claim_task(task)
|
|
547
|
+
@tasks_lock.synchronize do
|
|
548
|
+
!@inflight_requests.delete(task.id).nil?
|
|
549
|
+
end
|
|
550
|
+
end
|
|
412
551
|
|
|
552
|
+
# Signal idle waiters and notify observers after a claimed task finishes.
|
|
553
|
+
#
|
|
554
|
+
# @param task [RequestTask] the request task
|
|
555
|
+
# @return [void]
|
|
556
|
+
def finish_task(task)
|
|
413
557
|
@tasks_lock.synchronize do
|
|
414
|
-
@inflight_requests.delete(task.id)
|
|
415
558
|
if @pending_tasks.empty? && @inflight_requests.empty?
|
|
416
559
|
@idle_condition.broadcast
|
|
417
560
|
end
|
|
@@ -419,30 +562,37 @@ module PatientHttp
|
|
|
419
562
|
notify_observers { |observer| observer.request_end(task) }
|
|
420
563
|
end
|
|
421
564
|
|
|
422
|
-
# Handle successful response.
|
|
565
|
+
# Handle successful response. The caller must have claimed the task via
|
|
566
|
+
# {#claim_task} so the result is delivered exactly once.
|
|
423
567
|
#
|
|
424
568
|
# @param task [RequestTask] the request task
|
|
425
569
|
# @param response [Response] the response object
|
|
426
570
|
# @return [void]
|
|
427
571
|
def handle_completion(task, response)
|
|
428
|
-
if stopped?
|
|
429
|
-
@config.logger&.warn("[PatientHttp] Request #{task.id} succeeded after processor was stopped")
|
|
430
|
-
return
|
|
431
|
-
end
|
|
432
|
-
|
|
433
572
|
task.completed!(response)
|
|
434
573
|
|
|
435
574
|
@config.logger&.debug(
|
|
436
575
|
"[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
|
|
437
576
|
"enqueued callback #{task.callback}"
|
|
438
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?
|
|
439
583
|
end
|
|
440
584
|
|
|
441
585
|
# Handle a redirect response.
|
|
442
586
|
#
|
|
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.
|
|
592
|
+
#
|
|
443
593
|
# @param task [RequestTask] the request task
|
|
444
594
|
# @param response_data [Hash] the response data with status, headers, body
|
|
445
|
-
# @return [
|
|
595
|
+
# @return [Boolean] true if this caller owns delivery of the task's result
|
|
446
596
|
def handle_redirect(task, response_data)
|
|
447
597
|
status = response_data[:status]
|
|
448
598
|
location = response_data[:headers]["location"]
|
|
@@ -450,31 +600,39 @@ module PatientHttp
|
|
|
450
600
|
# Check for redirect errors
|
|
451
601
|
error = check_redirect_error(task, response_data)
|
|
452
602
|
if error
|
|
603
|
+
return false unless claim_task(task)
|
|
604
|
+
|
|
453
605
|
notify_observers { |observer| observer.request_error(error) }
|
|
454
606
|
handle_error(task, error)
|
|
455
|
-
return
|
|
607
|
+
return true
|
|
456
608
|
end
|
|
457
609
|
|
|
458
|
-
# Create redirect task
|
|
610
|
+
# Create the redirect task, then atomically claim the original (remove it
|
|
611
|
+
# from in-flight) and enqueue the redirect. If the claim fails the
|
|
612
|
+
# shutdown sequence already re-enqueued the original, so drop the redirect.
|
|
459
613
|
redirect_task = task.redirect_task(location: location, status: status)
|
|
460
614
|
redirect_task.enqueued!
|
|
461
|
-
|
|
615
|
+
|
|
616
|
+
claimed = @tasks_lock.synchronize do
|
|
617
|
+
next false if @inflight_requests.delete(task.id).nil?
|
|
618
|
+
|
|
619
|
+
@queue.push(redirect_task)
|
|
620
|
+
true
|
|
621
|
+
end
|
|
622
|
+
return false unless claimed
|
|
462
623
|
|
|
463
624
|
redirect_url = resolve_redirect_url(task.request.url, location)
|
|
464
625
|
@config.logger&.debug("[PatientHttp] Request #{task.id} redirected (#{status}) to #{redirect_url}")
|
|
626
|
+
true
|
|
465
627
|
end
|
|
466
628
|
|
|
467
|
-
# Handle error response.
|
|
629
|
+
# Handle error response. The caller must have claimed the task via
|
|
630
|
+
# {#claim_task} so the result is delivered exactly once.
|
|
468
631
|
#
|
|
469
632
|
# @param task [RequestTask] the request task
|
|
470
633
|
# @param exception [Exception] the exception
|
|
471
634
|
# @return [void]
|
|
472
635
|
def handle_error(task, exception)
|
|
473
|
-
if stopped?
|
|
474
|
-
@config.logger&.warn("[PatientHttp] Request #{task.id} failed after processor was stopped")
|
|
475
|
-
return
|
|
476
|
-
end
|
|
477
|
-
|
|
478
636
|
task.error!(exception)
|
|
479
637
|
|
|
480
638
|
@config.logger&.warn(
|
|
@@ -488,29 +646,54 @@ module PatientHttp
|
|
|
488
646
|
raise if PatientHttp.testing?
|
|
489
647
|
end
|
|
490
648
|
|
|
649
|
+
# Notify all observers of an event. Observers are called outside of any
|
|
650
|
+
# internal lock so they can safely call back into the processor.
|
|
491
651
|
def notify_observers(&block)
|
|
492
|
-
@observers.
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
@config.logger&.error(
|
|
496
|
-
"[PatientHttp] Observer #{observer.class.name} error: #{e.class} - #{e.message}"
|
|
497
|
-
)
|
|
498
|
-
raise e if PatientHttp.testing?
|
|
652
|
+
observers = @tasks_lock.synchronize { @observers.dup }
|
|
653
|
+
observers.each do |observer|
|
|
654
|
+
notify_observer(observer, &block)
|
|
499
655
|
end
|
|
500
656
|
end
|
|
501
657
|
|
|
658
|
+
def notify_observer(observer)
|
|
659
|
+
yield(observer)
|
|
660
|
+
rescue => e
|
|
661
|
+
@config.logger&.error(
|
|
662
|
+
"[PatientHttp] Observer #{observer.class.name} error: #{e.class} - #{e.message}"
|
|
663
|
+
)
|
|
664
|
+
raise e if PatientHttp.testing?
|
|
665
|
+
end
|
|
666
|
+
|
|
502
667
|
def reenqueue_pending_requests
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
@tasks_lock.synchronize do
|
|
506
|
-
# Now that we have the lock again, atomically transition to stopped and clear collections
|
|
507
|
-
@lifecycle.stopped!
|
|
508
|
-
tasks_to_reenqueue = @inflight_requests.values + @pending_tasks.values
|
|
509
|
-
@inflight_requests.clear
|
|
510
|
-
@pending_tasks.clear
|
|
511
|
-
end
|
|
668
|
+
reenqueue_tasks(drain_tracked_tasks)
|
|
669
|
+
end
|
|
512
670
|
|
|
513
|
-
|
|
671
|
+
# Atomically transition to stopped and remove all tracked (in-flight and
|
|
672
|
+
# pending) tasks, returning them so the caller can re-enqueue them.
|
|
673
|
+
#
|
|
674
|
+
# Acquires {@tasks_lock}; callers must NOT already hold it. Use
|
|
675
|
+
# {#drain_tracked_tasks_locked} when the lock is already held.
|
|
676
|
+
#
|
|
677
|
+
# @return [Array<RequestTask>] the tasks that were being tracked
|
|
678
|
+
def drain_tracked_tasks
|
|
679
|
+
@tasks_lock.synchronize { drain_tracked_tasks_locked }
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# Transition to stopped and remove all tracked tasks. Must be called with
|
|
683
|
+
# {@tasks_lock} held.
|
|
684
|
+
#
|
|
685
|
+
# @return [Array<RequestTask>] the tasks that were being tracked
|
|
686
|
+
def drain_tracked_tasks_locked
|
|
687
|
+
@lifecycle.stopped!
|
|
688
|
+
tasks = @inflight_requests.values + @pending_tasks.values
|
|
689
|
+
@inflight_requests.clear
|
|
690
|
+
@pending_tasks.clear
|
|
691
|
+
# Wake any stop() thread blocked on the idle condition. Without this, a
|
|
692
|
+
# reactor-side drain (e.g. after a crash during shutdown) would clear the
|
|
693
|
+
# tracking hashes without signalling, leaving stop() asleep until its
|
|
694
|
+
# full timeout elapses.
|
|
695
|
+
@idle_condition.broadcast
|
|
696
|
+
tasks
|
|
514
697
|
end
|
|
515
698
|
|
|
516
699
|
def reenqueue_remaining_queue_items
|
|
@@ -532,7 +715,10 @@ module PatientHttp
|
|
|
532
715
|
def reenqueue_tasks(tasks_to_reenqueue)
|
|
533
716
|
tasks_to_reenqueue.each do |task|
|
|
534
717
|
task.retry
|
|
535
|
-
|
|
718
|
+
# Only emit request_end for tasks that actually started, so observers
|
|
719
|
+
# that pair request_start/request_end (e.g. an in-flight gauge) stay
|
|
720
|
+
# balanced. Queued-but-never-started tasks emit neither.
|
|
721
|
+
notify_observers { |observer| observer.request_end(task) } if task.started?
|
|
536
722
|
|
|
537
723
|
@config.logger&.info(
|
|
538
724
|
"[PatientHttp] Retrying incomplete request #{task.id}"
|
|
@@ -25,8 +25,13 @@ module PatientHttp
|
|
|
25
25
|
#
|
|
26
26
|
# @param hash [Hash] hash representation
|
|
27
27
|
# @return [RedirectError] reconstructed error
|
|
28
|
+
# @raise [ArgumentError] if the serialized error class is not a RedirectError
|
|
28
29
|
def load(hash)
|
|
29
30
|
error_class = ClassHelper.resolve_class_name(hash["error_class"])
|
|
31
|
+
unless error_class.is_a?(Class) && error_class <= RedirectError
|
|
32
|
+
raise ArgumentError.new("Invalid redirect error class: #{hash["error_class"].inspect}")
|
|
33
|
+
end
|
|
34
|
+
|
|
30
35
|
error_class.new(
|
|
31
36
|
url: hash["url"],
|
|
32
37
|
http_method: hash["http_method"]&.to_sym,
|