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.
@@ -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::ECONNABORTED | Errno::EHOSTUNREACH | Errno::EPIPE | SocketError | IOError
83
+ in Errno::ECONNREFUSED | Errno::ECONNRESET | Errno::ECONNABORTED | Errno::EHOSTUNREACH | Errno::ETIMEDOUT | Errno::EPIPE | SocketError | IOError
84
84
  :connection
85
85
  else
86
86
  if exception.is_a?(PatientHttp::ResponseTooLargeError)
@@ -114,13 +114,16 @@ module PatientHttp
114
114
  # @param headers [Hash] default headers for requests
115
115
  # @param params [Hash, nil] default query parameters for requests
116
116
  # @param timeout [Float] default timeout in seconds
117
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] default names of
118
+ # preprocessors registered on the configuration to apply to requests
117
119
  # @return [void]
118
- def request_template(base_url: nil, headers: {}, params: nil, timeout: 30)
120
+ def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
119
121
  @patient_http_request_template = RequestTemplate.new(
120
122
  base_url: base_url,
121
123
  headers: headers,
122
124
  params: params,
123
- timeout: timeout
125
+ timeout: timeout,
126
+ preprocessors: preprocessors
124
127
  )
125
128
  end
126
129
 
@@ -140,6 +143,8 @@ module PatientHttp
140
143
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
141
144
  # reported as errors
142
145
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
146
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
147
+ # registered on the configuration to apply to the request when it is sent
143
148
  # @return [Object] return value from the registered request handler
144
149
  def async_request(
145
150
  method,
@@ -151,10 +156,11 @@ module PatientHttp
151
156
  params: nil,
152
157
  timeout: nil,
153
158
  raise_error_responses: nil,
154
- callback_args: nil
159
+ callback_args: nil,
160
+ preprocessors: nil
155
161
  )
156
162
  template = async_request_template
157
- kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout}
163
+ kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors}
158
164
  request = if template
159
165
  template.request(method, url, **kwargs)
160
166
  else
@@ -198,6 +204,8 @@ module PatientHttp
198
204
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
199
205
  # reported as errors
200
206
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
207
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
208
+ # registered on the configuration to apply to the request when it is sent
201
209
  # @return [Object] return value from the registered request handler
202
210
  def async_request(
203
211
  method,
@@ -209,7 +217,8 @@ module PatientHttp
209
217
  params: nil,
210
218
  timeout: nil,
211
219
  raise_error_responses: nil,
212
- callback_args: nil
220
+ callback_args: nil,
221
+ preprocessors: nil
213
222
  )
214
223
  self.class.async_request(
215
224
  method,
@@ -221,7 +230,8 @@ module PatientHttp
221
230
  params: params,
222
231
  timeout: timeout,
223
232
  raise_error_responses: raise_error_responses,
224
- callback_args: callback_args
233
+ callback_args: callback_args,
234
+ preprocessors: preprocessors
225
235
  )
226
236
  end
227
237
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # Prepares a {Request} to be sent: resolves any secret references, sets the
5
+ # send-time headers (x-request-id and the default user-agent), and invokes any
6
+ # preprocessors attached to the request.
7
+ class RequestPreparer
8
+ # Raised when a request references a preprocessor name that is not registered.
9
+ class PreprocessorNotFoundError < StandardError; end
10
+
11
+ # @param config [Configuration] the configuration holding secrets and preprocessors
12
+ def initialize(config)
13
+ @config = config
14
+ end
15
+
16
+ # Prepare a request for sending.
17
+ #
18
+ # Secret references in the headers and query params are resolved first, then the
19
+ # x-request-id and default user-agent headers are set, and finally each
20
+ # preprocessor attached to the request is invoked in order with the outgoing
21
+ # request. Each preprocessor sees any changes made by the ones before it.
22
+ #
23
+ # @param request [Request] the request to prepare
24
+ # @param request_id [String] unique request identifier set as the x-request-id header
25
+ # @return [OutgoingRequest] the outgoing request with the final URL and headers
26
+ # @raise [PreprocessorNotFoundError] if the request references an unregistered preprocessor
27
+ def prepare(request, request_id)
28
+ headers = @config.secret_manager.resolve_headers(request.headers.to_h)
29
+ headers["x-request-id"] = request_id
30
+ headers["user-agent"] ||= @config.user_agent if @config.user_agent
31
+ url = @config.secret_manager.resolve_url(request.url, request.secret_params)
32
+
33
+ outgoing = OutgoingRequest.new(
34
+ http_method: request.http_method,
35
+ url: url,
36
+ headers: HttpHeaders.new(headers),
37
+ body: request.body
38
+ )
39
+
40
+ request.preprocessors.each do |name|
41
+ preprocessor = @config.preprocessor(name)
42
+ unless preprocessor
43
+ raise PreprocessorNotFoundError.new("No preprocessor registered for #{name.inspect}")
44
+ end
45
+
46
+ preprocessor.call(outgoing)
47
+ end
48
+
49
+ outgoing
50
+ end
51
+ end
52
+ end
@@ -92,6 +92,15 @@ module PatientHttp
92
92
  @started_at = monotonic_time
93
93
  end
94
94
 
95
+ # Return true if the task has started processing (i.e. {#started!} was
96
+ # called). Used to keep observer request_start/request_end notifications
97
+ # balanced when a task is re-enqueued during shutdown.
98
+ #
99
+ # @return [Boolean]
100
+ def started?
101
+ !@started_at.nil?
102
+ end
103
+
95
104
  # Returns the wall clock time when the task was enqueued.
96
105
  #
97
106
  # @return [Time, nil] The enqueued time or nil if not enqueued.
@@ -213,12 +222,11 @@ module PatientHttp
213
222
  # Resolve the redirect URL (handle relative URLs)
214
223
  redirect_url = resolve_redirect_url(location)
215
224
 
216
- # Strip sensitive headers on cross-origin redirects to prevent credential leakage
217
- redirect_headers = if cross_origin?(request.url, redirect_url)
218
- request.headers.except(*SENSITIVE_HEADERS)
219
- else
220
- request.headers
221
- end
225
+ # Strip sensitive headers and preprocessors on cross-origin redirects to
226
+ # prevent credential leakage
227
+ cross_origin = cross_origin?(request.url, redirect_url)
228
+ redirect_headers = cross_origin ? request.headers.except(*SENSITIVE_HEADERS) : request.headers
229
+ redirect_preprocessors = cross_origin ? [] : request.preprocessors
222
230
 
223
231
  # Create a new request for the redirect
224
232
  redirect_request = Request.new(
@@ -227,7 +235,8 @@ module PatientHttp
227
235
  headers: redirect_headers,
228
236
  body: redirect_body,
229
237
  timeout: request.timeout,
230
- max_redirects: request.max_redirects
238
+ max_redirects: request.max_redirects,
239
+ preprocessors: redirect_preprocessors
231
240
  )
232
241
 
233
242
  redirect_task_id = "#{id.split("/").first}/#{@redirects.size + 2}"
@@ -32,11 +32,14 @@ module PatientHttp
32
32
  # @param headers [Hash] Default headers for all requests
33
33
  # @param params [Hash, nil] Default query parameters to add to all requests
34
34
  # @param timeout [Float] Default request timeout in seconds
35
- def initialize(base_url: nil, headers: {}, params: nil, timeout: 30)
35
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Default preprocessors
36
+ # to apply to all requests
37
+ def initialize(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
36
38
  @base_url = base_url
37
39
  @headers = HttpHeaders.new(headers)
38
40
  @params = params
39
41
  @timeout = timeout
42
+ @preprocessors = preprocessors
40
43
  end
41
44
 
42
45
  # Build an async HTTP request. Returns a Request object.
@@ -47,8 +50,10 @@ module PatientHttp
47
50
  # @param json [Object, nil] JSON object to serialize (cannot use with body)
48
51
  # @param headers [Hash] additional headers to merge with client headers
49
52
  # @param params [Hash, nil] query parameters to add to URL
53
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] preprocessors to apply
54
+ # to the request (overrides the template default)
50
55
  # @return [Request] request object
51
- def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil)
56
+ def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil, preprocessors: nil)
52
57
  full_uri = @base_url ? URI.join(@base_url, uri.to_s) : URI(uri)
53
58
 
54
59
  merged_headers = headers&.any? ? @headers.merge(headers) : @headers
@@ -62,7 +67,8 @@ module PatientHttp
62
67
  body: body,
63
68
  json: json,
64
69
  params: merged_params,
65
- timeout: timeout || @timeout
70
+ timeout: timeout || @timeout,
71
+ preprocessors: preprocessors || @preprocessors
66
72
  )
67
73
  end
68
74
 
@@ -12,6 +12,9 @@ module PatientHttp
12
12
  # @return [Integer] HTTP status code
13
13
  attr_reader :status
14
14
 
15
+ # Response headers. Headers that appeared multiple times in the response
16
+ # (such as set-cookie) are flattened into a single joined string value.
17
+ #
15
18
  # @return [HttpHeaders] response headers
16
19
  attr_reader :headers
17
20
 
@@ -6,6 +6,13 @@ module PatientHttp
6
6
  # Encapsulates the logic for reading async HTTP responses with size validation
7
7
  # and building Response objects from the raw response data.
8
8
  class ResponseReader
9
+ # Raised when a body read is aborted because the processor was stopped
10
+ # past its shutdown deadline. The shutdown sequence re-enqueues the task,
11
+ # so this error is handled internally and never reaches callbacks.
12
+ #
13
+ # @api private
14
+ class ReadAbortedError < StandardError; end
15
+
9
16
  # Initialize the reader.
10
17
  #
11
18
  # @param processor [Processor] the processor object
@@ -24,6 +31,7 @@ module PatientHttp
24
31
  # @param headers_hash [Hash] the response headers
25
32
  # @return [String, nil] the response body or nil if no body present
26
33
  # @raise [ResponseTooLargeError] if body exceeds max_response_size
34
+ # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
27
35
  def read_body(async_response, headers_hash)
28
36
  return nil unless async_response.body
29
37
 
@@ -58,8 +66,9 @@ module PatientHttp
58
66
  # Read body chunks while checking size.
59
67
  #
60
68
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
61
- # @return [String, nil] the response body in ASCII-8BIT encoding, or nil if interrupted
69
+ # @return [String] the response body in ASCII-8BIT encoding
62
70
  # @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
71
+ # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
63
72
  def read_body_chunks(async_response)
64
73
  chunks = []
65
74
  total_size = 0
@@ -67,9 +76,12 @@ module PatientHttp
67
76
 
68
77
  begin
69
78
  async_response.body.each do |chunk|
70
- # Check if processor is stopping/stopped (early exit during shutdown)
71
- if @processor.stopping? || @processor.stopped?
72
- return nil
79
+ # Abort the read once the processor has passed its shutdown deadline.
80
+ # Reads are allowed to finish while the processor is merely stopping
81
+ # (the graceful shutdown window) so in-flight responses can still be
82
+ # delivered.
83
+ if @processor.stopped?
84
+ raise ReadAbortedError.new("Processor stopped while reading response body")
73
85
  end
74
86
 
75
87
  total_size += chunk.bytesize
@@ -19,6 +19,7 @@ module PatientHttp
19
19
  @on_complete = on_complete
20
20
  @on_error = on_error
21
21
  @proxy_client = nil
22
+ @request_preparer = RequestPreparer.new(config)
22
23
  end
23
24
 
24
25
  # Execute the request synchronously.
@@ -30,21 +31,21 @@ module PatientHttp
30
31
  begin
31
32
  http_client = nil
32
33
  response_data = nil
34
+ redirect_error = nil
33
35
 
34
36
  loop do
35
37
  http_client&.close
36
38
  @proxy_client&.close
37
39
  @proxy_client = nil
38
- http_client = create_http_client
40
+ outgoing = @request_preparer.prepare(@task.request, @task.id)
41
+ http_client = create_http_client(outgoing.url)
39
42
  timeout = @task.request.timeout || @config.request_timeout
40
43
 
41
44
  response_data = Async::Task.current.with_timeout(timeout) do
42
- headers = @config.secret_manager.resolve_headers(@task.request.headers.to_h)
43
- headers["x-request-id"] = @task.id
44
- headers["user-agent"] ||= @config.user_agent if @config.user_agent
45
+ headers = outgoing.headers.to_h
45
46
  body = Protocol::HTTP::Body::Buffered.wrap([@task.request.body.to_s]) if @task.request.body
46
47
 
47
- endpoint = Async::HTTP::Endpoint.parse(request_url)
48
+ endpoint = Async::HTTP::Endpoint.parse(outgoing.url)
48
49
  endpoint = configure_endpoint(endpoint) if @config.connection_timeout
49
50
 
50
51
  verb = @task.request.http_method.to_s.upcase
@@ -57,6 +58,8 @@ module PatientHttp
57
58
 
58
59
  request = Protocol::HTTP::Request[verb, endpoint.path, **options]
59
60
  async_response = http_client.call(request)
61
+ # Note: headers that appear multiple times (e.g. set-cookie) are
62
+ # flattened to a single joined string value.
60
63
  headers_hash = async_response.headers.to_h.transform_values(&:to_s)
61
64
 
62
65
  body_content = read_response_body(async_response, headers_hash)
@@ -71,36 +74,39 @@ module PatientHttp
71
74
  # Check for redirect
72
75
  break unless should_follow_redirect?(@task, response_data)
73
76
 
77
+ # Note: a `return` here would raise LocalJumpError since this block
78
+ # runs on a reactor fiber, so break out and handle the error below.
74
79
  redirect_error = check_redirect_error(@task, response_data)
75
- if redirect_error
76
- invoke_callback(redirect_error, :error)
77
- return
78
- end
80
+ break if redirect_error
79
81
 
80
82
  location = response_data[:headers]["location"]
81
83
  @task = @task.redirect_task(location: location, status: response_data[:status])
82
84
  end
83
85
 
84
- end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
- duration = end_time - start_time
86
-
87
- response = Response.new(
88
- status: response_data[:status],
89
- headers: response_data[:headers],
90
- body: response_data[:body],
91
- duration: duration,
92
- request_id: @task.id,
93
- url: @task.request.url,
94
- http_method: @task.request.http_method,
95
- callback_args: @task.callback_args,
96
- redirects: @task.redirects
97
- )
98
-
99
- if @task.raise_error_responses && !response.success?
100
- http_error = HttpError.new(response)
101
- invoke_callback(http_error, :error)
86
+ if redirect_error
87
+ invoke_callback(redirect_error, :error)
102
88
  else
103
- invoke_callback(response, :response)
89
+ end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ duration = end_time - start_time
91
+
92
+ response = Response.new(
93
+ status: response_data[:status],
94
+ headers: response_data[:headers],
95
+ body: response_data[:body],
96
+ duration: duration,
97
+ request_id: @task.original_id,
98
+ url: @task.request.url,
99
+ http_method: @task.request.http_method,
100
+ callback_args: @task.callback_args,
101
+ redirects: @task.redirects
102
+ )
103
+
104
+ if @task.raise_error_responses && !response.success?
105
+ http_error = HttpError.new(response)
106
+ invoke_callback(http_error, :error)
107
+ else
108
+ invoke_callback(response, :response)
109
+ end
104
110
  end
105
111
  rescue => e
106
112
  end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -125,18 +131,12 @@ module PatientHttp
125
131
 
126
132
  private
127
133
 
128
- # Resolve the current task's request URL, appending any secret query params.
129
- #
130
- # @return [String] the resolved request URL
131
- def request_url
132
- @config.secret_manager.resolve_url(@task.request.url, @task.request.secret_params)
133
- end
134
-
135
134
  # Create HTTP client with config settings (retries, proxy, connection timeout).
136
135
  #
136
+ # @param url [String] the resolved request URL
137
137
  # @return [Protocol::HTTP::AcceptEncoding] wrapped HTTP client
138
- def create_http_client
139
- endpoint = Async::HTTP::Endpoint.parse(request_url)
138
+ def create_http_client(url)
139
+ endpoint = Async::HTTP::Endpoint.parse(url)
140
140
  endpoint = configure_endpoint(endpoint) if @config.connection_timeout
141
141
 
142
142
  client = if @config.proxy_url
@@ -191,15 +191,23 @@ module PatientHttp
191
191
 
192
192
  chunks = []
193
193
  total_size = 0
194
-
195
- async_response.body.each do |chunk|
196
- total_size += chunk.bytesize
197
- if total_size > @config.max_response_size
198
- raise ResponseTooLargeError.new(
199
- "Response body size exceeded maximum allowed size (#{@config.max_response_size} bytes)"
200
- )
194
+ finished = false
195
+
196
+ begin
197
+ async_response.body.each do |chunk|
198
+ total_size += chunk.bytesize
199
+ if total_size > @config.max_response_size
200
+ raise ResponseTooLargeError.new(
201
+ "Response body size exceeded maximum allowed size (#{@config.max_response_size} bytes)"
202
+ )
203
+ end
204
+ chunks << chunk
201
205
  end
202
- chunks << chunk
206
+
207
+ finished = true
208
+ ensure
209
+ # Close the body if the read was interrupted so the connection is released
210
+ async_response.body.close unless finished
203
211
  end
204
212
 
205
213
  body = chunks.join.force_encoding(Encoding::ASCII_8BIT)