patient_http 1.6.1 → 1.7.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -7
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +539 -515
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +28 -22
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +367 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +35 -29
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +70 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +142 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +113 -84
  40. data/lib/patient_http/request_template.rb +87 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +66 -65
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +67 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +311 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
@@ -8,30 +8,28 @@ end
8
8
 
9
9
  module PatientHttp
10
10
  module PayloadStore
11
- # S3-based payload store for production deployments.
11
+ # A payload store that keeps payloads as JSON objects in Amazon S3. Use it
12
+ # in production when payloads need durable storage that several processes
13
+ # and hosts share.
12
14
  #
13
- # Stores payloads as JSON objects in S3. This store is recommended
14
- # for production environments where payloads need durable storage
15
- # and can be shared across multiple processes/instances.
15
+ # Requires the `aws-sdk-s3` gem. The S3 client is responsible for thread
16
+ # safety.
16
17
  #
17
- # Thread-safe: S3 clients handle their own thread safety.
18
- #
19
- # @example Configuration with S3 bucket
18
+ # @example Register an S3 store
20
19
  # s3 = Aws::S3::Resource.new
21
20
  # bucket = s3.bucket("my-payloads-bucket")
22
21
  # config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
23
22
  class S3Store < Base
24
23
  Base.register :s3, self
25
24
 
26
- # @return [String] The key prefix used for all stored payloads
25
+ # @return [String] The prefix for the keys of all stored payloads.
27
26
  attr_reader :key_prefix
28
27
 
29
- # Initialize a new S3 store.
28
+ # Creates an S3 store.
30
29
  #
31
- # @param bucket [Aws::S3::Bucket] S3 Bucket object. Required.
32
- # @param key_prefix [String] Prefix for all S3 object keys.
33
- # Defaults to "patient_http/payloads/"
34
- # @raise [ArgumentError] If bucket is not provided
30
+ # @param bucket [Aws::S3::Bucket] The S3 bucket.
31
+ # @param key_prefix [String] The prefix for all S3 object keys.
32
+ # @raise [ArgumentError] If the bucket is missing.
35
33
  def initialize(bucket:, key_prefix: nil)
36
34
  raise ArgumentError, "S3 bucket is required" unless bucket
37
35
 
@@ -39,21 +37,22 @@ module PatientHttp
39
37
  @key_prefix = key_prefix || "patient_http/payloads/"
40
38
  end
41
39
 
42
- # Store pre-serialized JSON string directly in S3.
40
+ # Stores a JSON string in S3.
43
41
  #
44
- # @param key [String] Unique key (appended to key_prefix)
45
- # @param json [String] Pre-serialized JSON string
46
- # @return [String] The key
42
+ # @param key [String] The unique key. The object key is the key prefix
43
+ # and this key.
44
+ # @param json [String] The serialized JSON.
45
+ # @return [String] The key.
47
46
  def store_json(key, json)
48
47
  full_key = key_with_prefix(key)
49
48
  @bucket.object(full_key).put(body: json, content_type: "application/json")
50
49
  key
51
50
  end
52
51
 
53
- # Fetch data from S3.
52
+ # Fetches stored data.
54
53
  #
55
- # @param key [String] The key to fetch
56
- # @return [Hash, nil] The stored data or nil if not found
54
+ # @param key [String] The key.
55
+ # @return [Hash, nil] The parsed data, or `nil` if the key isn't found.
57
56
  def fetch(key)
58
57
  full_key = key_with_prefix(key)
59
58
  response = @bucket.object(full_key).get
@@ -63,22 +62,20 @@ module PatientHttp
63
62
  nil
64
63
  end
65
64
 
66
- # Delete a payload from S3.
67
- #
68
- # Idempotent - does not raise if object doesn't exist.
65
+ # Deletes stored data. Doesn't raise an error if the key doesn't exist.
69
66
  #
70
- # @param key [String] The key to delete
71
- # @return [Boolean] true
67
+ # @param key [String] The key.
68
+ # @return [Boolean] `true`.
72
69
  def delete(key)
73
70
  full_key = key_with_prefix(key)
74
71
  @bucket.object(full_key).delete
75
72
  true
76
73
  end
77
74
 
78
- # Check if a payload exists.
75
+ # Returns whether a payload exists.
79
76
  #
80
- # @param key [String] The key to check
81
- # @return [Boolean] true if the payload exists
77
+ # @param key [String] The key.
78
+ # @return [Boolean] `true` if the payload exists.
82
79
  def exists?(key)
83
80
  full_key = key_with_prefix(key)
84
81
  @bucket.object(full_key).exists?
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
+ # Payload stores for large payloads. Register a store with
5
+ # {Configuration#register_payload_store}.
4
6
  module PayloadStore
5
7
  autoload :Base, File.join(__dir__, "payload_store/base")
6
8
  autoload :ActiveRecordStore, File.join(__dir__, "payload_store/active_record_store")
@@ -1,40 +1,56 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Core processor that handles async HTTP requests in a dedicated thread
4
+ # Runs HTTP requests in a dedicated reactor thread.
5
+ #
6
+ # The reactor thread uses Ruby's fiber scheduler, so it can run hundreds of
7
+ # requests at the same time. Completion worker threads decode the responses
8
+ # and deliver the results through each task's {TaskHandler}.
9
+ #
10
+ # The processor moves through these states:
11
+ #
12
+ # stopped -> starting -> running -> draining -> stopping -> stopped
13
+ #
14
+ # @example Run a processor
15
+ # config = PatientHttp::Configuration.new(max_connections: 256)
16
+ # processor = PatientHttp::Processor.new(config)
17
+ # processor.start
18
+ # processor.enqueue(task)
19
+ # processor.stop(timeout: 25)
5
20
  class Processor
6
21
  include TimeHelper
7
22
  include RedirectHelper
8
23
 
9
- # Timing constants for the reactor loop
10
- DEQUEUE_TIMEOUT = 1.0 # Seconds to wait when dequeueing requests
24
+ # The seconds that the reactor waits for a task before it checks for
25
+ # shutdown.
26
+ DEQUEUE_TIMEOUT = 1.0
11
27
 
12
- # Base delay between attempts when delivering a completed result fails.
13
- # The delay grows linearly with each attempt.
28
+ # The base delay in seconds between result delivery attempts. The delay
29
+ # increases linearly with each attempt.
14
30
  COMPLETION_RETRY_DELAY = 0.5
15
31
 
16
- # Seconds allowed for the completion executor to drain during shutdown.
17
- # The reactor's teardown and stop() share this budget so the reactor can
18
- # never spend longer draining than stop() is willing to wait for it.
32
+ # The seconds that the completion workers have to finish at shutdown. The
33
+ # reactor teardown and {#stop} share this time, so the reactor never waits
34
+ # longer than {#stop}.
19
35
  COMPLETION_SHUTDOWN_TIMEOUT = 5
20
36
 
21
- # @return [Configuration] the configuration object for the processor
37
+ # @return [Configuration] The processor configuration.
22
38
  attr_reader :config
23
39
 
24
- # @return [String] the processor's name; used in thread names so multiple
25
- # named processors in one process are distinguishable
40
+ # @return [String] The processor name. Thread names include it, so you can
41
+ # identify the threads of each processor in a process.
26
42
  attr_reader :name
27
43
 
28
- # Callback to invoke after each request. Only available in testing mode.
44
+ # A callback that runs after each request. Available only in test mode.
45
+ #
29
46
  # @api private
30
47
  attr_accessor :testing_callback
31
48
 
32
- # Initialize the processor.
49
+ # Creates a processor.
33
50
  #
34
- # @param config [Configuration] the configuration object
35
- # @param name [String, Symbol] optional name to distinguish this processor
36
- # when a process runs more than one
37
- # @return [void]
51
+ # @param config [Configuration] The processor configuration.
52
+ # @param name [String, Symbol] The processor name. Use a different name for
53
+ # each processor in a process.
38
54
  def initialize(config, name: "default")
39
55
  @config = config
40
56
  @name = name.to_s
@@ -61,7 +77,7 @@ module PatientHttp
61
77
  @completion_executor = nil
62
78
  end
63
79
 
64
- # Start the processor.
80
+ # Starts the processor. This method returns when the reactor is ready.
65
81
  #
66
82
  # @return [void]
67
83
  def start
@@ -156,9 +172,14 @@ module PatientHttp
156
172
  observers_to_notify&.each { |observer| notify_observer(observer) { |o| o.start } }
157
173
  end
158
174
 
159
- # Stop the processor.
175
+ # Stops the processor.
160
176
  #
161
- # @param timeout [Numeric, nil] how long to wait for in-flight requests (seconds)
177
+ # The processor waits for in-flight requests to finish. When the timeout
178
+ # ends, it calls {TaskHandler#retry} for each request that didn't finish,
179
+ # so the job system can run it again.
180
+ #
181
+ # @param timeout [Numeric, nil] The seconds to wait for in-flight requests.
182
+ # If `nil`, the configured `shutdown_timeout` applies.
162
183
  # @return [void]
163
184
  def stop(timeout: nil)
164
185
  timeout ||= @config.shutdown_timeout
@@ -243,7 +264,7 @@ module PatientHttp
243
264
  notify_observers { |observer| observer.stop } if should_notify_stop
244
265
  end
245
266
 
246
- # Drain the processor (stop accepting new requests).
267
+ # Stops accepting new requests. In-flight requests continue to run.
247
268
  #
248
269
  # @return [void]
249
270
  def drain
@@ -254,11 +275,14 @@ module PatientHttp
254
275
  @config.logger&.info("[PatientHttp] Processor draining (no longer accepting new requests)")
255
276
  end
256
277
 
257
- # Enqueue a request task for processing.
278
+ # Adds a task to the processor queue.
279
+ #
280
+ # Observers receive `request_enqueued` before the task is queued. If the
281
+ # processor doesn't accept the task, they receive `request_rejected`.
258
282
  #
259
- # @param task [RequestTask] the request task to enqueue
260
- # @raise [NotRunningError] if processor is not running
261
- # @raise [MaxCapacityError] if at max capacity
283
+ # @param task [RequestTask] The task.
284
+ # @raise [NotRunningError] If the processor isn't running.
285
+ # @raise [MaxCapacityError] If the processor is at `max_connections`.
262
286
  # @return [void]
263
287
  def enqueue(task)
264
288
  raise NotRunningError.new("Cannot enqueue request: processor is #{state}") unless running?
@@ -278,59 +302,61 @@ module PatientHttp
278
302
  end
279
303
  end
280
304
 
281
- # Get the current processor state.
305
+ # Returns the processor state.
282
306
  #
283
- # @return [Symbol] the current state
307
+ # @return [Symbol] `:stopped`, `:starting`, `:running`, `:draining`, or
308
+ # `:stopping`.
284
309
  def state
285
310
  @lifecycle.state
286
311
  end
287
312
 
288
- # Check if processor is starting.
313
+ # Returns whether the processor is starting.
289
314
  #
290
- # @return [Boolean]
315
+ # @return [Boolean] `true` if the state is `:starting`.
291
316
  def starting?
292
317
  @lifecycle.starting?
293
318
  end
294
319
 
295
- # Check if processor is running.
320
+ # Returns whether the processor is running and accepts requests.
296
321
  #
297
- # @return [Boolean]
322
+ # @return [Boolean] `true` if the state is `:running`.
298
323
  def running?
299
324
  @lifecycle.running?
300
325
  end
301
326
 
302
- # Check if processor is stopped.
327
+ # Returns whether the processor is stopped.
303
328
  #
304
- # @return [Boolean]
329
+ # @return [Boolean] `true` if the state is `:stopped`.
305
330
  def stopped?
306
331
  @lifecycle.stopped?
307
332
  end
308
333
 
309
- # Check if processor is draining.
334
+ # Returns whether the processor is draining. A draining processor doesn't
335
+ # accept new requests but continues to run in-flight requests.
310
336
  #
311
- # @return [Boolean]
337
+ # @return [Boolean] `true` if the state is `:draining`.
312
338
  def draining?
313
339
  @lifecycle.draining?
314
340
  end
315
341
 
316
- # Check if processor is drained (draining and idle).
342
+ # Returns whether the processor is draining and idle.
317
343
  #
318
- # @return [Boolean]
344
+ # @return [Boolean] `true` if the processor is draining and has no requests.
319
345
  def drained?
320
346
  @lifecycle.draining? && idle?
321
347
  end
322
348
 
323
- # Check if processor is stopping.
349
+ # Returns whether the processor is stopping.
324
350
  #
325
- # @return [Boolean]
351
+ # @return [Boolean] `true` if the state is `:stopping`.
326
352
  def stopping?
327
353
  @lifecycle.stopping?
328
354
  end
329
355
 
330
- # Check if processor is idle (no queued or in-flight requests, and no
331
- # results still being delivered by the completion executor).
356
+ # Returns whether the processor is idle. An idle processor has no queued
357
+ # or in-flight requests, and no results that wait for delivery.
332
358
  #
333
- # @return [Boolean]
359
+ # @return [Boolean] `true` if the processor is idle.
334
360
  def idle?
335
361
  executor = @completion_executor
336
362
  tracking_empty = @tasks_lock.synchronize do
@@ -340,13 +366,14 @@ module PatientHttp
340
366
  tracking_empty && (executor.nil? || executor.idle?)
341
367
  end
342
368
 
343
- # Check how many more requests the processor can accept before reaching
344
- # max capacity. This is an advisory value: the authoritative check happens
345
- # inside {#enqueue}, so a concurrent enqueue can still hit
346
- # {MaxCapacityError}. It performs no observer notifications and no durable
347
- # registration, so it is cheap to call before paying enqueue costs.
369
+ # Returns the number of requests that the processor can accept before it
370
+ # reaches `max_connections`.
371
+ #
372
+ # The value is advisory. {#enqueue} makes the final check, so a concurrent
373
+ # enqueue can still raise {MaxCapacityError}. This method doesn't notify
374
+ # observers, so it's a fast check before a costly enqueue.
348
375
  #
349
- # @return [Integer] remaining capacity (never negative)
376
+ # @return [Integer] The remaining capacity. Never negative.
350
377
  def remaining_capacity
351
378
  @tasks_lock.synchronize do
352
379
  remaining = @config.max_connections - (@queue.size + @pending_tasks.size + @inflight_requests.size)
@@ -354,58 +381,58 @@ module PatientHttp
354
381
  end
355
382
  end
356
383
 
357
- # Check if the processor can accept at least one more request. Advisory
358
- # only; see {#remaining_capacity}.
384
+ # Returns whether the processor can accept one more request. The value is
385
+ # advisory. See {#remaining_capacity}.
359
386
  #
360
- # @return [Boolean]
387
+ # @return [Boolean] `true` if the processor has capacity.
361
388
  def capacity_available?
362
389
  remaining_capacity > 0
363
390
  end
364
391
 
365
- # Get the number of in-flight requests (actively executing HTTP calls).
392
+ # Returns the number of requests that are running.
366
393
  #
367
- # This does not include queued or pending tasks. For the total pipeline
368
- # count used by the capacity check, see {#total_count}.
394
+ # The count doesn't include queued or pending tasks. For the count that the
395
+ # capacity check uses, see {#total_count}.
369
396
  #
370
- # @return [Integer]
397
+ # @return [Integer] The number of in-flight requests.
371
398
  def inflight_count
372
399
  @inflight_requests.size
373
400
  end
374
401
 
375
- # Get the total number of tasks in the pipeline (queued + pending + in-flight).
376
- #
377
- # This is the count used by {#enqueue} for capacity enforcement.
402
+ # Returns the number of queued, pending, and in-flight tasks. {#enqueue}
403
+ # compares this count with `max_connections`.
378
404
  #
379
- # @return [Integer]
405
+ # @return [Integer] The number of tasks.
380
406
  def total_count
381
407
  @tasks_lock.synchronize do
382
408
  @queue.size + @pending_tasks.size + @inflight_requests.size
383
409
  end
384
410
  end
385
411
 
386
- # Get the IDs of in-flight requests.
412
+ # Returns the IDs of the requests that are running.
387
413
  #
388
- # @return [Array<String>]
414
+ # @return [Array<String>] The task IDs.
389
415
  def inflight_request_ids
390
416
  @tasks_lock.synchronize do
391
417
  @inflight_requests.keys
392
418
  end
393
419
  end
394
420
 
395
- # Get the IDs of all tasks in the pipeline (queued, pending, and in-flight).
396
- # Use this to keep durable tracking (e.g. heartbeats) alive for tasks the
397
- # processor has accepted but not yet started.
421
+ # Returns the IDs of all queued, pending, and in-flight tasks. Use it to
422
+ # keep durable tracking, such as heartbeats, current for tasks that the
423
+ # processor accepted but didn't start yet.
398
424
  #
399
- # @return [Array<String>]
425
+ # @return [Array<String>] The task IDs.
400
426
  def tracked_request_ids
401
427
  @tasks_lock.synchronize do
402
428
  (@queued_tasks.keys + @pending_tasks.keys + @inflight_requests.keys).uniq
403
429
  end
404
430
  end
405
431
 
406
- # Add an observer for processor events.
432
+ # Adds an observer that receives processor events. If the processor is
433
+ # running, the observer receives `start` immediately.
407
434
  #
408
- # @param observer [ProcessorObserver] the observer to add
435
+ # @param observer [ProcessorObserver] The observer.
409
436
  # @return [void]
410
437
  def observe(observer)
411
438
  notify_start = false
@@ -423,30 +450,33 @@ module PatientHttp
423
450
  notify_observer(observer) { |o| o.start } if notify_start
424
451
  end
425
452
 
426
- # Wait for the processor to start.
453
+ # Waits for the processor to start.
427
454
  #
428
- # @param timeout [Numeric] maximum time to wait in seconds (default: 5)
429
- # @return [Boolean] true if started, false if timeout reached
455
+ # @param timeout [Numeric] The maximum seconds to wait.
456
+ # @return [Boolean] `true` if the processor started, or `false` if the
457
+ # timeout ended first.
430
458
  # @api private
431
459
  def wait_for_running(timeout: 5)
432
460
  start
433
461
  @lifecycle.wait_for_running(timeout: timeout)
434
462
  end
435
463
 
436
- # Wait for the queue to be empty and all in-flight requests to complete.
437
- # This is mainly for use in tests.
464
+ # Waits for the queue to be empty and all in-flight requests to finish.
465
+ # Intended for tests.
438
466
  #
439
- # @param timeout [Numeric] maximum time to wait in seconds (default: 5)
440
- # @return [Boolean] true if processing completed, false if timeout reached
467
+ # @param timeout [Numeric] The maximum seconds to wait.
468
+ # @return [Boolean] `true` if the processor is idle, or `false` if the
469
+ # timeout ended first.
441
470
  # @api private
442
471
  def wait_for_idle(timeout: 1)
443
472
  @lifecycle.wait_for_condition(timeout: timeout) { idle? }
444
473
  end
445
474
 
446
- # Wait for at least one request to start processing. This is mainly for use in tests.
475
+ # Waits for a request to start. Intended for tests.
447
476
  #
448
- # @param timeout [Numeric] maximum time to wait in seconds (default: 5)
449
- # @return [Boolean] true if a request started processing, false if timeout reached
477
+ # @param timeout [Numeric] The maximum seconds to wait.
478
+ # @return [Boolean] `true` if a request started, or `false` if the timeout
479
+ # ended first.
450
480
  # @api private
451
481
  def wait_for_processing(timeout: 1)
452
482
  @lifecycle.wait_for_condition(timeout: timeout) do
@@ -454,9 +484,11 @@ module PatientHttp
454
484
  end
455
485
  end
456
486
 
457
- # Run the processor in a block. This is intended for use in tests to
458
- # ensure the processor is started and stopped properly.
487
+ # Starts the processor, yields to a block, and then stops the processor.
488
+ # Intended for tests.
459
489
  #
490
+ # @yield The block to run while the processor runs.
491
+ # @return [void]
460
492
  # @api private
461
493
  def run
462
494
  start