patient_http 1.6.1 → 1.7.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 +8 -7
- data/CHANGELOG.md +29 -0
- data/README.md +539 -515
- data/VERSION +1 -1
- data/lib/patient_http/callback_args.rb +53 -48
- data/lib/patient_http/callback_validator.rb +11 -7
- data/lib/patient_http/class_helper.rb +6 -7
- data/lib/patient_http/client.rb +28 -22
- data/lib/patient_http/client_pool.rb +134 -39
- data/lib/patient_http/completion_executor.rb +20 -20
- data/lib/patient_http/configuration.rb +367 -119
- data/lib/patient_http/connection_endpoint.rb +150 -0
- data/lib/patient_http/encryptor.rb +28 -18
- data/lib/patient_http/error.rb +24 -18
- data/lib/patient_http/external_storage.rb +42 -38
- data/lib/patient_http/http_error.rb +30 -26
- data/lib/patient_http/http_headers.rb +35 -29
- data/lib/patient_http/immediate_retries.rb +98 -0
- data/lib/patient_http/inline_task_handler.rb +15 -10
- data/lib/patient_http/lifecycle_manager.rb +39 -40
- data/lib/patient_http/outgoing_request.rb +25 -23
- data/lib/patient_http/payload.rb +28 -26
- data/lib/patient_http/payload_store/active_record_store.rb +31 -34
- data/lib/patient_http/payload_store/base.rb +42 -46
- data/lib/patient_http/payload_store/file_store.rb +22 -26
- data/lib/patient_http/payload_store/redis_store.rb +28 -34
- data/lib/patient_http/payload_store/s3_store.rb +25 -28
- data/lib/patient_http/payload_store.rb +2 -0
- data/lib/patient_http/processor.rb +111 -79
- data/lib/patient_http/processor_observer.rb +65 -59
- data/lib/patient_http/rails/engine.rb +13 -8
- data/lib/patient_http/redirect_error.rb +50 -41
- data/lib/patient_http/redirect_helper.rb +38 -38
- data/lib/patient_http/request.rb +70 -46
- data/lib/patient_http/request_error.rb +47 -42
- data/lib/patient_http/request_helper.rb +142 -119
- data/lib/patient_http/request_preparer.rb +13 -10
- data/lib/patient_http/request_task.rb +113 -84
- data/lib/patient_http/request_template.rb +87 -64
- data/lib/patient_http/response.rb +58 -52
- data/lib/patient_http/response_reader.rb +66 -65
- data/lib/patient_http/secret_manager.rb +34 -30
- data/lib/patient_http/secret_reference.rb +33 -26
- data/lib/patient_http/synchronous_executor.rb +67 -95
- data/lib/patient_http/task_handler.rb +23 -19
- data/lib/patient_http/time_helper.rb +8 -8
- data/lib/patient_http.rb +311 -186
- data/patient_http.gemspec +3 -2
- metadata +21 -5
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.7.0
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
|
-
#
|
|
4
|
+
# The callback arguments that a request passes to its `on_complete` and
|
|
5
|
+
# `on_error` callbacks.
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# or symbols. All hash keys, including nested hashes and hashes within arrays, are
|
|
10
|
-
# deeply converted to strings.
|
|
7
|
+
# The arguments are stored with string keys, so they can be serialized to
|
|
8
|
+
# JSON. You can read them with string or symbol keys. The keys of nested
|
|
9
|
+
# hashes, including hashes in arrays, are converted to strings.
|
|
11
10
|
#
|
|
12
|
-
# @example
|
|
11
|
+
# @example Read arguments
|
|
13
12
|
# args = CallbackArgs.new(user_id: 123, action: "fetch")
|
|
14
13
|
# args[:user_id] # => 123
|
|
15
14
|
# args["user_id"] # => 123
|
|
@@ -17,30 +16,34 @@ module PatientHttp
|
|
|
17
16
|
# args.include?(:user_id) # => true
|
|
18
17
|
# args.to_h # => {user_id: 123, action: "fetch"}
|
|
19
18
|
#
|
|
20
|
-
# @example
|
|
19
|
+
# @example Read a nested hash
|
|
21
20
|
# args = CallbackArgs.new(metadata: {tags: ["a", "b"], level: 1})
|
|
22
21
|
# args[:metadata] # => {"tags" => ["a", "b"], "level" => 1}
|
|
23
22
|
#
|
|
24
|
-
# @example
|
|
23
|
+
# @example Read arguments from a response
|
|
25
24
|
# response.callback_args[:user_id]
|
|
26
25
|
class CallbackArgs
|
|
27
|
-
# JSON-native types that are allowed as values
|
|
26
|
+
# The JSON-native types that are allowed as values, in addition to `Array`
|
|
27
|
+
# and `Hash`.
|
|
28
28
|
ALLOWED_TYPES = [NilClass, TrueClass, FalseClass, String, Integer, Float].freeze
|
|
29
29
|
|
|
30
30
|
class << self
|
|
31
|
-
#
|
|
31
|
+
# Creates callback arguments from their serialized form. The values aren't
|
|
32
|
+
# validated.
|
|
32
33
|
#
|
|
33
|
-
# @param hash [Hash, nil] hash
|
|
34
|
-
# @return [CallbackArgs]
|
|
34
|
+
# @param hash [Hash, nil] The hash from {#as_json}.
|
|
35
|
+
# @return [CallbackArgs] The callback arguments.
|
|
35
36
|
def load(hash)
|
|
36
37
|
new(hash || {}, validate: false)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
#
|
|
40
|
+
# Validates that a value is a JSON-native type. Arrays and hashes are
|
|
41
|
+
# validated recursively.
|
|
40
42
|
#
|
|
41
|
-
# @param value [Object]
|
|
42
|
-
# @param path [String]
|
|
43
|
-
# @raise [ArgumentError]
|
|
43
|
+
# @param value [Object] The value to validate.
|
|
44
|
+
# @param path [String] The path to the value, for the error message.
|
|
45
|
+
# @raise [ArgumentError] If the value isn't a JSON-native type, or if a hash
|
|
46
|
+
# key isn't a String or Symbol.
|
|
44
47
|
# @return [void]
|
|
45
48
|
def validate_value!(value, path = "value")
|
|
46
49
|
case value
|
|
@@ -63,10 +66,11 @@ module PatientHttp
|
|
|
63
66
|
end
|
|
64
67
|
end
|
|
65
68
|
|
|
66
|
-
#
|
|
69
|
+
# Converts all hash keys to strings, including the keys of nested hashes
|
|
70
|
+
# and of hashes in arrays.
|
|
67
71
|
#
|
|
68
|
-
# @param value [Object]
|
|
69
|
-
# @return [Object]
|
|
72
|
+
# @param value [Object] The value to convert.
|
|
73
|
+
# @return [Object] The value with string keys.
|
|
70
74
|
def deep_stringify_keys(value)
|
|
71
75
|
case value
|
|
72
76
|
when Hash
|
|
@@ -79,12 +83,14 @@ module PatientHttp
|
|
|
79
83
|
end
|
|
80
84
|
end
|
|
81
85
|
|
|
82
|
-
#
|
|
86
|
+
# Creates callback arguments.
|
|
83
87
|
#
|
|
84
|
-
# @param args [Hash, nil] arguments
|
|
85
|
-
# @param validate [Boolean]
|
|
86
|
-
#
|
|
87
|
-
# @raise [ArgumentError]
|
|
88
|
+
# @param args [Hash, nil] The arguments. All keys are converted to strings.
|
|
89
|
+
# @param validate [Boolean] Whether to validate that the values are JSON-native
|
|
90
|
+
# types.
|
|
91
|
+
# @raise [ArgumentError] If `args` isn't `nil` and doesn't respond to `to_h`.
|
|
92
|
+
# @raise [ArgumentError] If `validate` is `true` and a value isn't a
|
|
93
|
+
# JSON-native type.
|
|
88
94
|
def initialize(args = nil, validate: true)
|
|
89
95
|
if args.nil?
|
|
90
96
|
@data = {}
|
|
@@ -101,11 +107,11 @@ module PatientHttp
|
|
|
101
107
|
end
|
|
102
108
|
end
|
|
103
109
|
|
|
104
|
-
#
|
|
110
|
+
# Returns the value for a key.
|
|
105
111
|
#
|
|
106
|
-
# @param key [String, Symbol]
|
|
107
|
-
# @return [Object]
|
|
108
|
-
# @raise [KeyError]
|
|
112
|
+
# @param key [String, Symbol] The key.
|
|
113
|
+
# @return [Object] The value.
|
|
114
|
+
# @raise [KeyError] If the key doesn't exist.
|
|
109
115
|
def [](key)
|
|
110
116
|
string_key = key.to_s
|
|
111
117
|
unless @data.include?(string_key)
|
|
@@ -115,60 +121,59 @@ module PatientHttp
|
|
|
115
121
|
@data[string_key]
|
|
116
122
|
end
|
|
117
123
|
|
|
118
|
-
#
|
|
124
|
+
# Returns the value for a key, or a default value if the key doesn't exist.
|
|
119
125
|
#
|
|
120
|
-
# @param key [String, Symbol]
|
|
121
|
-
# @param default [Object]
|
|
122
|
-
# @return [Object]
|
|
126
|
+
# @param key [String, Symbol] The key.
|
|
127
|
+
# @param default [Object] The value to return if the key doesn't exist.
|
|
128
|
+
# @return [Object] The value, or the default value.
|
|
123
129
|
def fetch(key, default = nil)
|
|
124
130
|
@data.fetch(key.to_s, default)
|
|
125
131
|
end
|
|
126
132
|
|
|
127
|
-
#
|
|
133
|
+
# Returns whether a key exists.
|
|
128
134
|
#
|
|
129
|
-
# @param key [String, Symbol]
|
|
130
|
-
# @return [Boolean] true if the key exists
|
|
135
|
+
# @param key [String, Symbol] The key.
|
|
136
|
+
# @return [Boolean] `true` if the key exists.
|
|
131
137
|
def include?(key)
|
|
132
138
|
@data.include?(key.to_s)
|
|
133
139
|
end
|
|
134
140
|
|
|
135
|
-
#
|
|
141
|
+
# Returns the arguments as a hash with symbol keys. Only the top-level keys
|
|
142
|
+
# are symbols. The keys of nested hashes stay strings.
|
|
136
143
|
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
# @return [Hash] hash with symbol keys
|
|
144
|
+
# @return [Hash] The arguments.
|
|
140
145
|
def to_h
|
|
141
146
|
@data.transform_keys(&:to_sym)
|
|
142
147
|
end
|
|
143
148
|
|
|
144
|
-
#
|
|
149
|
+
# Returns the arguments as a JSON-compatible hash with string keys.
|
|
145
150
|
#
|
|
146
|
-
# @return [Hash]
|
|
151
|
+
# @return [Hash] The serialized arguments.
|
|
147
152
|
def as_json
|
|
148
153
|
@data.dup
|
|
149
154
|
end
|
|
150
155
|
|
|
151
156
|
alias_method :dump, :as_json
|
|
152
157
|
|
|
153
|
-
#
|
|
158
|
+
# Returns whether there are no arguments.
|
|
154
159
|
#
|
|
155
|
-
# @return [Boolean] true if
|
|
160
|
+
# @return [Boolean] `true` if there are no arguments.
|
|
156
161
|
def empty?
|
|
157
162
|
@data.empty?
|
|
158
163
|
end
|
|
159
164
|
|
|
160
|
-
#
|
|
165
|
+
# Returns the number of arguments.
|
|
161
166
|
#
|
|
162
|
-
# @return [Integer]
|
|
167
|
+
# @return [Integer] The number of top-level keys.
|
|
163
168
|
def size
|
|
164
169
|
@data.size
|
|
165
170
|
end
|
|
166
171
|
|
|
167
172
|
alias_method :length, :size
|
|
168
173
|
|
|
169
|
-
#
|
|
174
|
+
# Returns the top-level keys.
|
|
170
175
|
#
|
|
171
|
-
# @return [Array<String>]
|
|
176
|
+
# @return [Array<String>] The keys.
|
|
172
177
|
def keys
|
|
173
178
|
@data.keys
|
|
174
179
|
end
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
|
+
# Validates callback service classes and callback arguments.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
4
7
|
module CallbackValidator
|
|
5
8
|
class << self
|
|
6
|
-
#
|
|
9
|
+
# Validates that the callback class defines the required methods.
|
|
7
10
|
#
|
|
8
|
-
# @param callback [Class, String]
|
|
11
|
+
# @param callback [Class, String] The callback service class, or its name.
|
|
9
12
|
# @return [void]
|
|
10
|
-
# @raise [ArgumentError]
|
|
13
|
+
# @raise [ArgumentError] If the class doesn't define `on_complete` and
|
|
14
|
+
# `on_error` instance methods that take one argument.
|
|
11
15
|
def validate!(callback)
|
|
12
16
|
callback_class = callback.is_a?(Class) ? callback : ClassHelper.resolve_class_name(callback)
|
|
13
17
|
|
|
@@ -15,11 +19,11 @@ module PatientHttp
|
|
|
15
19
|
validate_callback_method!(callback_class, :on_error)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
|
-
#
|
|
22
|
+
# Validates the callback arguments and converts them to a hash with string keys.
|
|
19
23
|
#
|
|
20
|
-
# @param callback_args [#to_h, nil]
|
|
21
|
-
# @return [Hash, nil]
|
|
22
|
-
# @raise [ArgumentError]
|
|
24
|
+
# @param callback_args [#to_h, nil] The callback arguments.
|
|
25
|
+
# @return [Hash, nil] Validated hash with string keys, or nil.
|
|
26
|
+
# @raise [ArgumentError] If callback_args is invalid.
|
|
23
27
|
def validate_callback_args(callback_args)
|
|
24
28
|
return nil if callback_args.nil?
|
|
25
29
|
|
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
|
-
#
|
|
4
|
+
# Resolves class names to classes.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# which is useful for dynamic class loading.
|
|
6
|
+
# @api private
|
|
8
7
|
module ClassHelper
|
|
9
8
|
extend self
|
|
10
9
|
|
|
11
|
-
#
|
|
10
|
+
# Returns the class for a class name.
|
|
12
11
|
#
|
|
13
|
-
# @param class_name [String]
|
|
14
|
-
# @return [Class, nil]
|
|
15
|
-
# @raise [NameError]
|
|
12
|
+
# @param class_name [String] The fully qualified class name.
|
|
13
|
+
# @return [Class, nil] The class object or nil if no class_name given.
|
|
14
|
+
# @raise [NameError] If class cannot be found.
|
|
16
15
|
def resolve_class_name(class_name)
|
|
17
16
|
return class_name if class_name.is_a?(Class)
|
|
18
17
|
return nil if class_name.nil? || class_name.empty?
|
data/lib/patient_http/client.rb
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
|
+
# Sends the requests of a {Processor} through a pool of HTTP clients.
|
|
5
|
+
#
|
|
6
|
+
# @api private
|
|
4
7
|
class Client
|
|
8
|
+
include ImmediateRetries
|
|
9
|
+
|
|
10
|
+
# Creates a client for a processor.
|
|
11
|
+
#
|
|
12
|
+
# @param processor [Processor] The processor that owns the client.
|
|
5
13
|
def initialize(processor)
|
|
6
14
|
@processor = processor
|
|
7
|
-
@client_pool = ClientPool.
|
|
8
|
-
max_size: config.connection_pool_size,
|
|
9
|
-
connection_timeout: config.connection_timeout,
|
|
10
|
-
proxy_url: config.proxy_url,
|
|
11
|
-
retries: config.retries,
|
|
12
|
-
protocol: config.protocol,
|
|
13
|
-
connection_limit: config.max_connections_per_host
|
|
14
|
-
)
|
|
15
|
+
@client_pool = ClientPool.from_config(config)
|
|
15
16
|
@response_reader = ResponseReader.new(@processor)
|
|
16
17
|
@request_preparer = RequestPreparer.new(config)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
#
|
|
20
|
+
# Makes an asynchronous HTTP request.
|
|
20
21
|
#
|
|
21
22
|
# The returned body is the array of raw (possibly compressed) body chunks;
|
|
22
23
|
# use {#decode_response} to produce the final body string. Splitting the
|
|
23
24
|
# decode out keeps CPU-bound work off the reactor thread.
|
|
24
25
|
#
|
|
25
|
-
# @param request [Request]
|
|
26
|
-
# @param request_id [String]
|
|
27
|
-
# @return [Hash]
|
|
26
|
+
# @param request [Request] The request to make.
|
|
27
|
+
# @param request_id [String] Unique request identifier.
|
|
28
|
+
# @return [Hash] The response data with keys for :status, :headers, and :body.
|
|
28
29
|
def make_request(request, request_id)
|
|
29
30
|
async_response = nil
|
|
31
|
+
client = nil
|
|
30
32
|
|
|
31
33
|
begin
|
|
32
34
|
outgoing = @request_preparer.prepare(request, request_id)
|
|
@@ -36,7 +38,10 @@ module PatientHttp
|
|
|
36
38
|
timeout = request.timeout || config.request_timeout
|
|
37
39
|
|
|
38
40
|
Async::Task.current.with_timeout(timeout) do
|
|
39
|
-
|
|
41
|
+
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
42
|
+
async_response = request_with_immediate_retries(
|
|
43
|
+
@client_pool, request, endpoint, headers, body
|
|
44
|
+
) { |pooled_client| client = pooled_client }
|
|
40
45
|
# Note: headers that appear multiple times (e.g. set-cookie) are
|
|
41
46
|
# flattened to a single joined string value.
|
|
42
47
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
@@ -49,17 +54,18 @@ module PatientHttp
|
|
|
49
54
|
}
|
|
50
55
|
end
|
|
51
56
|
rescue => e
|
|
52
|
-
# Close the response and evict the client
|
|
53
|
-
#
|
|
57
|
+
# Close the response and evict the client that failed so its stale
|
|
58
|
+
# connections are not reused. Evicting by identity leaves a replacement
|
|
59
|
+
# client for the host alone.
|
|
54
60
|
async_response&.close
|
|
55
|
-
if connection_error?(e)
|
|
56
|
-
@client_pool.evict(
|
|
61
|
+
if client && connection_error?(e)
|
|
62
|
+
@client_pool.evict(url, client)
|
|
57
63
|
end
|
|
58
64
|
raise
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
62
|
-
#
|
|
68
|
+
# Decodes raw response data into deliverable response data.
|
|
63
69
|
#
|
|
64
70
|
# Joins and inflates the raw body chunks, applies the charset, and rewrites
|
|
65
71
|
# the content-encoding header to name only the encodings still applied to
|
|
@@ -68,9 +74,9 @@ module PatientHttp
|
|
|
68
74
|
# response always describes the body it carries. This is CPU-bound work
|
|
69
75
|
# intended to run on a completion worker thread.
|
|
70
76
|
#
|
|
71
|
-
# @param response_data [Hash]
|
|
72
|
-
# @return [Hash]
|
|
73
|
-
# @raise [ResponseTooLargeError]
|
|
77
|
+
# @param response_data [Hash] Raw response data from {#make_request}.
|
|
78
|
+
# @return [Hash] Response data with the decoded body string.
|
|
79
|
+
# @raise [ResponseTooLargeError] If the inflated body exceeds max_response_size.
|
|
74
80
|
def decode_response(response_data)
|
|
75
81
|
headers = response_data[:headers]
|
|
76
82
|
body = @response_reader.decode_body(response_data[:body], headers)
|
|
@@ -79,7 +85,7 @@ module PatientHttp
|
|
|
79
85
|
response_data.merge(headers: headers, body: body)
|
|
80
86
|
end
|
|
81
87
|
|
|
82
|
-
#
|
|
88
|
+
# Closes all clients and releases their resources.
|
|
83
89
|
#
|
|
84
90
|
# @return [void]
|
|
85
91
|
def close
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module PatientHttp
|
|
4
|
-
#
|
|
4
|
+
# A pool of HTTP clients, one for each host.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# A client is created when a host first needs one. When the pool is full and
|
|
7
|
+
# a new client is needed, the least recently used client is closed and
|
|
8
|
+
# removed.
|
|
9
|
+
#
|
|
10
|
+
# @api private
|
|
9
11
|
class ClientPool
|
|
10
12
|
# Supported protocol names mapped to their async-http implementations. Forcing
|
|
11
13
|
# :http1 also limits the TLS ALPN advertisement to http/1.1, which avoids
|
|
@@ -15,7 +17,40 @@ module PatientHttp
|
|
|
15
17
|
http2: Async::HTTP::Protocol::HTTP2
|
|
16
18
|
}.freeze
|
|
17
19
|
|
|
18
|
-
|
|
20
|
+
class << self
|
|
21
|
+
# Builds a pool with the connection settings from a configuration.
|
|
22
|
+
#
|
|
23
|
+
# @param config [Configuration] The configuration to read settings from.
|
|
24
|
+
# @return [ClientPool] The new pool.
|
|
25
|
+
def from_config(config)
|
|
26
|
+
new(
|
|
27
|
+
max_size: config.connection_pool_size,
|
|
28
|
+
connection_timeout: config.connection_timeout,
|
|
29
|
+
proxy_url: config.proxy_url,
|
|
30
|
+
retries: config.retries,
|
|
31
|
+
protocol: config.protocol,
|
|
32
|
+
connection_limit: config.max_connections_per_host,
|
|
33
|
+
tcp_keepalive: config.tcp_keepalive,
|
|
34
|
+
tcp_user_timeout: config.tcp_user_timeout
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Creates a client pool.
|
|
40
|
+
#
|
|
41
|
+
# @param max_size [Integer] The maximum number of host clients in the pool.
|
|
42
|
+
# @param connection_timeout [Numeric, nil] The timeout in seconds to open a
|
|
43
|
+
# connection.
|
|
44
|
+
# @param proxy_url [String, nil] The HTTP or HTTPS proxy URL.
|
|
45
|
+
# @param retries [Integer] The number of retries for failed requests.
|
|
46
|
+
# @param protocol [Symbol, nil] The HTTP protocol: `:http1` or `:http2`.
|
|
47
|
+
# @param connection_limit [Integer, nil] The maximum number of connections
|
|
48
|
+
# to each host.
|
|
49
|
+
# @param tcp_keepalive [Integer, Hash, nil] The TCP keepalive settings.
|
|
50
|
+
# @param tcp_user_timeout [Numeric, nil] The TCP user timeout in seconds.
|
|
51
|
+
# @raise [ArgumentError] If the protocol isn't supported.
|
|
52
|
+
def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil,
|
|
53
|
+
connection_limit: nil, tcp_keepalive: nil, tcp_user_timeout: nil)
|
|
19
54
|
if protocol && !PROTOCOLS.include?(protocol)
|
|
20
55
|
raise ArgumentError.new("protocol must be one of #{PROTOCOLS.keys.inspect}, got: #{protocol.inspect}")
|
|
21
56
|
end
|
|
@@ -23,20 +58,26 @@ module PatientHttp
|
|
|
23
58
|
@clients = {}
|
|
24
59
|
@max_size = max_size
|
|
25
60
|
@connection_timeout = connection_timeout
|
|
61
|
+
@tcp_keepalive = tcp_keepalive.is_a?(Numeric) ? {idle: tcp_keepalive} : tcp_keepalive
|
|
62
|
+
@tcp_user_timeout = tcp_user_timeout
|
|
26
63
|
@proxy_url = proxy_url
|
|
27
64
|
@retries = retries
|
|
28
65
|
@protocol = protocol
|
|
29
66
|
@connection_limit = connection_limit
|
|
30
67
|
@mutex = Mutex.new
|
|
31
68
|
@proxy_client = nil
|
|
69
|
+
@closing_tasks = []
|
|
70
|
+
@closing_mutex = Mutex.new
|
|
32
71
|
end
|
|
33
72
|
|
|
34
|
-
|
|
73
|
+
# @return [Object] The connection settings that the pool was created with.
|
|
74
|
+
attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol, :connection_limit,
|
|
75
|
+
:tcp_keepalive, :tcp_user_timeout
|
|
35
76
|
|
|
36
|
-
#
|
|
77
|
+
# Returns or creates a client for the given endpoint.
|
|
37
78
|
#
|
|
38
|
-
# @param endpoint [Async::HTTP::Endpoint]
|
|
39
|
-
# @return [Async::HTTP::Client]
|
|
79
|
+
# @param endpoint [Async::HTTP::Endpoint] The target endpoint.
|
|
80
|
+
# @return [Async::HTTP::Client] The client for the endpoint's host.
|
|
40
81
|
def client_for(endpoint)
|
|
41
82
|
key = host_key(endpoint)
|
|
42
83
|
|
|
@@ -53,17 +94,20 @@ module PatientHttp
|
|
|
53
94
|
end
|
|
54
95
|
end
|
|
55
96
|
|
|
56
|
-
#
|
|
97
|
+
# Makes a request.
|
|
57
98
|
#
|
|
58
|
-
# @param http_method [String, Symbol] HTTP method
|
|
59
|
-
# @param url [String]
|
|
60
|
-
#
|
|
61
|
-
# @param
|
|
62
|
-
# @param
|
|
63
|
-
# @
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
# @param http_method [String, Symbol] HTTP method.
|
|
100
|
+
# @param url [String, Async::HTTP::Endpoint] Request URL, or the endpoint
|
|
101
|
+
# already parsed from it.
|
|
102
|
+
# @param headers [Hash] Request headers.
|
|
103
|
+
# @param body [String, nil] Request body.
|
|
104
|
+
# @param client [Async::HTTP::Client, nil] The pooled client to send through,
|
|
105
|
+
# normally the one {#client_for} returned for the URL; nil looks it up.
|
|
106
|
+
# @param block [Proc] Optional block to process the response.
|
|
107
|
+
# @return [Protocol::HTTP::Response] The response.
|
|
108
|
+
def request(http_method, url, headers, body, client: nil, &block)
|
|
109
|
+
endpoint = url.is_a?(Async::HTTP::Endpoint) ? url : Async::HTTP::Endpoint.parse(url)
|
|
110
|
+
client ||= client_for(endpoint)
|
|
67
111
|
|
|
68
112
|
verb = http_method.to_s.upcase
|
|
69
113
|
|
|
@@ -86,7 +130,10 @@ module PatientHttp
|
|
|
86
130
|
end
|
|
87
131
|
end
|
|
88
132
|
|
|
89
|
-
#
|
|
133
|
+
# Closes all clients and releases their resources.
|
|
134
|
+
#
|
|
135
|
+
# Clients evicted earlier whose close is still waiting for their in-flight
|
|
136
|
+
# requests are waited on as well, so no connection outlives the pool.
|
|
90
137
|
#
|
|
91
138
|
# @return [void]
|
|
92
139
|
def close
|
|
@@ -105,29 +152,39 @@ module PatientHttp
|
|
|
105
152
|
end
|
|
106
153
|
@proxy_client = nil
|
|
107
154
|
end
|
|
155
|
+
|
|
156
|
+
pending = @closing_mutex.synchronize { @closing_tasks.dup }
|
|
157
|
+
pending.each do |task|
|
|
158
|
+
task.wait
|
|
159
|
+
rescue StandardError, Async::Stop
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
108
162
|
end
|
|
109
163
|
|
|
110
|
-
#
|
|
164
|
+
# Evicts and closes the client for the host of a URL.
|
|
111
165
|
#
|
|
112
166
|
# This forces a new connection to be established on the next request to this host.
|
|
167
|
+
# When the client that failed is given, only that client is evicted: a
|
|
168
|
+
# replacement installed for the host after an earlier eviction is left alone,
|
|
169
|
+
# so a late failure on the old client cannot discard a healthy new one.
|
|
113
170
|
#
|
|
114
|
-
# @param url [String]
|
|
171
|
+
# @param url [String] The request URL whose host client should be evicted.
|
|
172
|
+
# @param client [Async::HTTP::Client, nil] The client that failed, or nil to
|
|
173
|
+
# evict whichever client the pool currently holds for the host.
|
|
115
174
|
# @return [void]
|
|
116
|
-
def evict(url)
|
|
175
|
+
def evict(url, client = nil)
|
|
117
176
|
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
118
177
|
key = host_key(endpoint)
|
|
119
178
|
|
|
120
|
-
@mutex.synchronize do
|
|
121
|
-
client
|
|
122
|
-
|
|
123
|
-
client&.close
|
|
124
|
-
rescue
|
|
125
|
-
nil
|
|
179
|
+
evicted = @mutex.synchronize do
|
|
180
|
+
if client.nil? || @clients[key].equal?(client)
|
|
181
|
+
@clients.delete(key)
|
|
126
182
|
end
|
|
127
183
|
end
|
|
184
|
+
close_later(evicted) if evicted
|
|
128
185
|
end
|
|
129
186
|
|
|
130
|
-
# @return [Integer]
|
|
187
|
+
# @return [Integer] Number of clients in the pool.
|
|
131
188
|
def size
|
|
132
189
|
@mutex.synchronize { @clients.size }
|
|
133
190
|
end
|
|
@@ -139,10 +196,29 @@ module PatientHttp
|
|
|
139
196
|
return unless lru_key
|
|
140
197
|
|
|
141
198
|
@clients.delete(lru_key)
|
|
142
|
-
|
|
143
|
-
|
|
199
|
+
close_later(lru_client)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Closing a client waits for its in-flight requests to finish before closing
|
|
203
|
+
# their connections. Evictions run on a request task while other requests are
|
|
204
|
+
# waiting to be dispatched, so the close runs in its own task and neither the
|
|
205
|
+
# evicting request nor the pool mutex waits for it. Outside a reactor the
|
|
206
|
+
# block runs inline.
|
|
207
|
+
#
|
|
208
|
+
# The task is transient so it does not hold the evicting request's task
|
|
209
|
+
# open, and it is tracked so {#close} can wait for it. The tracking list has
|
|
210
|
+
# its own mutex because evictions spawn the task while holding the pool mutex.
|
|
211
|
+
def close_later(client)
|
|
212
|
+
task = Async(transient: true) do |current|
|
|
213
|
+
client.close
|
|
144
214
|
rescue
|
|
145
215
|
nil
|
|
216
|
+
ensure
|
|
217
|
+
@closing_mutex.synchronize { @closing_tasks.delete(current) }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
unless task.finished?
|
|
221
|
+
@closing_mutex.synchronize { @closing_tasks << task }
|
|
146
222
|
end
|
|
147
223
|
end
|
|
148
224
|
|
|
@@ -158,12 +234,16 @@ module PatientHttp
|
|
|
158
234
|
# Response bodies are decoded by ResponseReader on a completion worker
|
|
159
235
|
# thread instead of a Protocol::HTTP::AcceptEncoding wrapper, so the
|
|
160
236
|
# reactor thread never pays for inflating compressed bodies.
|
|
237
|
+
#
|
|
238
|
+
# Each client makes a single attempt per request. Retries, bounded by the
|
|
239
|
+
# pool's retries setting, are applied by ImmediateRetries so that only
|
|
240
|
+
# one layer decides when a request is sent again.
|
|
161
241
|
@proxy_url ? make_proxied_client(endpoint) : make_direct_client(endpoint)
|
|
162
242
|
end
|
|
163
243
|
|
|
164
244
|
def make_direct_client(endpoint)
|
|
165
|
-
configured_endpoint = configure_endpoint(endpoint)
|
|
166
|
-
Async::HTTP::Client.new(configured_endpoint, retries:
|
|
245
|
+
configured_endpoint = connectable_endpoint(configure_endpoint(endpoint))
|
|
246
|
+
Async::HTTP::Client.new(configured_endpoint, retries: 1, **client_options)
|
|
167
247
|
end
|
|
168
248
|
|
|
169
249
|
def make_proxied_client(endpoint)
|
|
@@ -173,7 +253,8 @@ module PatientHttp
|
|
|
173
253
|
configured_endpoint = configure_endpoint(endpoint)
|
|
174
254
|
|
|
175
255
|
proxy = @proxy_client.proxy(configured_endpoint)
|
|
176
|
-
|
|
256
|
+
tunneled_endpoint = connectable_endpoint(proxy.wrap_endpoint(configured_endpoint))
|
|
257
|
+
Async::HTTP::Client.new(tunneled_endpoint, retries: 1, **client_options)
|
|
177
258
|
end
|
|
178
259
|
|
|
179
260
|
def client_options
|
|
@@ -182,15 +263,29 @@ module PatientHttp
|
|
|
182
263
|
|
|
183
264
|
def create_proxy_client
|
|
184
265
|
proxy_endpoint = Async::HTTP::Endpoint.parse(@proxy_url)
|
|
185
|
-
|
|
186
|
-
|
|
266
|
+
Async::HTTP::Client.new(connectable_endpoint(proxy_endpoint))
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The connection timeout is enforced by the wrapper around establishing the
|
|
270
|
+
# connection rather than passed to the endpoint, which would set it as an IO
|
|
271
|
+
# timeout on every read and write for the life of the connection. The
|
|
272
|
+
# wrapper also applies the TCP keepalive and user timeout settings to each
|
|
273
|
+
# new socket.
|
|
274
|
+
def connectable_endpoint(endpoint)
|
|
275
|
+
unless @connection_timeout || @tcp_keepalive || @tcp_user_timeout
|
|
276
|
+
return endpoint
|
|
187
277
|
end
|
|
188
|
-
|
|
278
|
+
|
|
279
|
+
ConnectionEndpoint.new(
|
|
280
|
+
endpoint,
|
|
281
|
+
connection_timeout: @connection_timeout,
|
|
282
|
+
tcp_keepalive: @tcp_keepalive,
|
|
283
|
+
tcp_user_timeout: @tcp_user_timeout
|
|
284
|
+
)
|
|
189
285
|
end
|
|
190
286
|
|
|
191
287
|
def configure_endpoint(endpoint)
|
|
192
288
|
options = {}
|
|
193
|
-
options[:timeout] = @connection_timeout if @connection_timeout
|
|
194
289
|
options[:protocol] = PROTOCOLS.fetch(@protocol) if @protocol
|
|
195
290
|
return endpoint if options.empty?
|
|
196
291
|
|