patient_http 1.3.0 → 1.5.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.
@@ -2,9 +2,31 @@
2
2
 
3
3
  module PatientHttp
4
4
  # Interface for observing request processing. A process observer can be registered with
5
- # a Processor and receive events as requests are processed. Observers will run on the main
6
- # processor thread and so should be lightweight and not do processing other than recording
7
- # metrics or similar.
5
+ # a Processor and receive events as requests are processed. Observers should be
6
+ # lightweight and not do processing other than recording metrics or similar.
7
+ #
8
+ # Hooks run on different threads depending on where the event originates:
9
+ # - request_enqueued, request_rejected: the thread calling Processor#enqueue
10
+ # (usually an application thread), and the reactor thread for each task
11
+ # created to follow a redirect. Work done in these hooks blocks the reactor
12
+ # for redirected requests, so keep it off the critical path or accept the
13
+ # delay it adds to every other in-flight request
14
+ # - capacity_exceeded: the thread calling Processor#enqueue (usually an
15
+ # application thread)
16
+ # - request_start: the reactor thread
17
+ # - request_end, request_error, completion_failed: a completion worker
18
+ # thread (request_end also fires on the reactor thread for followed
19
+ # redirects, and on the stopping thread for shutdown re-enqueues)
20
+ # - request_requeued: the stopping thread or the reactor thread
21
+ # - start, stop: the thread calling Processor#start / Processor#stop
22
+ #
23
+ # Observers must be thread-safe. Hooks are called from several threads, and
24
+ # the completion-time hooks run on any of the completion worker threads, so
25
+ # two of them can run at the same time and in an order unrelated to the
26
+ # order the requests completed. Guard any counter or buffer an observer
27
+ # shares between calls. Setting completion_threads to 1 serializes the
28
+ # completion-time hooks but does not serialize them against the hooks that
29
+ # fire on other threads.
8
30
  class ProcessorObserver
9
31
  # Called when the processor starts.
10
32
  #
@@ -24,6 +46,41 @@ module PatientHttp
24
46
  def capacity_exceeded
25
47
  end
26
48
 
49
+ # Called when a request task is handed to the processor, before the task is
50
+ # visible to the reactor. The notification is guaranteed to arrive before
51
+ # request_start for the task, so observers can set up durable tracking
52
+ # (e.g. a crash-recovery registry entry) with no risk that the task
53
+ # completes first. If the processor does not accept the task,
54
+ # request_rejected is sent afterward. Unlike other notifications, an error
55
+ # raised here propagates from Processor#enqueue and rejects the task, so a
56
+ # failed tracking setup does not let the task be accepted as if it were
57
+ # durable.
58
+ #
59
+ # @param request_task [RequestTask] the request task that was enqueued
60
+ # @return [void]
61
+ def request_enqueued(request_task)
62
+ end
63
+
64
+ # Called when a request task announced with request_enqueued was not
65
+ # accepted by the processor (not running or at capacity). Observers should
66
+ # tear down anything they set up in request_enqueued; the caller owns the
67
+ # request again once this is sent.
68
+ #
69
+ # @param request_task [RequestTask] the request task that was rejected
70
+ # @return [void]
71
+ def request_rejected(request_task)
72
+ end
73
+
74
+ # Called when an incomplete request task was re-enqueued through its task
75
+ # handler (processor shutdown or reactor failure). The task handler's job
76
+ # system owns the request again once this is sent, so observers should
77
+ # tear down any durable tracking for the task.
78
+ #
79
+ # @param request_task [RequestTask] the request task that was re-enqueued
80
+ # @return [void]
81
+ def request_requeued(request_task)
82
+ end
83
+
27
84
  # Called when a request starts processing.
28
85
  #
29
86
  # @param request_task [RequestTask] the request task that started
@@ -44,5 +101,16 @@ module PatientHttp
44
101
  # @return [void]
45
102
  def request_error(error)
46
103
  end
104
+
105
+ # Called when a finished result could not be delivered to the task handler
106
+ # after all retries. request_end is NOT sent for the task, so durable
107
+ # tracking set up in request_enqueued stays in place and an external
108
+ # recovery process (e.g. an orphan collector) can re-enqueue the request.
109
+ #
110
+ # @param request_task [RequestTask] the request task whose result was not delivered
111
+ # @param error [StandardError] the delivery failure
112
+ # @return [void]
113
+ def completion_failed(request_task, error)
114
+ end
47
115
  end
48
116
  end
@@ -42,6 +42,11 @@ module PatientHttp
42
42
  # to apply to the request when it is sent
43
43
  attr_reader :preprocessors
44
44
 
45
+ # @return [String, nil] Name of the processor that should execute the request.
46
+ # Integrations use this to route the request to a named processor; nil
47
+ # uses the default processor.
48
+ attr_reader :processor
49
+
45
50
  class << self
46
51
  # Reconstruct a Request from a hash
47
52
  #
@@ -56,7 +61,8 @@ module PatientHttp
56
61
  params: load_secret_params(hash["secret_params"]),
57
62
  timeout: hash["timeout"],
58
63
  max_redirects: hash["max_redirects"],
59
- preprocessors: hash["preprocessors"]
64
+ preprocessors: hash["preprocessors"],
65
+ processor: hash["processor"]
60
66
  )
61
67
  end
62
68
 
@@ -91,6 +97,8 @@ module PatientHttp
91
97
  # @param max_redirects [Integer, nil] Maximum redirects to follow (nil uses config, 0 disables).
92
98
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Names of preprocessors
93
99
  # registered on the configuration to apply to the request when it is sent.
100
+ # @param processor [String, Symbol, nil] Name of the processor that should execute the
101
+ # request. Integrations use this to route the request to a named processor.
94
102
  def initialize(
95
103
  http_method,
96
104
  url,
@@ -100,7 +108,8 @@ module PatientHttp
100
108
  params: nil,
101
109
  timeout: nil,
102
110
  max_redirects: nil,
103
- preprocessors: nil
111
+ preprocessors: nil,
112
+ processor: nil
104
113
  )
105
114
  @http_method = http_method.is_a?(String) ? http_method.downcase.to_sym : http_method
106
115
 
@@ -117,6 +126,7 @@ module PatientHttp
117
126
  @timeout = timeout
118
127
  @max_redirects = max_redirects
119
128
  @preprocessors = normalized_preprocessors(preprocessors)
129
+ @processor = normalized_processor(processor)
120
130
 
121
131
  if json
122
132
  raise ArgumentError.new("Cannot provide both body and json") if @body
@@ -158,6 +168,7 @@ module PatientHttp
158
168
  end
159
169
 
160
170
  hash["preprocessors"] = @preprocessors if @preprocessors.any?
171
+ hash["processor"] = @processor if @processor
161
172
 
162
173
  hash
163
174
  end
@@ -171,6 +182,16 @@ module PatientHttp
171
182
  end
172
183
  end
173
184
 
185
+ # Normalize the processor name to a frozen string or nil.
186
+ def normalized_processor(processor)
187
+ return nil if processor.nil?
188
+
189
+ name = processor.to_s
190
+ raise ArgumentError.new("processor name cannot be empty") if name.empty?
191
+
192
+ name.freeze
193
+ end
194
+
174
195
  # Normalize preprocessor names to a frozen array of strings.
175
196
  def normalized_preprocessors(preprocessors)
176
197
  names = Array(preprocessors).map(&:to_s)
@@ -116,14 +116,16 @@ module PatientHttp
116
116
  # @param timeout [Float] default timeout in seconds
117
117
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] default names of
118
118
  # preprocessors registered on the configuration to apply to requests
119
+ # @param processor [String, Symbol, nil] default processor name for requests
119
120
  # @return [void]
120
- def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
121
+ def request_template(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil, processor: nil)
121
122
  @patient_http_request_template = RequestTemplate.new(
122
123
  base_url: base_url,
123
124
  headers: headers,
124
125
  params: params,
125
126
  timeout: timeout,
126
- preprocessors: preprocessors
127
+ preprocessors: preprocessors,
128
+ processor: processor
127
129
  )
128
130
  end
129
131
 
@@ -145,6 +147,8 @@ module PatientHttp
145
147
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
146
148
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
147
149
  # registered on the configuration to apply to the request when it is sent
150
+ # @param processor [String, Symbol, nil] name of the processor that should execute
151
+ # the request
148
152
  # @return [Object] return value from the registered request handler
149
153
  def async_request(
150
154
  method,
@@ -157,10 +161,11 @@ module PatientHttp
157
161
  timeout: nil,
158
162
  raise_error_responses: nil,
159
163
  callback_args: nil,
160
- preprocessors: nil
164
+ preprocessors: nil,
165
+ processor: nil
161
166
  )
162
167
  template = async_request_template
163
- kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors}
168
+ kwargs = {body: body, json: json, headers: headers, params: params, timeout: timeout, preprocessors: preprocessors, processor: processor}
164
169
  request = if template
165
170
  template.request(method, url, **kwargs)
166
171
  else
@@ -206,6 +211,8 @@ module PatientHttp
206
211
  # @param callback_args [Hash, nil] JSON-compatible callback arguments
207
212
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
208
213
  # registered on the configuration to apply to the request when it is sent
214
+ # @param processor [String, Symbol, nil] name of the processor that should execute
215
+ # the request
209
216
  # @return [Object] return value from the registered request handler
210
217
  def async_request(
211
218
  method,
@@ -218,7 +225,8 @@ module PatientHttp
218
225
  timeout: nil,
219
226
  raise_error_responses: nil,
220
227
  callback_args: nil,
221
- preprocessors: nil
228
+ preprocessors: nil,
229
+ processor: nil
222
230
  )
223
231
  self.class.async_request(
224
232
  method,
@@ -231,7 +239,8 @@ module PatientHttp
231
239
  timeout: timeout,
232
240
  raise_error_responses: raise_error_responses,
233
241
  callback_args: callback_args,
234
- preprocessors: preprocessors
242
+ preprocessors: preprocessors,
243
+ processor: processor
235
244
  )
236
245
  end
237
246
 
@@ -28,6 +28,13 @@ module PatientHttp
28
28
  headers = @config.secret_manager.resolve_headers(request.headers.to_h)
29
29
  headers["x-request-id"] = request_id
30
30
  headers["user-agent"] ||= @config.user_agent if @config.user_agent
31
+ # Compressed responses are inflated by ResponseReader during response
32
+ # decoding rather than by a client middleware wrapper. Requesting gzip is
33
+ # the default because it is what the reader can decode, but a caller that
34
+ # sets the header keeps its own value: "identity" opts out of compression,
35
+ # and any other encoding is delivered still encoded with its
36
+ # content-encoding header intact.
37
+ headers["accept-encoding"] ||= "gzip"
31
38
  url = @config.secret_manager.resolve_url(request.url, request.secret_params)
32
39
 
33
40
  outgoing = OutgoingRequest.new(
@@ -236,7 +236,8 @@ module PatientHttp
236
236
  body: redirect_body,
237
237
  timeout: request.timeout,
238
238
  max_redirects: request.max_redirects,
239
- preprocessors: redirect_preprocessors
239
+ preprocessors: redirect_preprocessors,
240
+ processor: request.processor
240
241
  )
241
242
 
242
243
  redirect_task_id = "#{id.split("/").first}/#{@redirects.size + 2}"
@@ -34,12 +34,14 @@ module PatientHttp
34
34
  # @param timeout [Float] Default request timeout in seconds
35
35
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Default preprocessors
36
36
  # to apply to all requests
37
- def initialize(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil)
37
+ # @param processor [String, Symbol, nil] Default processor name for all requests
38
+ def initialize(base_url: nil, headers: {}, params: nil, timeout: 30, preprocessors: nil, processor: nil)
38
39
  @base_url = base_url
39
40
  @headers = HttpHeaders.new(headers)
40
41
  @params = params
41
42
  @timeout = timeout
42
43
  @preprocessors = preprocessors
44
+ @processor = processor
43
45
  end
44
46
 
45
47
  # Build an async HTTP request. Returns a Request object.
@@ -52,8 +54,10 @@ module PatientHttp
52
54
  # @param params [Hash, nil] query parameters to add to URL
53
55
  # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] preprocessors to apply
54
56
  # to the request (overrides the template default)
57
+ # @param processor [String, Symbol, nil] processor name for the request (overrides the
58
+ # template default)
55
59
  # @return [Request] request object
56
- def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil, preprocessors: nil)
60
+ def request(method, uri, body: nil, json: nil, headers: nil, params: nil, timeout: nil, preprocessors: nil, processor: nil)
57
61
  full_uri = @base_url ? URI.join(@base_url, uri.to_s) : URI(uri)
58
62
 
59
63
  merged_headers = headers&.any? ? @headers.merge(headers) : @headers
@@ -68,7 +72,8 @@ module PatientHttp
68
72
  json: json,
69
73
  params: merged_params,
70
74
  timeout: timeout || @timeout,
71
- preprocessors: preprocessors || @preprocessors
75
+ preprocessors: preprocessors || @preprocessors,
76
+ processor: processor || @processor
72
77
  )
73
78
  end
74
79
 
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Reads and validates HTTP response bodies.
4
+ # Reads and decodes HTTP response bodies.
5
5
  #
6
- # Encapsulates the logic for reading async HTTP responses with size validation
7
- # and building Response objects from the raw response data.
6
+ # Reading happens on the reactor thread and collects the raw (possibly
7
+ # compressed) body chunks with size validation. Decoding joining the
8
+ # chunks, inflating compressed content, and applying the charset — is a
9
+ # separate step so it can run on a completion worker thread instead of
10
+ # blocking the event loop.
8
11
  class ResponseReader
9
12
  # Raised when a body read is aborted because the processor was stopped
10
13
  # past its shutdown deadline. The shutdown sequence re-enqueues the task,
@@ -13,41 +16,204 @@ module PatientHttp
13
16
  # @api private
14
17
  class ReadAbortedError < StandardError; end
15
18
 
19
+ # Content encodings that are inflated during decoding, mapped to the window
20
+ # bits of each wire format the encoding may arrive in. Formats are tried in
21
+ # order until one inflates the body.
22
+ #
23
+ # A "deflate" body should carry a zlib header (RFC 9110 specifies the zlib
24
+ # format), but some servers send a bare deflate stream instead, so the raw
25
+ # format is kept as a fallback.
26
+ INFLATE_WINDOW_BITS = {
27
+ "gzip" => [Zlib::MAX_WBITS | 16].freeze,
28
+ "deflate" => [Zlib::MAX_WBITS, -Zlib::MAX_WBITS].freeze
29
+ }.freeze
30
+
31
+ # Content encoding that means the body was not encoded at all. It needs no
32
+ # work to decode, but it still has to be recognized so it does not stop the
33
+ # decode of the encodings applied before it.
34
+ IDENTITY_ENCODING = "identity"
35
+
36
+ class << self
37
+ # Split the encodings named in the content-encoding header into the ones
38
+ # that stay applied to the body and the ones that can be decoded.
39
+ #
40
+ # A body can carry more than one encoding. They are listed in the order
41
+ # they were applied, so decoding runs from the last name backwards and
42
+ # stops at the first name it does not recognize. Everything before that
43
+ # point stays applied to the body.
44
+ #
45
+ # @param headers_hash [Hash] the response headers
46
+ # @return [Array(Array<String>, Array<String>)] the encodings that remain
47
+ # applied and the encodings that can be decoded, both in applied order
48
+ def split_encodings(headers_hash)
49
+ encodings = content_encodings(headers_hash)
50
+ boundary = encodings.rindex { |name| !decodable?(name) }
51
+ return [[], encodings] if boundary.nil?
52
+
53
+ [encodings[0..boundary], encodings[(boundary + 1)..]]
54
+ end
55
+
56
+ # Parse the content-encoding header into encoding names.
57
+ #
58
+ # @param headers_hash [Hash] the response headers
59
+ # @return [Array<String>] the lowercased encoding names in applied order
60
+ def content_encodings(headers_hash)
61
+ headers_hash["content-encoding"].to_s.split(",").filter_map do |name|
62
+ name = name.strip.downcase
63
+ name unless name.empty?
64
+ end
65
+ end
66
+
67
+ # @param name [String] a lowercased content encoding name
68
+ # @return [Boolean] true if the reader can remove this encoding
69
+ def decodable?(name)
70
+ name == IDENTITY_ENCODING || INFLATE_WINDOW_BITS.key?(name)
71
+ end
72
+
73
+ # Restate the content-encoding header for a decoded body. The header is
74
+ # removed when nothing is left applied, and narrowed to the encodings the
75
+ # reader could not remove otherwise, so the header always describes the
76
+ # body delivered with it.
77
+ #
78
+ # @param headers_hash [Hash] the response headers
79
+ # @return [Hash] the headers with content-encoding updated or removed
80
+ def rewrite_content_encoding(headers_hash)
81
+ return headers_hash unless headers_hash.key?("content-encoding")
82
+
83
+ remaining, _decodable = split_encodings(headers_hash)
84
+
85
+ if remaining.empty?
86
+ headers_hash.except("content-encoding")
87
+ else
88
+ headers_hash.merge("content-encoding" => remaining.join(", "))
89
+ end
90
+ end
91
+ end
92
+
16
93
  # Initialize the reader.
17
94
  #
18
- # @param processor [Processor] the processor object
19
- def initialize(processor)
95
+ # Reading needs a processor so it can abort once the processor is past its
96
+ # shutdown deadline. Decoding needs only the configuration, so a caller
97
+ # that does its own reading can supply the configuration on its own.
98
+ #
99
+ # @param processor [Processor, nil] the processor object
100
+ # @param config [Configuration, nil] the configuration; defaults to the
101
+ # processor's configuration
102
+ def initialize(processor, config: nil)
20
103
  @processor = processor
104
+ @config = config || processor.config
21
105
  end
22
106
 
23
- # Read the response body with size validation.
107
+ # Read the raw response body chunks with size validation.
24
108
  #
25
109
  # Reads the async HTTP response body asynchronously to completion, which allows
26
110
  # the connection to be reused. The async-http client handles connection pooling
27
111
  # and keep-alive internally. Using iteration instead of read() ensures non-blocking
28
- # I/O that yields to the reactor.
112
+ # I/O that yields to the reactor. The chunks are the wire bytes: when the
113
+ # response is compressed, the size check here applies to the compressed
114
+ # bytes and {#decode_body} applies the same limit to the inflated bytes.
29
115
  #
30
116
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
31
117
  # @param headers_hash [Hash] the response headers
32
- # @return [String, nil] the response body or nil if no body present
33
- # @raise [ResponseTooLargeError] if body exceeds max_response_size
118
+ # @return [Array<String>, nil] the raw body chunks or nil if no body present
119
+ # @raise [ResponseTooLargeError] if the body exceeds max_response_size
34
120
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
35
- def read_body(async_response, headers_hash)
121
+ def read_raw_body(async_response, headers_hash)
36
122
  return nil unless async_response.body
37
123
 
38
124
  validate_content_length(headers_hash)
39
- body = read_body_chunks(async_response)
125
+ read_body_chunks(async_response)
126
+ end
127
+
128
+ # Decode raw body chunks into the final body string.
129
+ #
130
+ # Joins the chunks, inflates gzip/deflate content (enforcing
131
+ # max_response_size on the inflated bytes), and applies the charset from
132
+ # the Content-Type header. This is CPU-bound work intended to run on a
133
+ # completion worker thread.
134
+ #
135
+ # An encoding the reader does not support leaves the body encoded, and a
136
+ # body that is still encoded keeps its binary encoding because the charset
137
+ # does not describe it. Use {.split_encodings} to find what stays applied
138
+ # so the content-encoding header delivered with the response describes the
139
+ # body it carries.
140
+ #
141
+ # @param chunks [Array<String>, nil] the raw body chunks
142
+ # @param headers_hash [Hash] the response headers
143
+ # @return [String, nil] the decoded body or nil if there was no body
144
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
145
+ def decode_body(chunks, headers_hash)
146
+ return nil if chunks.nil?
147
+
148
+ remaining, decodable = self.class.split_encodings(headers_hash)
149
+ warn_undecodable(remaining) unless remaining.empty?
150
+
151
+ body = inflate_encodings(chunks, decodable).join
152
+ body.force_encoding(Encoding::ASCII_8BIT)
153
+ # A body that is still encoded is not text yet, so the charset does not
154
+ # describe its bytes. Leave it binary for the caller to decode.
155
+ return body unless remaining.empty?
156
+
40
157
  apply_charset_encoding(body, headers_hash)
41
158
  end
42
159
 
43
160
  private
44
161
 
162
+ # Remove the given encodings from the body, starting with the one applied
163
+ # last. Identity needs no work; every other name here inflates.
164
+ #
165
+ # @param chunks [Array<String>] the encoded body chunks
166
+ # @param encodings [Array<String>] decodable encoding names in applied order
167
+ # @return [Array<String>] the decoded chunks
168
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
169
+ def inflate_encodings(chunks, encodings)
170
+ encodings.reverse_each do |name|
171
+ chunks = [inflate_encoding(chunks, name)] if INFLATE_WINDOW_BITS.key?(name)
172
+ end
173
+
174
+ chunks
175
+ end
176
+
177
+ # Inflate one encoding, trying each wire format the encoding can use. The
178
+ # chunks are all in memory, so a format that turns out to be wrong can be
179
+ # abandoned and the next one started from the beginning of the body.
180
+ #
181
+ # @param chunks [Array<String>] the encoded body chunks
182
+ # @param name [String] a lowercased content encoding name
183
+ # @return [String] the inflated body
184
+ # @raise [Zlib::Error] if no format could inflate the body
185
+ # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
186
+ def inflate_encoding(chunks, name)
187
+ formats = INFLATE_WINDOW_BITS.fetch(name)
188
+ last_index = formats.size - 1
189
+
190
+ formats.each_with_index do |window_bits, index|
191
+ return inflate_chunks(chunks, window_bits)
192
+ rescue Zlib::DataError, Zlib::BufError
193
+ raise if index == last_index
194
+ end
195
+ end
196
+
197
+ # Report an encoding that could not be removed. The body is still delivered
198
+ # with its content-encoding header, so the caller can decode it, but the
199
+ # server ignored the accept-encoding header and that is worth recording.
200
+ #
201
+ # @param remaining [Array<String>] the encodings left on the body
202
+ # @return [void]
203
+ def warn_undecodable(remaining)
204
+ logger&.warn(
205
+ "[PatientHttp] Cannot decode response body with content-encoding " \
206
+ "'#{remaining.join(", ")}'; returning the encoded body"
207
+ )
208
+ nil
209
+ end
210
+
45
211
  def max_response_size
46
- @processor.config.max_response_size
212
+ @config.max_response_size
47
213
  end
48
214
 
49
215
  def logger
50
- @processor.config.logger
216
+ @config.logger
51
217
  end
52
218
 
53
219
  # Validate content-length header doesn't exceed max size.
@@ -66,7 +232,7 @@ module PatientHttp
66
232
  # Read body chunks while checking size.
67
233
  #
68
234
  # @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
69
- # @return [String] the response body in ASCII-8BIT encoding
235
+ # @return [Array<String>] the raw body chunks
70
236
  # @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
71
237
  # @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
72
238
  def read_body_chunks(async_response)
@@ -80,7 +246,7 @@ module PatientHttp
80
246
  # Reads are allowed to finish while the processor is merely stopping
81
247
  # (the graceful shutdown window) so in-flight responses can still be
82
248
  # delivered.
83
- if @processor.stopped?
249
+ if @processor&.stopped?
84
250
  raise ReadAbortedError.new("Processor stopped while reading response body")
85
251
  end
86
252
 
@@ -97,8 +263,7 @@ module PatientHttp
97
263
 
98
264
  finished = true
99
265
 
100
- # Join chunks and force to binary encoding to preserve raw bytes
101
- chunks.join.force_encoding(Encoding::ASCII_8BIT)
266
+ chunks
102
267
  ensure
103
268
  # Always close the body if we were interrupted or if an error occurred
104
269
  # This ensures the connection is properly released back to the pool
@@ -106,6 +271,49 @@ module PatientHttp
106
271
  end
107
272
  end
108
273
 
274
+ # Inflate compressed body chunks with streaming size enforcement, so a
275
+ # small compressed body cannot expand past max_response_size.
276
+ #
277
+ # @param chunks [Array<String>] the raw compressed chunks
278
+ # @param window_bits [Integer] Zlib window bits for the content encoding
279
+ # @return [String] the inflated body
280
+ # @raise [ResponseTooLargeError] if the inflated size exceeds max_response_size
281
+ def inflate_chunks(chunks, window_bits)
282
+ # A response can declare a content encoding and still carry no body.
283
+ # There is nothing to inflate, and finishing an empty stream would
284
+ # raise a buffer error.
285
+ return +"" if chunks.all?(&:empty?)
286
+
287
+ inflater = Zlib::Inflate.new(window_bits)
288
+ body = +""
289
+
290
+ begin
291
+ # The block form yields the inflated output in buffer-sized pieces, so
292
+ # the size is checked before the whole expansion is materialized. A
293
+ # single small compressed chunk can otherwise inflate to gigabytes
294
+ # before any check runs.
295
+ appender = ->(output) do
296
+ body << output
297
+ validate_inflated_size(body)
298
+ end
299
+
300
+ chunks.each { |chunk| inflater.inflate(chunk, &appender) }
301
+ inflater.finish(&appender) unless inflater.finished?
302
+ ensure
303
+ inflater.close
304
+ end
305
+
306
+ body
307
+ end
308
+
309
+ def validate_inflated_size(body)
310
+ if body.bytesize > max_response_size
311
+ raise ResponseTooLargeError.new(
312
+ "Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
313
+ )
314
+ end
315
+ end
316
+
109
317
  # Extract charset from Content-Type header.
110
318
  #
111
319
  # @param headers_hash [Hash] the response headers