patient_http 1.1.2 → 1.2.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 +22 -0
- data/README.md +58 -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/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 +290 -109
- 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_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 +16 -2
- metadata +4 -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")
|
|
@@ -358,8 +460,12 @@ module PatientHttp
|
|
|
358
460
|
# @param task [RequestTask] the request task to process
|
|
359
461
|
# @return [void]
|
|
360
462
|
def process_request(task)
|
|
361
|
-
# Move from pending to in-flight tracking
|
|
463
|
+
# Move from pending to in-flight tracking. If the shutdown deadline has
|
|
464
|
+
# already passed, the shutdown sequence re-enqueues the task, so leave
|
|
465
|
+
# it in pending tracking and don't execute it.
|
|
362
466
|
@tasks_lock.synchronize do
|
|
467
|
+
return if stopped?
|
|
468
|
+
|
|
363
469
|
@pending_tasks.delete(task.id)
|
|
364
470
|
@inflight_requests[task.id] = task
|
|
365
471
|
end
|
|
@@ -368,50 +474,82 @@ module PatientHttp
|
|
|
368
474
|
|
|
369
475
|
# Mark task as started
|
|
370
476
|
task.started!
|
|
371
|
-
|
|
477
|
+
claimed = false
|
|
372
478
|
|
|
373
479
|
begin
|
|
374
480
|
response_data = @http_client.make_request(task.request, task.id)
|
|
375
481
|
|
|
376
|
-
#
|
|
377
|
-
|
|
482
|
+
# If the shutdown deadline passed while the request was in flight, the
|
|
483
|
+
# shutdown sequence has re-enqueued the task; discard the response.
|
|
484
|
+
return if stopped?
|
|
378
485
|
|
|
379
|
-
# Check for redirect handling
|
|
486
|
+
# Check for redirect handling. handle_redirect claims the task itself
|
|
487
|
+
# (atomically with enqueueing the redirect) and returns whether this
|
|
488
|
+
# caller owns delivery, so the ensure block can finish the task.
|
|
380
489
|
if should_follow_redirect?(task, response_data)
|
|
381
|
-
handle_redirect(task, response_data)
|
|
382
|
-
response_handled = true
|
|
490
|
+
claimed = handle_redirect(task, response_data)
|
|
383
491
|
return
|
|
384
492
|
end
|
|
385
493
|
|
|
386
494
|
response = task.build_response(**response_data)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
495
|
+
claimed = claim_task(task)
|
|
496
|
+
if claimed
|
|
497
|
+
if task.raise_error_responses && !response.success?
|
|
498
|
+
http_error = HttpError.new(response)
|
|
499
|
+
notify_observers { |observer| observer.request_error(http_error) }
|
|
500
|
+
handle_error(task, http_error)
|
|
501
|
+
else
|
|
502
|
+
handle_completion(task, response)
|
|
503
|
+
end
|
|
393
504
|
end
|
|
394
|
-
|
|
395
|
-
|
|
505
|
+
rescue ResponseReader::ReadAbortedError
|
|
506
|
+
# The processor stopped past its shutdown deadline while the response
|
|
507
|
+
# body was being read. The shutdown sequence re-enqueues the task, so
|
|
508
|
+
# there is nothing to deliver.
|
|
509
|
+
nil
|
|
396
510
|
rescue => e
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
511
|
+
# A failure raised after the task was claimed came from the delivery
|
|
512
|
+
# attempt itself (only reachable in testing mode; the delivery helpers
|
|
513
|
+
# rescue their own failures in production). Re-raise rather than
|
|
514
|
+
# claiming again — claimed must stay true so the ensure block still
|
|
515
|
+
# finishes the task.
|
|
516
|
+
raise if claimed
|
|
517
|
+
|
|
518
|
+
claimed = claim_task(task)
|
|
519
|
+
if claimed
|
|
520
|
+
notify_observers { |observer| observer.request_error(e) }
|
|
521
|
+
handle_error(task, e)
|
|
522
|
+
end
|
|
400
523
|
ensure
|
|
401
|
-
|
|
402
|
-
|
|
524
|
+
finish_task(task) if claimed
|
|
525
|
+
# Only fire the testing hook for tasks this caller actually owns.
|
|
526
|
+
# A task discarded during shutdown (claimed == false) is re-enqueued
|
|
527
|
+
# by the shutdown sequence, not processed here.
|
|
528
|
+
@testing_callback&.call(task) if claimed && PatientHttp.testing?
|
|
403
529
|
end
|
|
404
530
|
end
|
|
405
531
|
|
|
406
|
-
#
|
|
532
|
+
# Atomically take ownership of delivering a task's result by removing it
|
|
533
|
+
# from in-flight tracking.
|
|
534
|
+
#
|
|
535
|
+
# Returns false when the task has already been claimed by the shutdown
|
|
536
|
+
# sequence (re-enqueued for retry), in which case the result must not be
|
|
537
|
+
# delivered.
|
|
407
538
|
#
|
|
408
539
|
# @param task [RequestTask] the request task
|
|
409
|
-
# @return [
|
|
410
|
-
def
|
|
411
|
-
|
|
540
|
+
# @return [Boolean] true if this caller owns delivery of the task's result
|
|
541
|
+
def claim_task(task)
|
|
542
|
+
@tasks_lock.synchronize do
|
|
543
|
+
!@inflight_requests.delete(task.id).nil?
|
|
544
|
+
end
|
|
545
|
+
end
|
|
412
546
|
|
|
547
|
+
# Signal idle waiters and notify observers after a claimed task finishes.
|
|
548
|
+
#
|
|
549
|
+
# @param task [RequestTask] the request task
|
|
550
|
+
# @return [void]
|
|
551
|
+
def finish_task(task)
|
|
413
552
|
@tasks_lock.synchronize do
|
|
414
|
-
@inflight_requests.delete(task.id)
|
|
415
553
|
if @pending_tasks.empty? && @inflight_requests.empty?
|
|
416
554
|
@idle_condition.broadcast
|
|
417
555
|
end
|
|
@@ -419,30 +557,37 @@ module PatientHttp
|
|
|
419
557
|
notify_observers { |observer| observer.request_end(task) }
|
|
420
558
|
end
|
|
421
559
|
|
|
422
|
-
# Handle successful response.
|
|
560
|
+
# Handle successful response. The caller must have claimed the task via
|
|
561
|
+
# {#claim_task} so the result is delivered exactly once.
|
|
423
562
|
#
|
|
424
563
|
# @param task [RequestTask] the request task
|
|
425
564
|
# @param response [Response] the response object
|
|
426
565
|
# @return [void]
|
|
427
566
|
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
567
|
task.completed!(response)
|
|
434
568
|
|
|
435
569
|
@config.logger&.debug(
|
|
436
570
|
"[PatientHttp] Request #{task.id} succeeded with status #{response.status}, " \
|
|
437
571
|
"enqueued callback #{task.callback}"
|
|
438
572
|
)
|
|
573
|
+
rescue => e
|
|
574
|
+
@config.logger&.error(
|
|
575
|
+
"[PatientHttp] Failed to enqueue completion callback for request #{task.id}: #{e.class} - #{e.message}"
|
|
576
|
+
)
|
|
577
|
+
raise if PatientHttp.testing?
|
|
439
578
|
end
|
|
440
579
|
|
|
441
580
|
# Handle a redirect response.
|
|
442
581
|
#
|
|
582
|
+
# Claims the task before delivering an error or enqueueing the redirect so
|
|
583
|
+
# the result is delivered exactly once. When following a redirect, the
|
|
584
|
+
# original task is removed from in-flight tracking and the redirect task is
|
|
585
|
+
# pushed onto the queue within a single {@tasks_lock} section, so a
|
|
586
|
+
# concurrent {#idle?} never observes a moment where neither is tracked.
|
|
587
|
+
#
|
|
443
588
|
# @param task [RequestTask] the request task
|
|
444
589
|
# @param response_data [Hash] the response data with status, headers, body
|
|
445
|
-
# @return [
|
|
590
|
+
# @return [Boolean] true if this caller owns delivery of the task's result
|
|
446
591
|
def handle_redirect(task, response_data)
|
|
447
592
|
status = response_data[:status]
|
|
448
593
|
location = response_data[:headers]["location"]
|
|
@@ -450,31 +595,39 @@ module PatientHttp
|
|
|
450
595
|
# Check for redirect errors
|
|
451
596
|
error = check_redirect_error(task, response_data)
|
|
452
597
|
if error
|
|
598
|
+
return false unless claim_task(task)
|
|
599
|
+
|
|
453
600
|
notify_observers { |observer| observer.request_error(error) }
|
|
454
601
|
handle_error(task, error)
|
|
455
|
-
return
|
|
602
|
+
return true
|
|
456
603
|
end
|
|
457
604
|
|
|
458
|
-
# Create redirect task
|
|
605
|
+
# Create the redirect task, then atomically claim the original (remove it
|
|
606
|
+
# from in-flight) and enqueue the redirect. If the claim fails the
|
|
607
|
+
# shutdown sequence already re-enqueued the original, so drop the redirect.
|
|
459
608
|
redirect_task = task.redirect_task(location: location, status: status)
|
|
460
609
|
redirect_task.enqueued!
|
|
461
|
-
|
|
610
|
+
|
|
611
|
+
claimed = @tasks_lock.synchronize do
|
|
612
|
+
next false if @inflight_requests.delete(task.id).nil?
|
|
613
|
+
|
|
614
|
+
@queue.push(redirect_task)
|
|
615
|
+
true
|
|
616
|
+
end
|
|
617
|
+
return false unless claimed
|
|
462
618
|
|
|
463
619
|
redirect_url = resolve_redirect_url(task.request.url, location)
|
|
464
620
|
@config.logger&.debug("[PatientHttp] Request #{task.id} redirected (#{status}) to #{redirect_url}")
|
|
621
|
+
true
|
|
465
622
|
end
|
|
466
623
|
|
|
467
|
-
# Handle error response.
|
|
624
|
+
# Handle error response. The caller must have claimed the task via
|
|
625
|
+
# {#claim_task} so the result is delivered exactly once.
|
|
468
626
|
#
|
|
469
627
|
# @param task [RequestTask] the request task
|
|
470
628
|
# @param exception [Exception] the exception
|
|
471
629
|
# @return [void]
|
|
472
630
|
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
631
|
task.error!(exception)
|
|
479
632
|
|
|
480
633
|
@config.logger&.warn(
|
|
@@ -488,29 +641,54 @@ module PatientHttp
|
|
|
488
641
|
raise if PatientHttp.testing?
|
|
489
642
|
end
|
|
490
643
|
|
|
644
|
+
# Notify all observers of an event. Observers are called outside of any
|
|
645
|
+
# internal lock so they can safely call back into the processor.
|
|
491
646
|
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?
|
|
647
|
+
observers = @tasks_lock.synchronize { @observers.dup }
|
|
648
|
+
observers.each do |observer|
|
|
649
|
+
notify_observer(observer, &block)
|
|
499
650
|
end
|
|
500
651
|
end
|
|
501
652
|
|
|
653
|
+
def notify_observer(observer)
|
|
654
|
+
yield(observer)
|
|
655
|
+
rescue => e
|
|
656
|
+
@config.logger&.error(
|
|
657
|
+
"[PatientHttp] Observer #{observer.class.name} error: #{e.class} - #{e.message}"
|
|
658
|
+
)
|
|
659
|
+
raise e if PatientHttp.testing?
|
|
660
|
+
end
|
|
661
|
+
|
|
502
662
|
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
|
|
663
|
+
reenqueue_tasks(drain_tracked_tasks)
|
|
664
|
+
end
|
|
512
665
|
|
|
513
|
-
|
|
666
|
+
# Atomically transition to stopped and remove all tracked (in-flight and
|
|
667
|
+
# pending) tasks, returning them so the caller can re-enqueue them.
|
|
668
|
+
#
|
|
669
|
+
# Acquires {@tasks_lock}; callers must NOT already hold it. Use
|
|
670
|
+
# {#drain_tracked_tasks_locked} when the lock is already held.
|
|
671
|
+
#
|
|
672
|
+
# @return [Array<RequestTask>] the tasks that were being tracked
|
|
673
|
+
def drain_tracked_tasks
|
|
674
|
+
@tasks_lock.synchronize { drain_tracked_tasks_locked }
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Transition to stopped and remove all tracked tasks. Must be called with
|
|
678
|
+
# {@tasks_lock} held.
|
|
679
|
+
#
|
|
680
|
+
# @return [Array<RequestTask>] the tasks that were being tracked
|
|
681
|
+
def drain_tracked_tasks_locked
|
|
682
|
+
@lifecycle.stopped!
|
|
683
|
+
tasks = @inflight_requests.values + @pending_tasks.values
|
|
684
|
+
@inflight_requests.clear
|
|
685
|
+
@pending_tasks.clear
|
|
686
|
+
# Wake any stop() thread blocked on the idle condition. Without this, a
|
|
687
|
+
# reactor-side drain (e.g. after a crash during shutdown) would clear the
|
|
688
|
+
# tracking hashes without signalling, leaving stop() asleep until its
|
|
689
|
+
# full timeout elapses.
|
|
690
|
+
@idle_condition.broadcast
|
|
691
|
+
tasks
|
|
514
692
|
end
|
|
515
693
|
|
|
516
694
|
def reenqueue_remaining_queue_items
|
|
@@ -532,7 +710,10 @@ module PatientHttp
|
|
|
532
710
|
def reenqueue_tasks(tasks_to_reenqueue)
|
|
533
711
|
tasks_to_reenqueue.each do |task|
|
|
534
712
|
task.retry
|
|
535
|
-
|
|
713
|
+
# Only emit request_end for tasks that actually started, so observers
|
|
714
|
+
# that pair request_start/request_end (e.g. an in-flight gauge) stay
|
|
715
|
+
# balanced. Queued-but-never-started tasks emit neither.
|
|
716
|
+
notify_observers { |observer| observer.request_end(task) } if task.started?
|
|
536
717
|
|
|
537
718
|
@config.logger&.info(
|
|
538
719
|
"[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,
|
data/lib/patient_http/request.rb
CHANGED
|
@@ -38,6 +38,10 @@ module PatientHttp
|
|
|
38
38
|
# secret references, kept out of the serialized URL and resolved at send time
|
|
39
39
|
attr_reader :secret_params
|
|
40
40
|
|
|
41
|
+
# @return [Array<String>] Names of preprocessors registered on the configuration
|
|
42
|
+
# to apply to the request when it is sent
|
|
43
|
+
attr_reader :preprocessors
|
|
44
|
+
|
|
41
45
|
class << self
|
|
42
46
|
# Reconstruct a Request from a hash
|
|
43
47
|
#
|
|
@@ -51,7 +55,8 @@ module PatientHttp
|
|
|
51
55
|
body: Payload.load(hash["body"])&.value,
|
|
52
56
|
params: load_secret_params(hash["secret_params"]),
|
|
53
57
|
timeout: hash["timeout"],
|
|
54
|
-
max_redirects: hash["max_redirects"]
|
|
58
|
+
max_redirects: hash["max_redirects"],
|
|
59
|
+
preprocessors: hash["preprocessors"]
|
|
55
60
|
)
|
|
56
61
|
end
|
|
57
62
|
|
|
@@ -84,6 +89,8 @@ module PatientHttp
|
|
|
84
89
|
# @param params [Hash, nil] Query parameters to append to the URL.
|
|
85
90
|
# @param timeout [Numeric, nil] Overall timeout in seconds.
|
|
86
91
|
# @param max_redirects [Integer, nil] Maximum redirects to follow (nil uses config, 0 disables).
|
|
92
|
+
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Names of preprocessors
|
|
93
|
+
# registered on the configuration to apply to the request when it is sent.
|
|
87
94
|
def initialize(
|
|
88
95
|
http_method,
|
|
89
96
|
url,
|
|
@@ -92,7 +99,8 @@ module PatientHttp
|
|
|
92
99
|
json: nil,
|
|
93
100
|
params: nil,
|
|
94
101
|
timeout: nil,
|
|
95
|
-
max_redirects: nil
|
|
102
|
+
max_redirects: nil,
|
|
103
|
+
preprocessors: nil
|
|
96
104
|
)
|
|
97
105
|
@http_method = http_method.is_a?(String) ? http_method.downcase.to_sym : http_method
|
|
98
106
|
|
|
@@ -102,10 +110,13 @@ module PatientHttp
|
|
|
102
110
|
|
|
103
111
|
@secret_params = {}
|
|
104
112
|
@url = normalized_url(url, params)
|
|
105
|
-
|
|
113
|
+
# Copy the headers so the request does not share mutable state with the
|
|
114
|
+
# caller (or with another request when following redirects).
|
|
115
|
+
@headers = headers.is_a?(HttpHeaders) ? headers.dup : HttpHeaders.new(headers)
|
|
106
116
|
@body = (body == "") ? nil : body
|
|
107
117
|
@timeout = timeout
|
|
108
118
|
@max_redirects = max_redirects
|
|
119
|
+
@preprocessors = normalized_preprocessors(preprocessors)
|
|
109
120
|
|
|
110
121
|
if json
|
|
111
122
|
raise ArgumentError.new("Cannot provide both body and json") if @body
|
|
@@ -146,6 +157,8 @@ module PatientHttp
|
|
|
146
157
|
hash["secret_params"] = @secret_params.transform_values(&:as_json)
|
|
147
158
|
end
|
|
148
159
|
|
|
160
|
+
hash["preprocessors"] = @preprocessors if @preprocessors.any?
|
|
161
|
+
|
|
149
162
|
hash
|
|
150
163
|
end
|
|
151
164
|
|
|
@@ -158,6 +171,16 @@ module PatientHttp
|
|
|
158
171
|
end
|
|
159
172
|
end
|
|
160
173
|
|
|
174
|
+
# Normalize preprocessor names to a frozen array of strings.
|
|
175
|
+
def normalized_preprocessors(preprocessors)
|
|
176
|
+
names = Array(preprocessors).map(&:to_s)
|
|
177
|
+
if names.any?(&:empty?)
|
|
178
|
+
raise ArgumentError.new("preprocessor names cannot be empty")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
names.freeze
|
|
182
|
+
end
|
|
183
|
+
|
|
161
184
|
def normalized_url(url, params)
|
|
162
185
|
uri = url.is_a?(URI::Generic) ? url.dup : URI(url.to_s)
|
|
163
186
|
return uri.to_s unless params&.any?
|
|
@@ -80,7 +80,7 @@ module PatientHttp
|
|
|
80
80
|
:timeout
|
|
81
81
|
in OpenSSL::SSL::SSLError
|
|
82
82
|
:ssl
|
|
83
|
-
in Errno::ECONNREFUSED | Errno::ECONNRESET | Errno::ECONNABORTED | Errno::EHOSTUNREACH | Errno::EPIPE | SocketError | IOError
|
|
83
|
+
in Errno::ECONNREFUSED | Errno::ECONNRESET | Errno::ECONNABORTED | Errno::EHOSTUNREACH | Errno::ETIMEDOUT | Errno::EPIPE | SocketError | IOError
|
|
84
84
|
:connection
|
|
85
85
|
else
|
|
86
86
|
if exception.is_a?(PatientHttp::ResponseTooLargeError)
|