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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -7
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +539 -515
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +28 -22
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +367 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +35 -29
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +70 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +142 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +113 -84
  40. data/lib/patient_http/request_template.rb +87 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +66 -65
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +67 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +311 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.6.1
1
+ 1.7.0
@@ -1,15 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Container for callback arguments that are passed to completion and error callbacks.
4
+ # The callback arguments that a request passes to its `on_complete` and
5
+ # `on_error` callbacks.
5
6
  #
6
- # CallbackArgs provides a structured way to access arguments passed from the original
7
- # job to the callback workers. Arguments are stored with string keys internally
8
- # (for JSON serialization compatibility) but can be accessed using either strings
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 Basic usage
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 Nested hashes
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 From a response object
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
- # Reconstruct a CallbackArgs from a hash (used during deserialization).
31
+ # Creates callback arguments from their serialized form. The values aren't
32
+ # validated.
32
33
  #
33
- # @param hash [Hash, nil] hash with string keys
34
- # @return [CallbackArgs] reconstructed 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
- # Validate that a value is a JSON-native type (recursively for arrays and hashes).
40
+ # Validates that a value is a JSON-native type. Arrays and hashes are
41
+ # validated recursively.
40
42
  #
41
- # @param value [Object] the value to validate
42
- # @param path [String] the path to the value (for error messages)
43
- # @raise [ArgumentError] if the value is not a JSON-native type
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
- # Deep convert all hash keys to strings, including nested hashes and hashes in arrays.
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] the value to convert
69
- # @return [Object] the converted value with all hash keys as strings
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
- # Initialize a CallbackArgs with a hash.
86
+ # Creates callback arguments.
83
87
  #
84
- # @param args [Hash, nil] arguments to store (keys will be deeply converted to strings)
85
- # @param validate [Boolean] whether to validate values are JSON-native types
86
- # @raise [ArgumentError] if args is not nil and doesn't respond to to_h
87
- # @raise [ArgumentError] if any value is not a JSON-native type (when validate is true)
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
- # Access an argument by key.
110
+ # Returns the value for a key.
105
111
  #
106
- # @param key [String, Symbol] the key to access
107
- # @return [Object] the value
108
- # @raise [KeyError] if the key does not exist
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
- # Access an argument by key with an optional default.
124
+ # Returns the value for a key, or a default value if the key doesn't exist.
119
125
  #
120
- # @param key [String, Symbol] the key to access
121
- # @param default [Object] the default value to return if key doesn't exist
122
- # @return [Object] the value or default
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
- # Check if a key exists.
133
+ # Returns whether a key exists.
128
134
  #
129
- # @param key [String, Symbol] the key to check
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
- # Convert to a hash with symbol keys (shallow).
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
- # Only top-level keys are symbolized. Nested hash keys remain as strings.
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
- # Convert to hash with string keys for serialization.
149
+ # Returns the arguments as a JSON-compatible hash with string keys.
145
150
  #
146
- # @return [Hash] hash with string keys
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
- # Check if there are no arguments.
158
+ # Returns whether there are no arguments.
154
159
  #
155
- # @return [Boolean] true if empty
160
+ # @return [Boolean] `true` if there are no arguments.
156
161
  def empty?
157
162
  @data.empty?
158
163
  end
159
164
 
160
- # Return the number of arguments.
165
+ # Returns the number of arguments.
161
166
  #
162
- # @return [Integer] the count
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
- # Return the keys.
174
+ # Returns the top-level keys.
170
175
  #
171
- # @return [Array<String>] the keys
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
- # Validate that the callback class defines the required methods.
9
+ # Validates that the callback class defines the required methods.
7
10
  #
8
- # @param callback [Class, String] the callback class or its name
11
+ # @param callback [Class, String] The callback service class, or its name.
9
12
  # @return [void]
10
- # @raise [ArgumentError] if the callback class is invalid
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
- # Validate callback_args and convert to a hash with string keys.
22
+ # Validates the callback arguments and converts them to a hash with string keys.
19
23
  #
20
- # @param callback_args [#to_h, nil] the callback arguments
21
- # @return [Hash, nil] validated hash with string keys, or nil
22
- # @raise [ArgumentError] if callback_args is invalid
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
- # Helper module for class-related operations.
4
+ # Resolves class names to classes.
5
5
  #
6
- # Provides utilities for resolving class names to class objects,
7
- # which is useful for dynamic class loading.
6
+ # @api private
8
7
  module ClassHelper
9
8
  extend self
10
9
 
11
- # Resolve a class from its name class name to the class object.
10
+ # Returns the class for a class name.
12
11
  #
13
- # @param class_name [String] the fully qualified class name
14
- # @return [Class, nil] the class object or nil if no class_name given
15
- # @raise [NameError] if class cannot be found
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?
@@ -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.new(
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
- # Make an asynchronous HTTP request.
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] the request to make
26
- # @param request_id [String] unique request identifier
27
- # @return [Hash] the response data with keys for :status, :headers, and :body
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
- async_response = @client_pool.request(request.http_method, url, headers, body)
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 for this host to ensure the
53
- # stale connection is not reused for subsequent requests.
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(request.url)
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
- # Decode raw response data into deliverable response data.
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] raw response data from {#make_request}
72
- # @return [Hash] response data with the decoded body string
73
- # @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
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
- # Close all clients and release resources.
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
- # Pool of HTTP clients with LRU eviction.
4
+ # A pool of HTTP clients, one for each host.
5
5
  #
6
- # Maintains a pool of clients lazily instantiated for each host. The pool
7
- # is capped with an LRU algorithm - when a new client is needed and the
8
- # pool is at capacity, the least recently used client is closed and removed.
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
- def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil, connection_limit: nil)
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
- attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol, :connection_limit
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
- # Get or create a client for the given endpoint.
77
+ # Returns or creates a client for the given endpoint.
37
78
  #
38
- # @param endpoint [Async::HTTP::Endpoint] the target endpoint
39
- # @return [Async::HTTP::Client] the client for the endpoint's host
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
- # Make a request.
97
+ # Makes a request.
57
98
  #
58
- # @param http_method [String, Symbol] HTTP method
59
- # @param url [String] request URL
60
- # @param headers [Hash] request headers
61
- # @param body [String, nil] request body
62
- # @param block [Proc] optional block to process the response
63
- # @return [Protocol::HTTP::Response] the response
64
- def request(http_method, url, headers, body, &block)
65
- endpoint = Async::HTTP::Endpoint.parse(url)
66
- client = client_for(endpoint)
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
- # Close all clients and release resources.
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
- # Evict and close the client for the given URL.
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] the request URL whose host client should be evicted
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 = @clients.delete(key)
122
- begin
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] number of clients in the pool
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
- begin
143
- lru_client.close
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: @retries, **client_options)
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
- Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries, **client_options)
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
- if @connection_timeout
186
- proxy_endpoint = Async::HTTP::Endpoint.new(proxy_endpoint.url, timeout: @connection_timeout)
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
- Async::HTTP::Client.new(proxy_endpoint)
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