patient_http 1.4.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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +19 -7
- data/CHANGELOG.md +27 -0
- data/README.md +13 -4
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +27 -2
- data/lib/patient_http/client_pool.rb +14 -7
- data/lib/patient_http/completion_executor.rb +139 -0
- data/lib/patient_http/configuration.rb +46 -1
- data/lib/patient_http/payload.rb +37 -15
- data/lib/patient_http/processor.rb +284 -94
- data/lib/patient_http/processor_observer.rb +36 -3
- data/lib/patient_http/request.rb +23 -2
- data/lib/patient_http/request_helper.rb +15 -6
- data/lib/patient_http/request_preparer.rb +7 -0
- data/lib/patient_http/request_task.rb +2 -1
- data/lib/patient_http/request_template.rb +8 -3
- data/lib/patient_http/response_reader.rb +225 -17
- data/lib/patient_http/synchronous_executor.rb +16 -33
- data/lib/patient_http.rb +13 -2
- metadata +2 -1
|
@@ -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
|
-
|
|
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
|
|
4
|
+
# Reads and decodes HTTP response bodies.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
19
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
@
|
|
212
|
+
@config.max_response_size
|
|
47
213
|
end
|
|
48
214
|
|
|
49
215
|
def logger
|
|
50
|
-
@
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -20,6 +20,7 @@ module PatientHttp
|
|
|
20
20
|
@on_error = on_error
|
|
21
21
|
@proxy_client = nil
|
|
22
22
|
@request_preparer = RequestPreparer.new(config)
|
|
23
|
+
@response_reader = ResponseReader.new(nil, config: config)
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
# Execute the request synchronously.
|
|
@@ -62,11 +63,12 @@ module PatientHttp
|
|
|
62
63
|
# flattened to a single joined string value.
|
|
63
64
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
chunks = read_response_body(async_response, headers_hash)
|
|
67
|
+
body_content = @response_reader.decode_body(chunks, headers_hash)
|
|
66
68
|
|
|
67
69
|
{
|
|
68
70
|
status: async_response.status,
|
|
69
|
-
headers: headers_hash,
|
|
71
|
+
headers: ResponseReader.rewrite_content_encoding(headers_hash),
|
|
70
72
|
body: body_content
|
|
71
73
|
}
|
|
72
74
|
end
|
|
@@ -133,19 +135,22 @@ module PatientHttp
|
|
|
133
135
|
|
|
134
136
|
# Create HTTP client with config settings (retries, proxy, connection timeout).
|
|
135
137
|
#
|
|
138
|
+
# The client is not wrapped in a Protocol::HTTP::AcceptEncoding middleware.
|
|
139
|
+
# That wrapper overwrites the request's accept-encoding header, which would
|
|
140
|
+
# ignore a caller opting out of compression, so response bodies are decoded
|
|
141
|
+
# by ResponseReader here exactly as they are on the async path.
|
|
142
|
+
#
|
|
136
143
|
# @param url [String] the resolved request URL
|
|
137
|
-
# @return [
|
|
144
|
+
# @return [Async::HTTP::Client] the HTTP client
|
|
138
145
|
def create_http_client(url)
|
|
139
146
|
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
140
147
|
endpoint = configure_endpoint(endpoint) if @config.connection_timeout
|
|
141
148
|
|
|
142
|
-
|
|
149
|
+
if @config.proxy_url
|
|
143
150
|
create_proxied_client(endpoint)
|
|
144
151
|
else
|
|
145
152
|
Async::HTTP::Client.new(endpoint, retries: @config.retries)
|
|
146
153
|
end
|
|
147
|
-
|
|
148
|
-
Protocol::HTTP::AcceptEncoding.new(client)
|
|
149
154
|
end
|
|
150
155
|
|
|
151
156
|
# Create a proxied HTTP client.
|
|
@@ -174,11 +179,13 @@ module PatientHttp
|
|
|
174
179
|
)
|
|
175
180
|
end
|
|
176
181
|
|
|
177
|
-
# Read the response body with size validation.
|
|
182
|
+
# Read the raw response body chunks with size validation. The chunks are
|
|
183
|
+
# the wire bytes; ResponseReader#decode_body inflates and applies the
|
|
184
|
+
# charset, enforcing the same limit on the inflated bytes.
|
|
178
185
|
#
|
|
179
186
|
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
180
187
|
# @param headers_hash [Hash] the response headers
|
|
181
|
-
# @return [String
|
|
188
|
+
# @return [Array<String>, nil] the raw body chunks or nil if no body present
|
|
182
189
|
def read_response_body(async_response, headers_hash)
|
|
183
190
|
return nil unless async_response.body
|
|
184
191
|
|
|
@@ -210,31 +217,7 @@ module PatientHttp
|
|
|
210
217
|
async_response.body.close unless finished
|
|
211
218
|
end
|
|
212
219
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
charset = extract_charset(headers_hash)
|
|
216
|
-
if charset
|
|
217
|
-
begin
|
|
218
|
-
encoding = Encoding.find(charset)
|
|
219
|
-
body.force_encoding(encoding)
|
|
220
|
-
rescue ArgumentError
|
|
221
|
-
# Invalid charset, keep binary
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
body
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# Extract charset from Content-Type header.
|
|
229
|
-
def extract_charset(headers_hash)
|
|
230
|
-
content_type = headers_hash["content-type"]
|
|
231
|
-
return nil unless content_type
|
|
232
|
-
|
|
233
|
-
match = content_type.match(/;\s*charset\s*=\s*([^;\s]+)/i)
|
|
234
|
-
return nil unless match
|
|
235
|
-
|
|
236
|
-
charset = match[1].strip
|
|
237
|
-
charset.gsub(/\A["']|["']\z/, "")
|
|
220
|
+
chunks
|
|
238
221
|
end
|
|
239
222
|
|
|
240
223
|
# Invoke callback synchronously.
|
data/lib/patient_http.rb
CHANGED
|
@@ -30,6 +30,12 @@ module PatientHttp
|
|
|
30
30
|
|
|
31
31
|
class ResponseTooLargeError < StandardError; end
|
|
32
32
|
|
|
33
|
+
# Raised when a request names a processor that is not configured. Handlers
|
|
34
|
+
# that support named processors raise this at enqueue time; the executing
|
|
35
|
+
# side raises it for a job that names an unconfigured processor so the job
|
|
36
|
+
# lands in the job system's retry mechanism instead of being dropped.
|
|
37
|
+
class UnknownProcessorError < StandardError; end
|
|
38
|
+
|
|
33
39
|
# HTTP redirect status codes that should be followed
|
|
34
40
|
FOLLOWABLE_REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
35
41
|
|
|
@@ -45,6 +51,7 @@ module PatientHttp
|
|
|
45
51
|
autoload :Client, File.join(__dir__, "patient_http/client")
|
|
46
52
|
autoload :ClientError, File.join(__dir__, "patient_http/http_error")
|
|
47
53
|
autoload :ClientPool, File.join(__dir__, "patient_http/client_pool")
|
|
54
|
+
autoload :CompletionExecutor, File.join(__dir__, "patient_http/completion_executor")
|
|
48
55
|
autoload :Configuration, File.join(__dir__, "patient_http/configuration")
|
|
49
56
|
autoload :Encryptor, File.join(__dir__, "patient_http/encryptor")
|
|
50
57
|
autoload :Error, File.join(__dir__, "patient_http/error")
|
|
@@ -323,6 +330,8 @@ module PatientHttp
|
|
|
323
330
|
# @param callback_args [Hash, nil] JSON-compatible callback arguments
|
|
324
331
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
|
|
325
332
|
# registered on the configuration to apply to the request when it is sent
|
|
333
|
+
# @param processor [String, Symbol, nil] name of the processor that should execute
|
|
334
|
+
# the request; handlers that support named processors route on this value
|
|
326
335
|
# @return [Object] return value from the registered request handler
|
|
327
336
|
def request(
|
|
328
337
|
method,
|
|
@@ -335,7 +344,8 @@ module PatientHttp
|
|
|
335
344
|
timeout: nil,
|
|
336
345
|
raise_error_responses: nil,
|
|
337
346
|
callback_args: nil,
|
|
338
|
-
preprocessors: nil
|
|
347
|
+
preprocessors: nil,
|
|
348
|
+
processor: nil
|
|
339
349
|
)
|
|
340
350
|
request = Request.new(
|
|
341
351
|
method,
|
|
@@ -345,7 +355,8 @@ module PatientHttp
|
|
|
345
355
|
headers: headers,
|
|
346
356
|
params: params,
|
|
347
357
|
timeout: timeout,
|
|
348
|
-
preprocessors: preprocessors
|
|
358
|
+
preprocessors: preprocessors,
|
|
359
|
+
processor: processor
|
|
349
360
|
)
|
|
350
361
|
execute(
|
|
351
362
|
request: request,
|
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.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brian Durand
|
|
@@ -89,6 +89,7 @@ files:
|
|
|
89
89
|
- lib/patient_http/class_helper.rb
|
|
90
90
|
- lib/patient_http/client.rb
|
|
91
91
|
- lib/patient_http/client_pool.rb
|
|
92
|
+
- lib/patient_http/completion_executor.rb
|
|
92
93
|
- lib/patient_http/configuration.rb
|
|
93
94
|
- lib/patient_http/encryptor.rb
|
|
94
95
|
- lib/patient_http/error.rb
|