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.
@@ -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
- @tasks_lock.synchronize do
42
- return unless @lifecycle.start!
43
- end
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
- @reactor_thread = Thread.new do
46
- Thread.current.name = "patient-http-processor"
47
- run_reactor
48
- rescue => e
49
- @config.logger&.error("[PatientHttp] Processor error: #{e.message}\n#{e.backtrace.join("\n")}")
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
- raise if PatientHttp.testing?
52
- ensure
53
- @tasks_lock.synchronize { @lifecycle.stopped! } if @reactor_thread == Thread.current
54
- end
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
- @tasks_lock.synchronize do
57
- @lifecycle.running!
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
- # Block until the reactor is ready
62
- @lifecycle.wait_for_reactor
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
- # Atomically transition to stopping state under lock to ensure consistency
73
- # with other state-checking operations
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
- # Interrupt the reactor's queue wait by pushing a sentinel value
79
- @queue.push(nil)
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
- # Wait for in-flight and pending requests to complete.
82
- # Queue items are not checked here — they will be re-enqueued by
83
- # reenqueue_remaining_queue_items after the reactor thread exits.
84
- if timeout > 0
85
- deadline = monotonic_time + timeout
86
- @tasks_lock.synchronize do
87
- loop do
88
- break if @pending_tasks.empty? && @inflight_requests.empty?
89
- remaining = deadline - monotonic_time
90
- break if remaining <= 0
91
- @idle_condition.wait(@tasks_lock, remaining)
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
- end
160
+ @tasks_lock.synchronize do
161
+ @reactor_thread = nil if @reactor_thread.equal?(reactor)
162
+ end
95
163
 
96
- reenqueue_pending_requests
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
- @reactor_thread.join(1) if @reactor_thread&.alive?
99
- @reactor_thread.kill if @reactor_thread&.alive?
100
- @reactor_thread = nil
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
- # Drain any items left in the queue after the reactor has exited.
103
- # This must happen after the reactor thread is done to avoid consuming
104
- # the nil sentinel that wakes the reactor.
105
- reenqueue_remaining_queue_items
175
+ should_notify_stop = true
176
+ end
106
177
 
107
- notify_observers { |observer| observer.stop }
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
- notify_observers { |observer| observer.capacity_exceeded }
135
- raise MaxCapacityError.new("Cannot enqueue request: already at max capacity (#{@config.max_connections} connections)")
209
+ at_capacity = true
210
+ else
211
+ task.enqueued!
212
+ @queue.push(task)
136
213
  end
214
+ end
137
215
 
138
- task.enqueued!
139
- @queue.push(task)
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
- observer.start if starting? || running?
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' background gardener tasks shut down gracefully. Otherwise the
334
- # reactor stops with open pools and async-pool force-cancels each
335
- # gardener mid-wait, emitting a noisy (but harmless) ThreadError:
336
- # "Attempt to unlock a mutex which is not locked".
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
- response_handled = false
482
+ claimed = false
372
483
 
373
484
  begin
374
485
  response_data = @http_client.make_request(task.request, task.id)
375
486
 
376
- # Return early because the body many not have been fully read.
377
- return if stopping? || stopped?
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
- if task.raise_error_responses && !response.success?
388
- http_error = HttpError.new(response)
389
- notify_observers { |observer| observer.request_error(http_error) }
390
- handle_error(task, http_error)
391
- else
392
- handle_completion(task, response)
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
- response_handled = true
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
- notify_observers { |observer| observer.request_error(e) }
398
- handle_error(task, e)
399
- response_handled = true
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
- cleanup_after_task(task, response_handled)
402
- @testing_callback&.call(task) if PatientHttp.testing?
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
- # Cleanup after request processing.
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 [void]
410
- def cleanup_after_task(task, response_handled)
411
- return if (stopping? || stopped?) && !response_handled
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 [void]
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 and enqueue it
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
- @queue.push(redirect_task)
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.each do |observer|
493
- yield(observer)
494
- rescue => e
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
- # Re-enqueue any remaining in-flight, pending, and queued tasks
504
- tasks_to_reenqueue = []
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
- reenqueue_tasks(tasks_to_reenqueue)
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
- notify_observers { |observer| observer.request_end(task) }
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,