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.
@@ -15,6 +15,16 @@ module PatientHttp
15
15
  # @return [Integer] Maximum number of concurrent connections
16
16
  attr_reader :max_connections
17
17
 
18
+ # @return [Integer, nil] Maximum number of connections per host (nil for unlimited)
19
+ attr_reader :max_connections_per_host
20
+
21
+ # @return [Integer] Number of threads that deliver completed results
22
+ attr_reader :completion_threads
23
+
24
+ # @return [Integer] Number of retries when delivering a completed result fails.
25
+ # A retry calls the task handler again, so handlers must be idempotent.
26
+ attr_reader :completion_retries
27
+
18
28
  # @return [Numeric] Default request timeout in seconds
19
29
  attr_reader :request_timeout
20
30
 
@@ -33,6 +43,15 @@ module PatientHttp
33
43
  # @return [Integer] Maximum number of redirects to follow (0 disables redirects)
34
44
  attr_reader :max_redirects
35
45
 
46
+ # @return [Boolean] Whether a redirect that requires changing the HTTP method
47
+ # (for example POST to GET on a 302) may be followed. When false, such a
48
+ # redirect response is returned as the result instead of being followed.
49
+ attr_reader :follow_method_changing_redirects
50
+
51
+ # @return [Array<String>] Lowercase header names that are always stripped from
52
+ # redirected requests
53
+ attr_reader :redirect_strip_headers
54
+
36
55
  # @return [Integer] This is the maximum number of hosts for which connections
37
56
  # will be kept alive for at one time.
38
57
  attr_reader :connection_pool_size
@@ -63,11 +82,23 @@ module PatientHttp
63
82
  # @param user_agent [String, nil] Default User-Agent header value
64
83
  # @param raise_error_responses [Boolean] Whether to raise HttpError for non-2xx responses by default
65
84
  # @param max_redirects [Integer] Maximum number of redirects to follow (0 disables redirects)
85
+ # @param follow_method_changing_redirects [Boolean] Whether to follow a redirect that requires changing the
86
+ # HTTP method, such as POST to GET on a 301, 302, or 303 response. When false, requests
87
+ # whose method would change do not follow the redirect and receive the redirect response.
88
+ # @param redirect_strip_headers [String, Array<String>] Header names (case insensitive)
89
+ # that are always stripped from redirected requests, so sensitive headers are never
90
+ # sent to a redirect target
66
91
  # @param connection_pool_size [Integer] Maximum number of host clients to pool
67
92
  # @param connection_timeout [Numeric, nil] Connection timeout in seconds
68
93
  # @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
69
94
  # @param retries [Integer] Number of retries for failed requests
70
95
  # @param protocol [Symbol, nil] HTTP protocol to use (:http1 or :http2); nil to negotiate
96
+ # @param max_connections_per_host [Integer, nil] Maximum number of connections per host (nil for unlimited)
97
+ # @param completion_threads [Integer] Number of threads that deliver completed results
98
+ # @param completion_retries [Integer] Number of retries when delivering a completed result fails.
99
+ # A retry calls TaskHandler#on_complete or #on_error again, so a handler that raises after
100
+ # its side effect delivers the callback more than once unless it is idempotent. Set to 0 to
101
+ # report the first failure without retrying.
71
102
  def initialize(
72
103
  max_connections: 256,
73
104
  request_timeout: 60,
@@ -77,12 +108,17 @@ module PatientHttp
77
108
  user_agent: "PatientHttp",
78
109
  raise_error_responses: false,
79
110
  max_redirects: 5,
111
+ follow_method_changing_redirects: true,
112
+ redirect_strip_headers: [],
80
113
  connection_pool_size: 100,
81
114
  connection_timeout: nil,
82
115
  proxy_url: nil,
83
116
  retries: 3,
84
117
  protocol: nil,
85
- encryption_key: nil
118
+ encryption_key: nil,
119
+ max_connections_per_host: nil,
120
+ completion_threads: 2,
121
+ completion_retries: 2
86
122
  )
87
123
  @mutex = Mutex.new
88
124
 
@@ -107,12 +143,17 @@ module PatientHttp
107
143
  self.user_agent = user_agent
108
144
  self.raise_error_responses = raise_error_responses
109
145
  self.max_redirects = max_redirects
146
+ self.follow_method_changing_redirects = follow_method_changing_redirects
147
+ self.redirect_strip_headers = redirect_strip_headers
110
148
  self.connection_pool_size = connection_pool_size
111
149
  self.connection_timeout = connection_timeout
112
150
  self.proxy_url = proxy_url
113
151
  self.retries = retries
114
152
  self.protocol = protocol
115
153
  self.encryption_key = encryption_key
154
+ self.max_connections_per_host = max_connections_per_host
155
+ self.completion_threads = completion_threads
156
+ self.completion_retries = completion_retries
116
157
  end
117
158
 
118
159
  # Get the logger to use to report pool events. Default is to log errors to STDERR.
@@ -124,6 +165,26 @@ module PatientHttp
124
165
  @max_connections = value
125
166
  end
126
167
 
168
+ def max_connections_per_host=(value)
169
+ if value.nil?
170
+ @max_connections_per_host = nil
171
+ return
172
+ end
173
+
174
+ validate_positive_integer(:max_connections_per_host, value)
175
+ @max_connections_per_host = value
176
+ end
177
+
178
+ def completion_threads=(value)
179
+ validate_positive_integer(:completion_threads, value)
180
+ @completion_threads = value
181
+ end
182
+
183
+ def completion_retries=(value)
184
+ validate_non_negative_integer(:completion_retries, value)
185
+ @completion_retries = value
186
+ end
187
+
127
188
  def request_timeout=(value)
128
189
  validate_positive(:request_timeout, value)
129
190
  @request_timeout = value
@@ -144,6 +205,18 @@ module PatientHttp
144
205
  @max_redirects = value
145
206
  end
146
207
 
208
+ def follow_method_changing_redirects=(value)
209
+ unless value == true || value == false
210
+ raise ArgumentError.new("follow_method_changing_redirects must be true or false, got: #{value.inspect}")
211
+ end
212
+
213
+ @follow_method_changing_redirects = value
214
+ end
215
+
216
+ def redirect_strip_headers=(value)
217
+ @redirect_strip_headers = RedirectHelper.normalize_header_names(value)
218
+ end
219
+
147
220
  def connection_pool_size=(value)
148
221
  validate_positive_integer(:connection_pool_size, value)
149
222
  @connection_pool_size = value
@@ -383,11 +456,16 @@ module PatientHttp
383
456
  "user_agent" => user_agent,
384
457
  "raise_error_responses" => raise_error_responses,
385
458
  "max_redirects" => max_redirects,
459
+ "follow_method_changing_redirects" => follow_method_changing_redirects,
460
+ "redirect_strip_headers" => redirect_strip_headers,
386
461
  "connection_pool_size" => connection_pool_size,
387
462
  "connection_timeout" => connection_timeout,
388
463
  "proxy_url" => proxy_url,
389
464
  "retries" => retries,
390
465
  "protocol" => protocol,
466
+ "max_connections_per_host" => max_connections_per_host,
467
+ "completion_threads" => completion_threads,
468
+ "completion_retries" => completion_retries,
391
469
  "payload_stores" => payload_stores.keys,
392
470
  "default_payload_store" => default_payload_store_name,
393
471
  "secrets" => @mutex.synchronize { @secrets.keys },
@@ -12,7 +12,7 @@ module PatientHttp
12
12
  #
13
13
  # @see Configuration#register_preprocessor
14
14
  class OutgoingRequest
15
- # @return [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
15
+ # @return [Symbol] HTTP method (:get, :head, :post, :put, :patch, :delete, :query)
16
16
  attr_reader :http_method
17
17
 
18
18
  # @return [String] the request URL with any secret query params already resolved
@@ -29,7 +29,9 @@ module PatientHttp
29
29
  # Encodes a value based on its MIME type.
30
30
  #
31
31
  # For text-based content types, applies gzip compression if beneficial.
32
- # For binary content, uses Base64 encoding.
32
+ # For binary content, uses Base64 encoding. A value that a text MIME type
33
+ # claims is text but that does not hold text is encoded as binary as
34
+ # well, because the serialized form must survive JSON encoding.
33
35
  #
34
36
  # @param value [String] the value to encode
35
37
  # @param mimetype [String, nil] the MIME type of the content
@@ -38,21 +40,11 @@ module PatientHttp
38
40
  return nil if value.nil?
39
41
 
40
42
  if is_text_mimetype?(mimetype)
41
- value = text_value(value, charset(mimetype))
42
-
43
- if value.bytesize < 4096
44
- [:text, value, value.encoding.name]
45
- else
46
- gzipped = Zlib.gzip(value)
47
- if gzipped.bytesize < value.bytesize
48
- [:gzipped, [gzipped].pack("m0"), value.encoding.name]
49
- else
50
- [:text, value, value.encoding.name]
51
- end
52
- end
53
- else
54
- [:binary, [value].pack("m0"), Encoding::BINARY.name]
43
+ text = text_value(value, charset(mimetype))
44
+ return encode_text(text) if text?(text)
55
45
  end
46
+
47
+ [:binary, [value].pack("m0"), Encoding::BINARY.name]
56
48
  end
57
49
 
58
50
  # Decodes an encoded value based on its encoding type.
@@ -78,6 +70,36 @@ module PatientHttp
78
70
 
79
71
  private
80
72
 
73
+ # Encode a text value, compressing it when that makes it smaller.
74
+ #
75
+ # @param value [String] the text to encode
76
+ # @return [Array(Symbol, String, String)] [encoding, encoded_value, charset]
77
+ def encode_text(value)
78
+ return [:text, value, value.encoding.name] if value.bytesize < 4096
79
+
80
+ gzipped = Zlib.gzip(value)
81
+ if gzipped.bytesize < value.bytesize
82
+ [:gzipped, [gzipped].pack("m0"), value.encoding.name]
83
+ else
84
+ [:text, value, value.encoding.name]
85
+ end
86
+ end
87
+
88
+ # Whether a value can be serialized as text. JSON encoding converts a
89
+ # string to UTF-8, so the value must either be valid text in its own
90
+ # encoding or hold bytes that are already valid UTF-8. A body still
91
+ # carrying a content encoding the reader could not decode holds neither,
92
+ # even though its MIME type names a text type.
93
+ #
94
+ # @param value [String] the value to check
95
+ # @return [Boolean]
96
+ def text?(value)
97
+ return value.valid_encoding? unless value.encoding == Encoding::BINARY
98
+ return true if value.ascii_only?
99
+
100
+ value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
101
+ end
102
+
81
103
  def is_text_mimetype?(mimetype)
82
104
  mimetype&.match?(/\Atext\/|application\/(?:json|xml|javascript)/)
83
105
  end