patient_http 1.4.0 → 1.6.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 +43 -0
- data/README.md +68 -7
- 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 +79 -1
- data/lib/patient_http/outgoing_request.rb +1 -1
- data/lib/patient_http/payload.rb +37 -15
- data/lib/patient_http/processor.rb +285 -95
- data/lib/patient_http/processor_observer.rb +36 -3
- data/lib/patient_http/redirect_helper.rb +84 -1
- data/lib/patient_http/request.rb +64 -6
- data/lib/patient_http/request_helper.rb +63 -10
- data/lib/patient_http/request_preparer.rb +7 -0
- data/lib/patient_http/request_task.rb +26 -12
- data/lib/patient_http/request_template.rb +46 -4
- data/lib/patient_http/response_reader.rb +233 -19
- data/lib/patient_http/synchronous_executor.rb +12 -71
- data/lib/patient_http.rb +49 -5
- metadata +3 -2
|
@@ -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,210 @@ 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.
|
|
115
|
+
#
|
|
116
|
+
# The Content-Length header is checked against the size limit before the
|
|
117
|
+
# read starts. A response to a HEAD request has an empty body but reports
|
|
118
|
+
# the Content-Length of the resource, so the header check is skipped when
|
|
119
|
+
# the body reports itself as empty.
|
|
29
120
|
#
|
|
30
121
|
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
31
122
|
# @param headers_hash [Hash] the response headers
|
|
32
|
-
# @return [String
|
|
33
|
-
# @raise [ResponseTooLargeError] if body exceeds max_response_size
|
|
123
|
+
# @return [Array<String>, nil] the raw body chunks or nil if no body present
|
|
124
|
+
# @raise [ResponseTooLargeError] if the body exceeds max_response_size
|
|
34
125
|
# @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
|
|
35
|
-
def
|
|
36
|
-
|
|
126
|
+
def read_raw_body(async_response, headers_hash)
|
|
127
|
+
body = async_response.body
|
|
128
|
+
return nil unless body
|
|
129
|
+
|
|
130
|
+
validate_content_length(headers_hash) unless body.empty?
|
|
131
|
+
read_body_chunks(async_response)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Decode raw body chunks into the final body string.
|
|
135
|
+
#
|
|
136
|
+
# Joins the chunks, inflates gzip/deflate content (enforcing
|
|
137
|
+
# max_response_size on the inflated bytes), and applies the charset from
|
|
138
|
+
# the Content-Type header. This is CPU-bound work intended to run on a
|
|
139
|
+
# completion worker thread.
|
|
140
|
+
#
|
|
141
|
+
# An encoding the reader does not support leaves the body encoded, and a
|
|
142
|
+
# body that is still encoded keeps its binary encoding because the charset
|
|
143
|
+
# does not describe it. Use {.split_encodings} to find what stays applied
|
|
144
|
+
# so the content-encoding header delivered with the response describes the
|
|
145
|
+
# body it carries.
|
|
146
|
+
#
|
|
147
|
+
# @param chunks [Array<String>, nil] the raw body chunks
|
|
148
|
+
# @param headers_hash [Hash] the response headers
|
|
149
|
+
# @return [String, nil] the decoded body or nil if there was no body
|
|
150
|
+
# @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
|
|
151
|
+
def decode_body(chunks, headers_hash)
|
|
152
|
+
return nil if chunks.nil?
|
|
153
|
+
|
|
154
|
+
remaining, decodable = self.class.split_encodings(headers_hash)
|
|
155
|
+
warn_undecodable(remaining) unless remaining.empty?
|
|
156
|
+
|
|
157
|
+
body = inflate_encodings(chunks, decodable).join
|
|
158
|
+
body.force_encoding(Encoding::ASCII_8BIT)
|
|
159
|
+
# A body that is still encoded is not text yet, so the charset does not
|
|
160
|
+
# describe its bytes. Leave it binary for the caller to decode.
|
|
161
|
+
return body unless remaining.empty?
|
|
37
162
|
|
|
38
|
-
validate_content_length(headers_hash)
|
|
39
|
-
body = read_body_chunks(async_response)
|
|
40
163
|
apply_charset_encoding(body, headers_hash)
|
|
41
164
|
end
|
|
42
165
|
|
|
43
166
|
private
|
|
44
167
|
|
|
168
|
+
# Remove the given encodings from the body, starting with the one applied
|
|
169
|
+
# last. Identity needs no work; every other name here inflates.
|
|
170
|
+
#
|
|
171
|
+
# @param chunks [Array<String>] the encoded body chunks
|
|
172
|
+
# @param encodings [Array<String>] decodable encoding names in applied order
|
|
173
|
+
# @return [Array<String>] the decoded chunks
|
|
174
|
+
# @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
|
|
175
|
+
def inflate_encodings(chunks, encodings)
|
|
176
|
+
encodings.reverse_each do |name|
|
|
177
|
+
chunks = [inflate_encoding(chunks, name)] if INFLATE_WINDOW_BITS.key?(name)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
chunks
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Inflate one encoding, trying each wire format the encoding can use. The
|
|
184
|
+
# chunks are all in memory, so a format that turns out to be wrong can be
|
|
185
|
+
# abandoned and the next one started from the beginning of the body.
|
|
186
|
+
#
|
|
187
|
+
# @param chunks [Array<String>] the encoded body chunks
|
|
188
|
+
# @param name [String] a lowercased content encoding name
|
|
189
|
+
# @return [String] the inflated body
|
|
190
|
+
# @raise [Zlib::Error] if no format could inflate the body
|
|
191
|
+
# @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
|
|
192
|
+
def inflate_encoding(chunks, name)
|
|
193
|
+
formats = INFLATE_WINDOW_BITS.fetch(name)
|
|
194
|
+
last_index = formats.size - 1
|
|
195
|
+
|
|
196
|
+
formats.each_with_index do |window_bits, index|
|
|
197
|
+
return inflate_chunks(chunks, window_bits)
|
|
198
|
+
rescue Zlib::DataError, Zlib::BufError
|
|
199
|
+
raise if index == last_index
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Report an encoding that could not be removed. The body is still delivered
|
|
204
|
+
# with its content-encoding header, so the caller can decode it, but the
|
|
205
|
+
# server ignored the accept-encoding header and that is worth recording.
|
|
206
|
+
#
|
|
207
|
+
# @param remaining [Array<String>] the encodings left on the body
|
|
208
|
+
# @return [void]
|
|
209
|
+
def warn_undecodable(remaining)
|
|
210
|
+
logger&.warn(
|
|
211
|
+
"[PatientHttp] Cannot decode response body with content-encoding " \
|
|
212
|
+
"'#{remaining.join(", ")}'; returning the encoded body"
|
|
213
|
+
)
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
45
217
|
def max_response_size
|
|
46
|
-
@
|
|
218
|
+
@config.max_response_size
|
|
47
219
|
end
|
|
48
220
|
|
|
49
221
|
def logger
|
|
50
|
-
@
|
|
222
|
+
@config.logger
|
|
51
223
|
end
|
|
52
224
|
|
|
53
225
|
# Validate content-length header doesn't exceed max size.
|
|
@@ -66,7 +238,7 @@ module PatientHttp
|
|
|
66
238
|
# Read body chunks while checking size.
|
|
67
239
|
#
|
|
68
240
|
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
69
|
-
# @return [String] the
|
|
241
|
+
# @return [Array<String>] the raw body chunks
|
|
70
242
|
# @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
|
|
71
243
|
# @raise [ReadAbortedError] if the processor stopped past its shutdown deadline mid-read
|
|
72
244
|
def read_body_chunks(async_response)
|
|
@@ -80,7 +252,7 @@ module PatientHttp
|
|
|
80
252
|
# Reads are allowed to finish while the processor is merely stopping
|
|
81
253
|
# (the graceful shutdown window) so in-flight responses can still be
|
|
82
254
|
# delivered.
|
|
83
|
-
if @processor
|
|
255
|
+
if @processor&.stopped?
|
|
84
256
|
raise ReadAbortedError.new("Processor stopped while reading response body")
|
|
85
257
|
end
|
|
86
258
|
|
|
@@ -97,8 +269,7 @@ module PatientHttp
|
|
|
97
269
|
|
|
98
270
|
finished = true
|
|
99
271
|
|
|
100
|
-
|
|
101
|
-
chunks.join.force_encoding(Encoding::ASCII_8BIT)
|
|
272
|
+
chunks
|
|
102
273
|
ensure
|
|
103
274
|
# Always close the body if we were interrupted or if an error occurred
|
|
104
275
|
# This ensures the connection is properly released back to the pool
|
|
@@ -106,6 +277,49 @@ module PatientHttp
|
|
|
106
277
|
end
|
|
107
278
|
end
|
|
108
279
|
|
|
280
|
+
# Inflate compressed body chunks with streaming size enforcement, so a
|
|
281
|
+
# small compressed body cannot expand past max_response_size.
|
|
282
|
+
#
|
|
283
|
+
# @param chunks [Array<String>] the raw compressed chunks
|
|
284
|
+
# @param window_bits [Integer] Zlib window bits for the content encoding
|
|
285
|
+
# @return [String] the inflated body
|
|
286
|
+
# @raise [ResponseTooLargeError] if the inflated size exceeds max_response_size
|
|
287
|
+
def inflate_chunks(chunks, window_bits)
|
|
288
|
+
# A response can declare a content encoding and still carry no body.
|
|
289
|
+
# There is nothing to inflate, and finishing an empty stream would
|
|
290
|
+
# raise a buffer error.
|
|
291
|
+
return +"" if chunks.all?(&:empty?)
|
|
292
|
+
|
|
293
|
+
inflater = Zlib::Inflate.new(window_bits)
|
|
294
|
+
body = +""
|
|
295
|
+
|
|
296
|
+
begin
|
|
297
|
+
# The block form yields the inflated output in buffer-sized pieces, so
|
|
298
|
+
# the size is checked before the whole expansion is materialized. A
|
|
299
|
+
# single small compressed chunk can otherwise inflate to gigabytes
|
|
300
|
+
# before any check runs.
|
|
301
|
+
appender = ->(output) do
|
|
302
|
+
body << output
|
|
303
|
+
validate_inflated_size(body)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
chunks.each { |chunk| inflater.inflate(chunk, &appender) }
|
|
307
|
+
inflater.finish(&appender) unless inflater.finished?
|
|
308
|
+
ensure
|
|
309
|
+
inflater.close
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
body
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def validate_inflated_size(body)
|
|
316
|
+
if body.bytesize > max_response_size
|
|
317
|
+
raise ResponseTooLargeError.new(
|
|
318
|
+
"Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
109
323
|
# Extract charset from Content-Type header.
|
|
110
324
|
#
|
|
111
325
|
# @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 = @response_reader.read_raw_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
|
|
@@ -79,8 +81,7 @@ module PatientHttp
|
|
|
79
81
|
redirect_error = check_redirect_error(@task, response_data)
|
|
80
82
|
break if redirect_error
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
@task = @task.redirect_task(location: location, status: response_data[:status])
|
|
84
|
+
@task = build_redirect_task(@task, response_data)
|
|
84
85
|
end
|
|
85
86
|
|
|
86
87
|
if redirect_error
|
|
@@ -133,19 +134,22 @@ module PatientHttp
|
|
|
133
134
|
|
|
134
135
|
# Create HTTP client with config settings (retries, proxy, connection timeout).
|
|
135
136
|
#
|
|
137
|
+
# The client is not wrapped in a Protocol::HTTP::AcceptEncoding middleware.
|
|
138
|
+
# That wrapper overwrites the request's accept-encoding header, which would
|
|
139
|
+
# ignore a caller opting out of compression, so response bodies are decoded
|
|
140
|
+
# by ResponseReader here exactly as they are on the async path.
|
|
141
|
+
#
|
|
136
142
|
# @param url [String] the resolved request URL
|
|
137
|
-
# @return [
|
|
143
|
+
# @return [Async::HTTP::Client] the HTTP client
|
|
138
144
|
def create_http_client(url)
|
|
139
145
|
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
140
146
|
endpoint = configure_endpoint(endpoint) if @config.connection_timeout
|
|
141
147
|
|
|
142
|
-
|
|
148
|
+
if @config.proxy_url
|
|
143
149
|
create_proxied_client(endpoint)
|
|
144
150
|
else
|
|
145
151
|
Async::HTTP::Client.new(endpoint, retries: @config.retries)
|
|
146
152
|
end
|
|
147
|
-
|
|
148
|
-
Protocol::HTTP::AcceptEncoding.new(client)
|
|
149
153
|
end
|
|
150
154
|
|
|
151
155
|
# Create a proxied HTTP client.
|
|
@@ -174,69 +178,6 @@ module PatientHttp
|
|
|
174
178
|
)
|
|
175
179
|
end
|
|
176
180
|
|
|
177
|
-
# Read the response body with size validation.
|
|
178
|
-
#
|
|
179
|
-
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
180
|
-
# @param headers_hash [Hash] the response headers
|
|
181
|
-
# @return [String, nil] the response body
|
|
182
|
-
def read_response_body(async_response, headers_hash)
|
|
183
|
-
return nil unless async_response.body
|
|
184
|
-
|
|
185
|
-
content_length = headers_hash["content-length"]&.to_i
|
|
186
|
-
if content_length && content_length > @config.max_response_size
|
|
187
|
-
raise ResponseTooLargeError.new(
|
|
188
|
-
"Response body size (#{content_length} bytes) exceeds maximum allowed size (#{@config.max_response_size} bytes)"
|
|
189
|
-
)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
chunks = []
|
|
193
|
-
total_size = 0
|
|
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
|
|
205
|
-
end
|
|
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
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
body = chunks.join.force_encoding(Encoding::ASCII_8BIT)
|
|
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/, "")
|
|
238
|
-
end
|
|
239
|
-
|
|
240
181
|
# Invoke callback synchronously.
|
|
241
182
|
#
|
|
242
183
|
# @param result [Response, Error] the result to pass to callback
|
data/lib/patient_http.rb
CHANGED
|
@@ -30,8 +30,15 @@ module PatientHttp
|
|
|
30
30
|
|
|
31
31
|
class ResponseTooLargeError < StandardError; end
|
|
32
32
|
|
|
33
|
-
#
|
|
34
|
-
|
|
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
|
+
|
|
39
|
+
# HTTP redirect status codes that are followed when a Location header is present.
|
|
40
|
+
# A 300 response is followed only when the server names a preferred choice in Location.
|
|
41
|
+
FOLLOWABLE_REDIRECT_STATUSES = [300, 301, 302, 303, 307, 308].freeze
|
|
35
42
|
|
|
36
43
|
VERSION = File.read(File.join(__dir__, "../VERSION")).strip
|
|
37
44
|
|
|
@@ -45,6 +52,7 @@ module PatientHttp
|
|
|
45
52
|
autoload :Client, File.join(__dir__, "patient_http/client")
|
|
46
53
|
autoload :ClientError, File.join(__dir__, "patient_http/http_error")
|
|
47
54
|
autoload :ClientPool, File.join(__dir__, "patient_http/client_pool")
|
|
55
|
+
autoload :CompletionExecutor, File.join(__dir__, "patient_http/completion_executor")
|
|
48
56
|
autoload :Configuration, File.join(__dir__, "patient_http/configuration")
|
|
49
57
|
autoload :Encryptor, File.join(__dir__, "patient_http/encryptor")
|
|
50
58
|
autoload :Error, File.join(__dir__, "patient_http/error")
|
|
@@ -268,6 +276,16 @@ module PatientHttp
|
|
|
268
276
|
request(:get, uri, callback: callback, **kwargs)
|
|
269
277
|
end
|
|
270
278
|
|
|
279
|
+
# Enqueues an HTTP HEAD request.
|
|
280
|
+
#
|
|
281
|
+
# @param uri [String] absolute URL
|
|
282
|
+
# @param callback [Class, String] callback class to handle the response
|
|
283
|
+
# @param kwargs [Hash] forwarded to `request`
|
|
284
|
+
# @return [Object] return value from the registered request handler
|
|
285
|
+
def head(uri, callback:, **kwargs)
|
|
286
|
+
request(:head, uri, callback: callback, **kwargs)
|
|
287
|
+
end
|
|
288
|
+
|
|
271
289
|
# Enqueues an HTTP POST request.
|
|
272
290
|
#
|
|
273
291
|
# @param uri [String] absolute URL
|
|
@@ -308,9 +326,19 @@ module PatientHttp
|
|
|
308
326
|
request(:delete, uri, callback: callback, **kwargs)
|
|
309
327
|
end
|
|
310
328
|
|
|
329
|
+
# Enqueues an HTTP QUERY request.
|
|
330
|
+
#
|
|
331
|
+
# @param uri [String] absolute URL
|
|
332
|
+
# @param callback [Class, String] callback class to handle the response
|
|
333
|
+
# @param kwargs [Hash] forwarded to `request`
|
|
334
|
+
# @return [Object] return value from the registered request handler
|
|
335
|
+
def query(uri, callback:, **kwargs)
|
|
336
|
+
request(:query, uri, callback: callback, **kwargs)
|
|
337
|
+
end
|
|
338
|
+
|
|
311
339
|
# Builds and dispatches an HTTP request.
|
|
312
340
|
#
|
|
313
|
-
# @param method [Symbol] HTTP method (`:get`, `:post`, `:put`, `:patch`, `:delete`)
|
|
341
|
+
# @param method [Symbol] HTTP method (`:get`, `:head`, `:post`, `:put`, `:patch`, `:delete`, `:query`)
|
|
314
342
|
# @param url [String] absolute URL
|
|
315
343
|
# @param callback [Class, String] callback class to handle the response
|
|
316
344
|
# @param headers [Hash, nil] request headers
|
|
@@ -321,8 +349,16 @@ module PatientHttp
|
|
|
321
349
|
# @param raise_error_responses [Boolean, nil] when true, non-success responses are
|
|
322
350
|
# reported as errors
|
|
323
351
|
# @param callback_args [Hash, nil] JSON-compatible callback arguments
|
|
352
|
+
# @param max_redirects [Integer, nil] maximum redirects to follow (nil uses the configuration
|
|
353
|
+
# default, 0 disables redirects)
|
|
354
|
+
# @param follow_method_changing_redirects [Boolean, nil] whether to follow a redirect that changes the
|
|
355
|
+
# HTTP method (nil uses the configuration default)
|
|
356
|
+
# @param redirect_strip_headers [String, Array<String>, nil] header names (case insensitive)
|
|
357
|
+
# to strip from redirected requests, in addition to the configured names
|
|
324
358
|
# @param preprocessors [String, Symbol, Array<String, Symbol>, nil] names of preprocessors
|
|
325
359
|
# registered on the configuration to apply to the request when it is sent
|
|
360
|
+
# @param processor [String, Symbol, nil] name of the processor that should execute
|
|
361
|
+
# the request; handlers that support named processors route on this value
|
|
326
362
|
# @return [Object] return value from the registered request handler
|
|
327
363
|
def request(
|
|
328
364
|
method,
|
|
@@ -335,7 +371,11 @@ module PatientHttp
|
|
|
335
371
|
timeout: nil,
|
|
336
372
|
raise_error_responses: nil,
|
|
337
373
|
callback_args: nil,
|
|
338
|
-
|
|
374
|
+
max_redirects: nil,
|
|
375
|
+
follow_method_changing_redirects: nil,
|
|
376
|
+
redirect_strip_headers: nil,
|
|
377
|
+
preprocessors: nil,
|
|
378
|
+
processor: nil
|
|
339
379
|
)
|
|
340
380
|
request = Request.new(
|
|
341
381
|
method,
|
|
@@ -345,7 +385,11 @@ module PatientHttp
|
|
|
345
385
|
headers: headers,
|
|
346
386
|
params: params,
|
|
347
387
|
timeout: timeout,
|
|
348
|
-
|
|
388
|
+
max_redirects: max_redirects,
|
|
389
|
+
follow_method_changing_redirects: follow_method_changing_redirects,
|
|
390
|
+
redirect_strip_headers: redirect_strip_headers,
|
|
391
|
+
preprocessors: preprocessors,
|
|
392
|
+
processor: processor
|
|
349
393
|
)
|
|
350
394
|
execute(
|
|
351
395
|
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.6.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
|
|
@@ -145,7 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
145
146
|
- !ruby/object:Gem::Version
|
|
146
147
|
version: '0'
|
|
147
148
|
requirements: []
|
|
148
|
-
rubygems_version:
|
|
149
|
+
rubygems_version: 4.0.3
|
|
149
150
|
specification_version: 4
|
|
150
151
|
summary: Generic async HTTP connection pool for Ruby applications using Fiber-based
|
|
151
152
|
concurrency
|