patient_http 1.6.1 → 1.8.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 +18 -8
  3. data/CHANGELOG.md +43 -0
  4. data/README.md +564 -508
  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 +53 -26
  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 +405 -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 +117 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +178 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +115 -84
  40. data/lib/patient_http/request_template.rb +119 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +166 -118
  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 +84 -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 +327 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.6.1
1
+ 1.8.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,40 @@
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
+ # The request timeout limits the full exchange. The read timeout, if set,
27
+ # limits the wait for the response headers and the wait for each body
28
+ # chunk.
29
+ #
30
+ # @param request [Request] The request to make.
31
+ # @param request_id [String] Unique request identifier.
32
+ # @return [Hash] The response data with keys for :status, :headers, :body,
33
+ # and :max_response_size. The size limit goes with the data so that
34
+ # {#decode_response} applies the same limit to the inflated body.
28
35
  def make_request(request, request_id)
29
36
  async_response = nil
37
+ client = nil
30
38
 
31
39
  begin
32
40
  outgoing = @request_preparer.prepare(request, request_id)
@@ -34,32 +42,46 @@ module PatientHttp
34
42
  headers = outgoing.headers.to_h
35
43
  body = Protocol::HTTP::Body::Buffered.wrap([request.body.to_s]) if request.body
36
44
  timeout = request.timeout || config.request_timeout
45
+ read_timeout = request.read_timeout || config.read_timeout
46
+ max_response_size = request.max_response_size || config.max_response_size
37
47
 
38
48
  Async::Task.current.with_timeout(timeout) do
39
- async_response = @client_pool.request(request.http_method, url, headers, body)
49
+ endpoint = Async::HTTP::Endpoint.parse(url)
50
+ async_response = ResponseReader.with_read_timeout(read_timeout) do
51
+ request_with_immediate_retries(
52
+ @client_pool, request, endpoint, headers, body
53
+ ) { |pooled_client| client = pooled_client }
54
+ end
40
55
  # Note: headers that appear multiple times (e.g. set-cookie) are
41
56
  # flattened to a single joined string value.
42
57
  headers_hash = async_response.headers.to_h.transform_values(&:to_s)
43
- body = @response_reader.read_raw_body(async_response, headers_hash)
58
+ body = @response_reader.read_raw_body(
59
+ async_response,
60
+ headers_hash,
61
+ max_response_size: max_response_size,
62
+ read_timeout: read_timeout
63
+ )
44
64
 
45
65
  {
46
66
  status: async_response.status,
47
67
  headers: headers_hash,
48
- body: body
68
+ body: body,
69
+ max_response_size: max_response_size
49
70
  }
50
71
  end
51
72
  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.
73
+ # Close the response and evict the client that failed so its stale
74
+ # connections are not reused. Evicting by identity leaves a replacement
75
+ # client for the host alone.
54
76
  async_response&.close
55
- if connection_error?(e)
56
- @client_pool.evict(request.url)
77
+ if client && connection_error?(e)
78
+ @client_pool.evict(url, client)
57
79
  end
58
80
  raise
59
81
  end
60
82
  end
61
83
 
62
- # Decode raw response data into deliverable response data.
84
+ # Decodes raw response data into deliverable response data.
63
85
  #
64
86
  # Joins and inflates the raw body chunks, applies the charset, and rewrites
65
87
  # the content-encoding header to name only the encodings still applied to
@@ -68,18 +90,23 @@ module PatientHttp
68
90
  # response always describes the body it carries. This is CPU-bound work
69
91
  # intended to run on a completion worker thread.
70
92
  #
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
93
+ # @param response_data [Hash] Raw response data from {#make_request}.
94
+ # @return [Hash] Response data with the :status, :headers, and decoded
95
+ # :body keys.
96
+ # @raise [ResponseTooLargeError] If the inflated body exceeds max_response_size.
74
97
  def decode_response(response_data)
75
98
  headers = response_data[:headers]
76
- body = @response_reader.decode_body(response_data[:body], headers)
99
+ body = @response_reader.decode_body(
100
+ response_data[:body],
101
+ headers,
102
+ max_response_size: response_data[:max_response_size]
103
+ )
77
104
  headers = ResponseReader.rewrite_content_encoding(headers)
78
105
 
79
- response_data.merge(headers: headers, body: body)
106
+ response_data.except(:max_response_size).merge(headers: headers, body: body)
80
107
  end
81
108
 
82
- # Close all clients and release resources.
109
+ # Closes all clients and releases their resources.
83
110
  #
84
111
  # @return [void]
85
112
  def close