patient_http 1.1.2 → 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.
@@ -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)
data/lib/patient_http.rb CHANGED
@@ -52,6 +52,7 @@ module PatientHttp
52
52
  autoload :HttpError, File.join(__dir__, "patient_http/http_error")
53
53
  autoload :HttpHeaders, File.join(__dir__, "patient_http/http_headers")
54
54
  autoload :LifecycleManager, File.join(__dir__, "patient_http/lifecycle_manager")
55
+ autoload :OutgoingRequest, File.join(__dir__, "patient_http/outgoing_request")
55
56
  autoload :Payload, File.join(__dir__, "patient_http/payload")
56
57
  autoload :PayloadStore, File.join(__dir__, "patient_http/payload_store")
57
58
  autoload :Processor, File.join(__dir__, "patient_http/processor")
@@ -62,6 +63,7 @@ module PatientHttp
62
63
  autoload :Request, File.join(__dir__, "patient_http/request")
63
64
  autoload :RequestError, File.join(__dir__, "patient_http/request_error")
64
65
  autoload :RequestHelper, File.join(__dir__, "patient_http/request_helper")
66
+ autoload :RequestPreparer, File.join(__dir__, "patient_http/request_preparer")
65
67
  autoload :RequestTask, File.join(__dir__, "patient_http/request_task")
66
68
  autoload :RequestTemplate, File.join(__dir__, "patient_http/request_template")
67
69
  autoload :Response, File.join(__dir__, "patient_http/response")
@@ -238,6 +240,8 @@ module PatientHttp
238
240
  # @param raise_error_responses [Boolean, nil] when true, non-success responses are
239
241
  # reported as errors
240
242
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
243
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
244
+ # registered on the configuration to apply to the request when it is sent
241
245
  # @return [Object] return value from the registered request handler
242
246
  def request(
243
247
  method,
@@ -249,9 +253,19 @@ module PatientHttp
249
253
  params: nil,
250
254
  timeout: nil,
251
255
  raise_error_responses: nil,
252
- callback_args: nil
256
+ callback_args: nil,
257
+ preprocessors: nil
253
258
  )
254
- request = Request.new(method, url, body: body, json: json, headers: headers, params: params, timeout: timeout)
259
+ request = Request.new(
260
+ method,
261
+ url,
262
+ body: body,
263
+ json: json,
264
+ headers: headers,
265
+ params: params,
266
+ timeout: timeout,
267
+ preprocessors: preprocessors
268
+ )
255
269
  execute(
256
270
  request: request,
257
271
  callback: callback,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_http
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.2
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -110,6 +110,7 @@ files:
110
110
  - lib/patient_http/http_error.rb
111
111
  - lib/patient_http/http_headers.rb
112
112
  - lib/patient_http/lifecycle_manager.rb
113
+ - lib/patient_http/outgoing_request.rb
113
114
  - lib/patient_http/payload.rb
114
115
  - lib/patient_http/payload_store.rb
115
116
  - lib/patient_http/payload_store/active_record_store.rb
@@ -125,6 +126,7 @@ files:
125
126
  - lib/patient_http/request.rb
126
127
  - lib/patient_http/request_error.rb
127
128
  - lib/patient_http/request_helper.rb
129
+ - lib/patient_http/request_preparer.rb
128
130
  - lib/patient_http/request_task.rb
129
131
  - lib/patient_http/request_template.rb
130
132
  - lib/patient_http/response.rb
@@ -156,7 +158,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
158
  - !ruby/object:Gem::Version
157
159
  version: '0'
158
160
  requirements: []
159
- rubygems_version: 3.6.9
161
+ rubygems_version: 4.0.3
160
162
  specification_version: 4
161
163
  summary: Generic async HTTP connection pool for Ruby applications using Fiber-based
162
164
  concurrency