patient_http 1.6.0 → 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 +39 -0
  4. data/README.md +540 -514
  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 +57 -26
  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 +20 -4
@@ -1,52 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Case insensitive HTTP headers.
4
+ # HTTP headers with case-insensitive names.
5
5
  #
6
- # This class provides a hash-like interface for HTTP headers with case-insensitive
7
- # key access. Header names are normalized to lowercase for storage and lookup.
6
+ # The interface is like a Hash. Names are stored in lowercase.
7
+ #
8
+ # A header with an empty value isn't stored. If you set a header to `nil` or
9
+ # an empty string, the header is removed. As a result, a request never sends
10
+ # a header with no value.
8
11
  class HttpHeaders
9
12
  include Enumerable
10
13
 
11
- # Initializes a new HttpHeaders instance.
14
+ # Creates a set of headers. Entries with a `nil` or empty value are skipped.
12
15
  #
13
- # @param headers [Hash] initial headers to set
16
+ # @param headers [Hash, HttpHeaders] Initial headers to set.
14
17
  def initialize(headers = {})
15
18
  @headers = {}
16
19
  headers&.each do |key, value|
17
- @headers[key.to_s.downcase] = value
20
+ self[key] = value
18
21
  end
19
22
  end
20
23
 
21
24
  # Retrieves the value for a header (case insensitive).
22
25
  #
23
- # @param key [String, Symbol] header name
24
- # @return [String, nil] header value or nil if not found
26
+ # @param key [String, Symbol] Header name.
27
+ # @return [String, nil] Header value or nil if not found.
25
28
  def [](key)
26
29
  @headers[key.to_s.downcase]
27
30
  end
28
31
 
29
- # Sets the value for a header (case insensitive).
32
+ # Sets the value for a header (case insensitive). Setting a header to `nil`
33
+ # or an empty string removes it.
30
34
  #
31
- # @param key [String, Symbol] header name
32
- # @param value [String] header value
35
+ # @param key [String, Symbol] Header name.
36
+ # @param value [String, nil] Header value.
33
37
  def []=(key, value)
34
- @headers[key.to_s.downcase] = value
38
+ name = key.to_s.downcase
39
+ if empty_value?(value)
40
+ @headers.delete(name)
41
+ else
42
+ @headers[name] = value
43
+ end
44
+ end
45
+
46
+ # Removes a header (case insensitive).
47
+ #
48
+ # @param key [String, Symbol] Header name.
49
+ # @return [String, nil] The removed value or nil if not found.
50
+ def delete(key)
51
+ @headers.delete(key.to_s.downcase)
35
52
  end
36
53
 
37
54
  # Fetches the value for a header with an optional default.
38
55
  #
39
- # @param key [String, Symbol] header name
40
- # @param default [Object] default value if header not found
41
- # @return [String, Object] header value or default
56
+ # @param key [String, Symbol] Header name.
57
+ # @param default [Object] Default value if header not found.
58
+ # @return [String, Object] Header value or default.
42
59
  def fetch(key, default = nil)
43
60
  @headers.fetch(key.to_s.downcase, default)
44
61
  end
45
62
 
46
63
  # Merges another set of headers into a new HttpHeaders instance.
47
64
  #
48
- # @param other_headers [Hash, HttpHeaders] headers to merge
49
- # @return [HttpHeaders] new instance with merged headers
65
+ # @param other_headers [Hash, HttpHeaders] Headers to merge.
66
+ # @return [HttpHeaders] New instance with merged headers.
50
67
  def merge(other_headers)
51
68
  new_headers = dup
52
69
  other_headers.each do |key, value|
@@ -57,8 +74,8 @@ module PatientHttp
57
74
 
58
75
  # Returns a new HttpHeaders without the specified keys (case-insensitive).
59
76
  #
60
- # @param keys [Array<String, Symbol>] header names to exclude
61
- # @return [HttpHeaders] new instance without the specified headers
77
+ # @param keys [Array<String, Symbol>] Header names to exclude.
78
+ # @return [HttpHeaders] New instance without the specified headers.
62
79
  def except(*keys)
63
80
  normalized = keys.map { |k| k.to_s.downcase }
64
81
  filtered_headers = @headers.reject { |key, _value| normalized.include?(key) } # rubocop:disable Style/HashExcept
@@ -67,40 +84,54 @@ module PatientHttp
67
84
 
68
85
  # Converts to a regular hash with lowercase keys.
69
86
  #
70
- # @return [Hash] hash representation
87
+ # @return [Hash] Hash representation.
71
88
  def to_h
72
89
  @headers.dup
73
90
  end
74
91
 
75
92
  # Iterates over each header.
76
93
  #
77
- # @yield [key, value] yields each header key-value pair
78
- # @return [Enumerator] if no block given
94
+ # @yield [key, value] Yields each header key-value pair.
95
+ # @return [Enumerator] If no block given.
79
96
  def each(&block)
80
97
  @headers.each(&block)
81
98
  end
82
99
 
83
- # Checks if a header exists (case insensitive).
100
+ # Returns whether a header exists (case insensitive).
84
101
  #
85
- # @param name [String, Symbol] header name
86
- # @return [Boolean] true if header exists
102
+ # @param name [String, Symbol] Header name.
103
+ # @return [Boolean] `true` if header exists.
87
104
  def include?(name)
88
105
  @headers.include?(name.to_s.downcase)
89
106
  end
90
107
 
91
- # Ensure copies do not share the underlying storage so that mutating a
108
+ # Ensures copies do not share the underlying storage so that mutating a
92
109
  # copy (for example, via #merge) does not modify the original.
93
110
  def initialize_copy(other)
94
111
  super
95
112
  @headers = @headers.dup
96
113
  end
97
114
 
115
+ # Returns whether another object has the same headers.
116
+ #
117
+ # @param other [Object] The object to compare.
118
+ # @return [Boolean] `true` if the headers are equal.
98
119
  def eql?(other)
99
120
  other.is_a?(HttpHeaders) && @headers.eql?(other.to_h)
100
121
  end
101
122
 
123
+ # Returns a hash code based on the headers.
124
+ #
125
+ # @return [Integer] The hash code.
102
126
  def hash
103
127
  @headers.hash
104
128
  end
129
+
130
+ private
131
+
132
+ # A header value is empty when it is nil or a string with no characters.
133
+ def empty_value?(value)
134
+ value.nil? || (value.is_a?(String) && value.empty?)
135
+ end
105
136
  end
106
137
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # Retries a request at once when its failure is known to be safe to retry.
5
+ #
6
+ # Only failures raised before any response byte arrives are considered; a
7
+ # failure while reading the body is never retried. The pooled clients make a
8
+ # single attempt per call, so this module is the only layer that retries and
9
+ # one limit bounds how many times a request is sent.
10
+ #
11
+ # A connection failure evicts the host's pooled client before the retry, so
12
+ # the retry opens a new connection instead of taking another idle connection
13
+ # that may have failed the same way. Each attempt looks up the host's client
14
+ # in the pool, so a retry never runs on a client a concurrent failure evicted.
15
+ #
16
+ # The including class must provide a private `config` method returning the
17
+ # {Configuration}, which supplies the logger.
18
+ #
19
+ # @api private
20
+ module ImmediateRetries
21
+ # Minimum number of attempts made after a failure that is safe to retry. A
22
+ # pool `retries` setting above IMMEDIATE_RETRY_LIMIT + 1 allows
23
+ # `retries - 1` attempts instead.
24
+ IMMEDIATE_RETRY_LIMIT = 2
25
+
26
+ # Errors from a connection that failed before delivering any response byte.
27
+ # They are ambiguous for a non-idempotent request: the server may have
28
+ # processed it and then died, or it may never have received it. IOError
29
+ # covers EOFError and the IO::TimeoutError raised by the connection timeout.
30
+ # EPIPE means a write failed, but the server may already have read enough of
31
+ # the request to act on it before closing. ETIMEDOUT is the kernel giving up
32
+ # on unacknowledged data (see the TCP user timeout), not the request
33
+ # timeout, which is never retried.
34
+ CONNECTION_ERRORS = [
35
+ IOError, SocketError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, Errno::ETIMEDOUT,
36
+ ::Protocol::HTTP::RemoteError
37
+ ].freeze
38
+
39
+ private
40
+
41
+ # Sends the request through the pool, retrying safe failures at once.
42
+ #
43
+ # @param client_pool [ClientPool] The pool to send through.
44
+ # @param request [Request] The request being sent, used for its method and URL.
45
+ # @param endpoint [Async::HTTP::Endpoint] The endpoint parsed from the prepared URL.
46
+ # @param headers [Hash] The prepared request headers.
47
+ # @param body [Protocol::HTTP::Body::Buffered, nil] The request body.
48
+ # @yield [client] Each pooled client before a request is sent through it.
49
+ # @return [Protocol::HTTP::Response] The response with its headers read.
50
+ def request_with_immediate_retries(client_pool, request, endpoint, headers, body)
51
+ limit = [client_pool.retries - 1, IMMEDIATE_RETRY_LIMIT].max
52
+ attempt = 1
53
+
54
+ loop do
55
+ client = client_pool.client_for(endpoint)
56
+ yield client if block_given?
57
+ return client_pool.request(request.http_method, endpoint, headers, body, client: client)
58
+ rescue => e
59
+ unless attempt <= limit && immediately_retryable?(request, e)
60
+ raise
61
+ end
62
+
63
+ if client && connection_failure?(e)
64
+ client_pool.evict(endpoint.url.to_s, client)
65
+ end
66
+
67
+ attempt += 1
68
+ body&.rewind
69
+ # The request URL is logged rather than the prepared URL, which carries
70
+ # resolved secret params.
71
+ config.logger&.info(
72
+ "[PatientHttp] Request to #{request.url} failed before a response " \
73
+ "(#{e.class.name}: #{e.message}); retrying (attempt #{attempt})"
74
+ )
75
+ end
76
+ end
77
+
78
+ # A refused request (an HTTP/2 GOAWAY, a pooled connection closed after it was
79
+ # acquired, or a rejected stream) was never processed by the server, so it is
80
+ # safe to retry whatever the method. A connection that fails in any other way
81
+ # before responding may have processed the request, so that failure is
82
+ # retried only for idempotent methods.
83
+ def immediately_retryable?(request, error)
84
+ case error
85
+ when ::Protocol::HTTP::RefusedError
86
+ true
87
+ when *CONNECTION_ERRORS
88
+ request.idempotent?
89
+ else
90
+ false
91
+ end
92
+ end
93
+
94
+ def connection_failure?(error)
95
+ CONNECTION_ERRORS.any? { |error_class| error.is_a?(error_class) }
96
+ end
97
+ end
98
+ end
@@ -1,28 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # No-op task handler used for inline request execution.
4
+ # The task handler for requests that run inline.
5
5
  #
6
- # The {SynchronousExecutor} invokes the user callback directly, so the
7
- # completion and error hooks here are never exercised in practice; they are
8
- # defined as no-ops to satisfy the {TaskHandler} contract. Inline requests
9
- # have no job queue, so retrying is not supported.
6
+ # {SynchronousExecutor} calls the callback service directly, so
7
+ # {#on_complete} and {#on_error} do nothing. Inline requests have no job
8
+ # queue, so they can't be retried.
10
9
  #
11
10
  # @api private
12
11
  class InlineTaskHandler < TaskHandler
13
- # @param response [Response] the HTTP response object
14
- # @param callback [String] callback class name
12
+ # Does nothing.
13
+ #
14
+ # @param response [Response] The HTTP response.
15
+ # @param callback [String] The callback service class name.
15
16
  # @return [void]
16
17
  def on_complete(response, callback)
17
18
  end
18
19
 
19
- # @param error [Error] the error object
20
- # @param callback [String] callback class name
20
+ # Does nothing.
21
+ #
22
+ # @param error [Error] The error.
23
+ # @param callback [String] The callback service class name.
21
24
  # @return [void]
22
25
  def on_error(error, callback)
23
26
  end
24
27
 
25
- # @raise [NotImplementedError] inline requests cannot be retried
28
+ # Raises an error, because inline requests can't be retried.
29
+ #
30
+ # @raise [NotImplementedError] Always.
26
31
  def retry
27
32
  raise NotImplementedError, "Inline requests cannot be retried"
28
33
  end
@@ -1,20 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Manages the lifecycle state of the Processor.
4
+ # Manages the state of a {Processor}. The state changes are thread-safe.
5
5
  #
6
- # Handles state transitions and provides predicates for checking the current state.
7
- # Thread-safe state management using Concurrent::AtomicReference.
6
+ # @api private
8
7
  class LifecycleManager
9
8
  include TimeHelper
10
9
 
11
- # Valid processor states
10
+ # Valid processor states.
12
11
  STATES = %i[stopped starting running draining stopping].freeze
13
12
 
14
- # Polling interval during wait operations
13
+ # Polling interval during wait operations.
15
14
  POLL_INTERVAL = 0.01
16
15
 
17
- # Initialize the lifecycle manager.
16
+ # Creates the lifecycle manager.
18
17
  #
19
18
  # @return [void]
20
19
  def initialize
@@ -24,53 +23,53 @@ module PatientHttp
24
23
  @lock = Mutex.new
25
24
  end
26
25
 
27
- # Get the current state.
26
+ # Returns the current state.
28
27
  #
29
- # @return [Symbol] the current state
28
+ # @return [Symbol] The current state.
30
29
  def state
31
30
  @state.get
32
31
  end
33
32
 
34
- # Check if processor is starting.
33
+ # Returns whether the processor is starting.
35
34
  #
36
- # @return [Boolean] true if starting
35
+ # @return [Boolean] `true` if starting.
37
36
  def starting?
38
37
  state == :starting
39
38
  end
40
39
 
41
- # Check if processor is running.
40
+ # Returns whether the processor is running.
42
41
  #
43
- # @return [Boolean] true if running
42
+ # @return [Boolean] `true` if running.
44
43
  def running?
45
44
  state == :running
46
45
  end
47
46
 
48
- # Check if processor is stopped.
47
+ # Returns whether the processor is stopped.
49
48
  #
50
- # @return [Boolean] true if stopped
49
+ # @return [Boolean] `true` if stopped.
51
50
  def stopped?
52
51
  state == :stopped
53
52
  end
54
53
 
55
- # Check if processor is draining.
54
+ # Returns whether the processor is draining.
56
55
  #
57
- # @return [Boolean] true if draining
56
+ # @return [Boolean] `true` if draining.
58
57
  def draining?
59
58
  state == :draining
60
59
  end
61
60
 
62
- # Check if processor is stopping.
61
+ # Returns whether the processor is stopping.
63
62
  #
64
- # @return [Boolean] true if stopping
63
+ # @return [Boolean] `true` if stopping.
65
64
  def stopping?
66
65
  state == :stopping
67
66
  end
68
67
 
69
- # Transition to starting state. The processor can only be started from
68
+ # Transitions to starting state. The processor can only be started from
70
69
  # the stopped state; in particular, starting a draining processor would
71
70
  # spawn a second reactor alongside the one still finishing its drain.
72
71
  #
73
- # @return [Boolean] true if transition was successful
72
+ # @return [Boolean] `true` if transition was successful.
74
73
  def start!
75
74
  @lock.synchronize do
76
75
  return false unless stopped?
@@ -83,12 +82,12 @@ module PatientHttp
83
82
  true
84
83
  end
85
84
 
86
- # Transition to running state.
85
+ # Transitions to running state.
87
86
  #
88
87
  # The transition only occurs from the starting state so that a reactor
89
88
  # that already failed and transitioned to stopped is not overwritten.
90
89
  #
91
- # @return [Boolean] true if transition was successful
90
+ # @return [Boolean] `true` if transition was successful.
92
91
  def running!
93
92
  @lock.synchronize do
94
93
  return false unless starting?
@@ -99,9 +98,9 @@ module PatientHttp
99
98
  true
100
99
  end
101
100
 
102
- # Transition to draining state.
101
+ # Transitions to draining state.
103
102
  #
104
- # @return [Boolean] true if transition was successful
103
+ # @return [Boolean] `true` if transition was successful.
105
104
  def drain!
106
105
  @lock.synchronize do
107
106
  return false unless running?
@@ -112,9 +111,9 @@ module PatientHttp
112
111
  true
113
112
  end
114
113
 
115
- # Transition to stopping state.
114
+ # Transitions to stopping state.
116
115
  #
117
- # @return [Boolean] true if transition was successful
116
+ # @return [Boolean] `true` if transition was successful.
118
117
  def stop!
119
118
  @lock.synchronize do
120
119
  return false if stopped? || stopping? || starting?
@@ -126,7 +125,7 @@ module PatientHttp
126
125
  true
127
126
  end
128
127
 
129
- # Transition to stopped state.
128
+ # Transitions to stopped state.
130
129
  #
131
130
  # Also signals the reactor_ready event to unblock any thread
132
131
  # waiting in {#wait_for_reactor} in case the reactor failed
@@ -138,41 +137,41 @@ module PatientHttp
138
137
  @reactor_ready.set
139
138
  end
140
139
 
141
- # Signal that the reactor is ready.
140
+ # Signals that the reactor is ready.
142
141
  #
143
142
  # @return [void]
144
143
  def reactor_ready!
145
144
  @reactor_ready.set
146
145
  end
147
146
 
148
- # Wait for the reactor to be ready.
147
+ # Waits for the reactor to be ready.
149
148
  #
150
- # @param timeout [Numeric, nil] maximum time to wait in seconds (nil waits forever)
151
- # @return [Boolean] true if the reactor is ready, false if the timeout was reached
149
+ # @param timeout [Numeric, nil] Maximum time to wait in seconds (nil waits forever).
150
+ # @return [Boolean] `true` if the reactor is ready, false if the timeout was reached.
152
151
  def wait_for_reactor(timeout: nil)
153
152
  @reactor_ready.wait(timeout)
154
153
  end
155
154
 
156
- # Check if shutdown has been signaled.
155
+ # Returns whether shutdown has been signaled.
157
156
  #
158
- # @return [Boolean] true if shutdown is signaled
157
+ # @return [Boolean] `true` if shutdown is signaled.
159
158
  def shutdown_signaled?
160
159
  @shutdown_barrier.set?
161
160
  end
162
161
 
163
- # Wait for running state.
162
+ # Waits for running state.
164
163
  #
165
- # @param timeout [Numeric] maximum time to wait in seconds
166
- # @return [Boolean] true if running, false if timeout reached
164
+ # @param timeout [Numeric] Maximum time to wait in seconds.
165
+ # @return [Boolean] `true` if running, false if timeout reached.
167
166
  def wait_for_running(timeout: 5)
168
167
  wait_for_condition(timeout: timeout) { running? }
169
168
  end
170
169
 
171
- # Wait for a condition to be met.
170
+ # Waits for a condition to be met.
172
171
  #
173
- # @param timeout [Numeric] maximum time to wait in seconds
174
- # @yield Block that checks the condition.
175
- # @return [Boolean] true if the condition is met, false if timeout reached
172
+ # @param timeout [Numeric] Maximum time to wait in seconds.
173
+ # @yield The block that checks the condition.
174
+ # @return [Boolean] `true` if the condition is met, false if timeout reached.
176
175
  def wait_for_condition(timeout: 1)
177
176
  deadline = monotonic_time + timeout
178
177
  while monotonic_time <= deadline
@@ -1,35 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # A mutable view of a request as it is about to be sent, after secret references
5
- # have been resolved and the send-time headers (x-request-id and the default
6
- # user-agent) have been set.
4
+ # A request immediately before it's sent. Secret references are resolved, and
5
+ # the `x-request-id` and default `user-agent` headers are set.
7
6
  #
8
- # Preprocessors attached to a request receive this object and can modify the
9
- # headers or append query parameters before the request goes out -- for example,
10
- # to sign the request. The HTTP method, URL, and body are read-only; headers can
11
- # be changed in place and query parameters appended with {#add_param}.
7
+ # Preprocessors receive this object. They can change the headers and add
8
+ # query parameters, for example to sign the request. The HTTP method, URL,
9
+ # and body are read-only. Change the headers in place, and add query
10
+ # parameters with {#add_param}.
12
11
  #
13
12
  # @see Configuration#register_preprocessor
14
13
  class OutgoingRequest
15
- # @return [Symbol] HTTP method (:get, :head, :post, :put, :patch, :delete, :query)
14
+ # @return [Symbol] The HTTP method: `:get`, `:head`, `:post`, `:put`,
15
+ # `:patch`, `:delete`, or `:query`.
16
16
  attr_reader :http_method
17
17
 
18
- # @return [String] the request URL with any secret query params already resolved
18
+ # @return [String] The request URL, with the secret query parameters
19
+ # resolved.
19
20
  attr_reader :url
20
21
 
21
- # @return [String, nil] the request body
22
+ # @return [String, nil] The request body.
22
23
  attr_reader :body
23
24
 
24
- # @return [HttpHeaders] mutable, case-insensitive request headers
25
+ # @return [HttpHeaders] The request headers. You can change them. Names are
26
+ # case insensitive.
25
27
  attr_reader :headers
26
28
 
27
- # Initialize a new OutgoingRequest.
29
+ # Creates an outgoing request.
28
30
  #
29
- # @param http_method [Symbol] the HTTP method
30
- # @param url [String] the resolved request URL
31
- # @param headers [HttpHeaders] the resolved request headers
32
- # @param body [String, nil] the request body
31
+ # @param http_method [Symbol] The HTTP method.
32
+ # @param url [String] The request URL, with secrets resolved.
33
+ # @param headers [HttpHeaders] The request headers, with secrets resolved.
34
+ # @param body [String, nil] The request body.
33
35
  def initialize(http_method:, url:, headers:, body:)
34
36
  @http_method = http_method
35
37
  @url = url.to_s
@@ -37,11 +39,11 @@ module PatientHttp
37
39
  @body = body
38
40
  end
39
41
 
40
- # Append a query parameter to the request URL.
42
+ # Adds a query parameter to the request URL.
41
43
  #
42
- # @param name [String, Symbol] the parameter name
43
- # @param value [Object] the parameter value
44
- # @return [String] the updated URL
44
+ # @param name [String, Symbol] The parameter name.
45
+ # @param value [Object] The parameter value.
46
+ # @return [String] The new URL.
45
47
  def add_param(name, value)
46
48
  serialized_param = URI.encode_www_form([[name.to_s, value]])
47
49
  uri = URI(@url)
@@ -49,10 +51,10 @@ module PatientHttp
49
51
  @url = uri.to_s
50
52
  end
51
53
 
52
- # Inspect the outgoing request. Header values, the query string, and the body
53
- # are not shown since they may contain resolved secrets.
54
+ # Returns a description of the request. It doesn't show the header values,
55
+ # the query string, or the body, because they can contain secrets.
54
56
  #
55
- # @return [String]
57
+ # @return [String] The description.
56
58
  def inspect
57
59
  "#<#{self.class.name} #{http_method.to_s.upcase} #{redacted_url} headers=#{headers.to_h.keys.inspect}>"
58
60
  end