patient_http 1.1.1 → 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.
@@ -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")
@@ -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
- response_handled = false
477
+ claimed = false
372
478
 
373
479
  begin
374
480
  response_data = @http_client.make_request(task.request, task.id)
375
481
 
376
- # Return early because the body many not have been fully read.
377
- return if stopping? || stopped?
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
- 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)
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
- response_handled = true
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
- notify_observers { |observer| observer.request_error(e) }
398
- handle_error(task, e)
399
- response_handled = true
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
- cleanup_after_task(task, response_handled)
402
- @testing_callback&.call(task) if PatientHttp.testing?
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
- # Cleanup after request processing.
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 [void]
410
- def cleanup_after_task(task, response_handled)
411
- return if (stopping? || stopped?) && !response_handled
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 [void]
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 and enqueue it
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
- @queue.push(redirect_task)
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.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?
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
- # 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
663
+ reenqueue_tasks(drain_tracked_tasks)
664
+ end
512
665
 
513
- reenqueue_tasks(tasks_to_reenqueue)
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
- notify_observers { |observer| observer.request_end(task) }
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,
@@ -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
- @headers = headers.is_a?(HttpHeaders) ? headers : HttpHeaders.new(headers)
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::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)