patient_http 1.0.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +322 -0
  3. data/CHANGELOG.md +30 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +653 -0
  6. data/VERSION +1 -0
  7. data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
  8. data/lib/patient_http/callback_args.rb +176 -0
  9. data/lib/patient_http/callback_validator.rb +52 -0
  10. data/lib/patient_http/class_helper.rb +26 -0
  11. data/lib/patient_http/client.rb +80 -0
  12. data/lib/patient_http/client_pool.rb +178 -0
  13. data/lib/patient_http/configuration.rb +365 -0
  14. data/lib/patient_http/encryptor.rb +69 -0
  15. data/lib/patient_http/error.rb +76 -0
  16. data/lib/patient_http/external_storage.rb +134 -0
  17. data/lib/patient_http/http_error.rb +106 -0
  18. data/lib/patient_http/http_headers.rb +99 -0
  19. data/lib/patient_http/lifecycle_manager.rb +174 -0
  20. data/lib/patient_http/payload.rb +160 -0
  21. data/lib/patient_http/payload_store/active_record_store.rb +102 -0
  22. data/lib/patient_http/payload_store/base.rb +150 -0
  23. data/lib/patient_http/payload_store/file_store.rb +92 -0
  24. data/lib/patient_http/payload_store/redis_store.rb +98 -0
  25. data/lib/patient_http/payload_store/s3_store.rb +94 -0
  26. data/lib/patient_http/payload_store.rb +11 -0
  27. data/lib/patient_http/processor.rb +538 -0
  28. data/lib/patient_http/processor_observer.rb +48 -0
  29. data/lib/patient_http/rails/engine.rb +21 -0
  30. data/lib/patient_http/redirect_error.rb +136 -0
  31. data/lib/patient_http/redirect_helper.rb +90 -0
  32. data/lib/patient_http/request.rb +158 -0
  33. data/lib/patient_http/request_error.rb +150 -0
  34. data/lib/patient_http/request_helper.rb +230 -0
  35. data/lib/patient_http/request_task.rb +308 -0
  36. data/lib/patient_http/request_template.rb +114 -0
  37. data/lib/patient_http/response.rb +183 -0
  38. data/lib/patient_http/response_reader.rb +135 -0
  39. data/lib/patient_http/synchronous_executor.rb +241 -0
  40. data/lib/patient_http/task_handler.rb +55 -0
  41. data/lib/patient_http/time_helper.rb +32 -0
  42. data/lib/patient_http.rb +313 -0
  43. data/patient_http.gemspec +48 -0
  44. metadata +161 -0
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # Container for callback arguments that are passed to completion and error callbacks.
5
+ #
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.
11
+ #
12
+ # @example Basic usage
13
+ # args = CallbackArgs.new(user_id: 123, action: "fetch")
14
+ # args[:user_id] # => 123
15
+ # args["user_id"] # => 123
16
+ # args.fetch(:missing, "default") # => "default"
17
+ # args.include?(:user_id) # => true
18
+ # args.to_h # => {user_id: 123, action: "fetch"}
19
+ #
20
+ # @example Nested hashes
21
+ # args = CallbackArgs.new(metadata: {tags: ["a", "b"], level: 1})
22
+ # args[:metadata] # => {"tags" => ["a", "b"], "level" => 1}
23
+ #
24
+ # @example From a response object
25
+ # response.callback_args[:user_id]
26
+ class CallbackArgs
27
+ # JSON-native types that are allowed as values
28
+ ALLOWED_TYPES = [NilClass, TrueClass, FalseClass, String, Integer, Float].freeze
29
+
30
+ class << self
31
+ # Reconstruct a CallbackArgs from a hash (used during deserialization).
32
+ #
33
+ # @param hash [Hash, nil] hash with string keys
34
+ # @return [CallbackArgs] reconstructed CallbackArgs
35
+ def load(hash)
36
+ new(hash || {}, validate: false)
37
+ end
38
+
39
+ # Validate that a value is a JSON-native type (recursively for arrays and hashes).
40
+ #
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
44
+ # @return [void]
45
+ def validate_value!(value, path = "value")
46
+ case value
47
+ when *ALLOWED_TYPES
48
+ # Valid primitive type
49
+ when Array
50
+ value.each_with_index do |element, index|
51
+ validate_value!(element, "#{path}[#{index}]")
52
+ end
53
+ when Hash
54
+ value.each do |key, val|
55
+ unless key.is_a?(String) || key.is_a?(Symbol)
56
+ raise ArgumentError.new("#{path} hash key must be a String or Symbol, got #{key.class.name}")
57
+ end
58
+
59
+ validate_value!(val, "#{path}[#{key.inspect}]")
60
+ end
61
+ else
62
+ raise ArgumentError.new("#{path} must be a JSON-native type (nil, true, false, String, Integer, Float, Array, or Hash), got #{value.class.name}")
63
+ end
64
+ end
65
+
66
+ # Deep convert all hash keys to strings, including nested hashes and hashes in arrays.
67
+ #
68
+ # @param value [Object] the value to convert
69
+ # @return [Object] the converted value with all hash keys as strings
70
+ def deep_stringify_keys(value)
71
+ case value
72
+ when Hash
73
+ value.transform_keys(&:to_s).transform_values { |v| deep_stringify_keys(v) }
74
+ when Array
75
+ value.map { |element| deep_stringify_keys(element) }
76
+ else
77
+ value
78
+ end
79
+ end
80
+ end
81
+
82
+ # Initialize a CallbackArgs with a hash.
83
+ #
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
+ def initialize(args = nil, validate: true)
89
+ if args.nil?
90
+ @data = {}
91
+ elsif args.respond_to?(:to_h)
92
+ hash = args.to_h
93
+ if validate
94
+ hash.each do |key, value|
95
+ self.class.validate_value!(value, key.to_s)
96
+ end
97
+ end
98
+ @data = self.class.deep_stringify_keys(hash)
99
+ else
100
+ raise ArgumentError.new("callback_args must respond to to_h, got #{args.class.name}")
101
+ end
102
+ end
103
+
104
+ # Access an argument by key.
105
+ #
106
+ # @param key [String, Symbol] the key to access
107
+ # @return [Object] the value
108
+ # @raise [KeyError] if the key does not exist
109
+ def [](key)
110
+ string_key = key.to_s
111
+ unless @data.include?(string_key)
112
+ raise KeyError.new("key not found: #{key.inspect}. Available keys: #{@data.keys.join(", ")}")
113
+ end
114
+
115
+ @data[string_key]
116
+ end
117
+
118
+ # Access an argument by key with an optional default.
119
+ #
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
123
+ def fetch(key, default = nil)
124
+ @data.fetch(key.to_s, default)
125
+ end
126
+
127
+ # Check if a key exists.
128
+ #
129
+ # @param key [String, Symbol] the key to check
130
+ # @return [Boolean] true if the key exists
131
+ def include?(key)
132
+ @data.include?(key.to_s)
133
+ end
134
+
135
+ # Convert to a hash with symbol keys (shallow).
136
+ #
137
+ # Only top-level keys are symbolized. Nested hash keys remain as strings.
138
+ #
139
+ # @return [Hash] hash with symbol keys
140
+ def to_h
141
+ @data.transform_keys(&:to_sym)
142
+ end
143
+
144
+ # Convert to hash with string keys for serialization.
145
+ #
146
+ # @return [Hash] hash with string keys
147
+ def as_json
148
+ @data.dup
149
+ end
150
+
151
+ alias_method :dump, :as_json
152
+
153
+ # Check if there are no arguments.
154
+ #
155
+ # @return [Boolean] true if empty
156
+ def empty?
157
+ @data.empty?
158
+ end
159
+
160
+ # Return the number of arguments.
161
+ #
162
+ # @return [Integer] the count
163
+ def size
164
+ @data.size
165
+ end
166
+
167
+ alias_method :length, :size
168
+
169
+ # Return the keys.
170
+ #
171
+ # @return [Array<String>] the keys
172
+ def keys
173
+ @data.keys
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ module CallbackValidator
5
+ class << self
6
+ # Validate that the callback class defines the required methods.
7
+ #
8
+ # @param callback [Class, String] the callback class or its name
9
+ # @return [void]
10
+ # @raise [ArgumentError] if the callback class is invalid
11
+ def validate!(callback)
12
+ callback_class = callback.is_a?(Class) ? callback : ClassHelper.resolve_class_name(callback)
13
+
14
+ validate_callback_method!(callback_class, :on_complete)
15
+ validate_callback_method!(callback_class, :on_error)
16
+ end
17
+
18
+ # Validate callback_args and convert to a hash with string keys.
19
+ #
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
23
+ def validate_callback_args(callback_args)
24
+ return nil if callback_args.nil?
25
+
26
+ unless callback_args.respond_to?(:to_h)
27
+ raise ArgumentError.new("callback_args must respond to to_h, got #{callback_args.class.name}")
28
+ end
29
+
30
+ hash = callback_args.to_h
31
+ hash.each do |key, value|
32
+ CallbackArgs.validate_value!(value, key.to_s)
33
+ end
34
+ hash.transform_keys(&:to_s)
35
+ end
36
+
37
+ private
38
+
39
+ def validate_callback_method!(callback_class, method_name)
40
+ unless callback_class.method_defined?(method_name)
41
+ raise ArgumentError.new("callback class must define ##{method_name} instance method")
42
+ end
43
+
44
+ method = callback_class.instance_method(method_name)
45
+ # arity of 1 = exactly 1 required arg, -1 = any args (*args), -2 = 1 required + splat
46
+ unless method.arity == 1 || method.arity == -1 || method.arity == -2
47
+ raise ArgumentError.new("callback class ##{method_name} must accept exactly 1 positional argument")
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # Helper module for class-related operations.
5
+ #
6
+ # Provides utilities for resolving class names to class objects,
7
+ # which is useful for dynamic class loading.
8
+ module ClassHelper
9
+ extend self
10
+
11
+ # Resolve a class from its name class name to the class object.
12
+ #
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
16
+ def resolve_class_name(class_name)
17
+ return class_name if class_name.is_a?(Class)
18
+ return nil if class_name.nil? || class_name.empty?
19
+
20
+ hierarchy = class_name.split("::")
21
+ hierarchy.shift if hierarchy.first.to_s.empty? # strip leading :: for absolute names
22
+
23
+ hierarchy.reduce(Object) { |mod, name| mod.const_get(name) }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ class Client
5
+ def initialize(processor)
6
+ @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
+ )
13
+ @response_reader = ResponseReader.new(@processor)
14
+ end
15
+
16
+ # Make an asynchronous HTTP request.
17
+ #
18
+ # @param request [Request] the request to make
19
+ # @param request_id [String] unique request identifier
20
+ # @return [Hash] the response data with keys for :status, :headers, and :body
21
+ def make_request(request, request_id)
22
+ async_response = nil
23
+
24
+ begin
25
+ headers = request_headers(request, request_id)
26
+ body = Protocol::HTTP::Body::Buffered.wrap([request.body.to_s]) if request.body
27
+ timeout = request.timeout || config.request_timeout
28
+
29
+ Async::Task.current.with_timeout(timeout) do
30
+ async_response = @client_pool.request(request.http_method, request.url, headers, body)
31
+ headers_hash = async_response.headers.to_h.transform_values(&:to_s)
32
+ body = @response_reader.read_body(async_response, headers_hash)
33
+
34
+ {
35
+ status: async_response.status,
36
+ headers: headers_hash,
37
+ body: body
38
+ }
39
+ end
40
+ rescue => e
41
+ # Close the response and evict the client for this host to ensure the
42
+ # stale connection is not reused for subsequent requests.
43
+ async_response&.close
44
+ if connection_error?(e)
45
+ @client_pool.evict(request.url)
46
+ end
47
+ raise
48
+ end
49
+ end
50
+
51
+ # Close all clients and release resources.
52
+ #
53
+ # @return [void]
54
+ def close
55
+ @client_pool.close
56
+ end
57
+
58
+ private
59
+
60
+ def config
61
+ @processor.config
62
+ end
63
+
64
+ def connection_error?(exception)
65
+ case exception
66
+ when Async::TimeoutError, Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNREFUSED,
67
+ Errno::EHOSTUNREACH, SocketError, IOError
68
+ true
69
+ else
70
+ false
71
+ end
72
+ end
73
+
74
+ def request_headers(request, request_id)
75
+ headers = request.headers.to_h.merge("x-request-id" => request_id)
76
+ headers["user-agent"] ||= config.user_agent if config.user_agent
77
+ headers
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientHttp
4
+ # Pool of HTTP clients with LRU eviction.
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.
9
+ class ClientPool
10
+ def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3)
11
+ @clients = {}
12
+ @max_size = max_size
13
+ @connection_timeout = connection_timeout
14
+ @proxy_url = proxy_url
15
+ @retries = retries
16
+ @mutex = Mutex.new
17
+ @proxy_client = nil
18
+ end
19
+
20
+ attr_reader :max_size, :connection_timeout, :proxy_url, :retries
21
+
22
+ # Get or create a client for the given endpoint.
23
+ #
24
+ # @param endpoint [Async::HTTP::Endpoint] the target endpoint
25
+ # @return [Protocol::HTTP::AcceptEncoding] wrapped client
26
+ def client_for(endpoint)
27
+ key = host_key(endpoint)
28
+
29
+ @mutex.synchronize do
30
+ if @clients.key?(key)
31
+ # Move to end (most recently used) by re-inserting
32
+ client = @clients.delete(key)
33
+ @clients[key] = client
34
+ return client
35
+ end
36
+
37
+ evict_lru if @clients.size >= @max_size
38
+ @clients[key] = make_client(endpoint)
39
+ end
40
+ end
41
+
42
+ # Make a request.
43
+ #
44
+ # @param http_method [String, Symbol] HTTP method
45
+ # @param url [String] request URL
46
+ # @param headers [Hash] request headers
47
+ # @param body [String, nil] request body
48
+ # @param block [Proc] optional block to process the response
49
+ # @return [Protocol::HTTP::Response] the response
50
+ def request(http_method, url, headers, body, &block)
51
+ endpoint = Async::HTTP::Endpoint.parse(url)
52
+ client = client_for(endpoint)
53
+
54
+ verb = http_method.to_s.upcase
55
+
56
+ options = {
57
+ headers: headers,
58
+ body: body,
59
+ scheme: endpoint.scheme,
60
+ authority: endpoint.authority
61
+ }
62
+
63
+ request = ::Protocol::HTTP::Request[verb, endpoint.path, **options]
64
+ response = client.call(request)
65
+
66
+ return response unless block_given?
67
+
68
+ begin
69
+ yield response
70
+ ensure
71
+ response.close
72
+ end
73
+ end
74
+
75
+ # Close all clients and release resources.
76
+ #
77
+ # @return [void]
78
+ def close
79
+ @mutex.synchronize do
80
+ @clients.each_value do |client|
81
+ client.close
82
+ rescue
83
+ nil
84
+ end
85
+ @clients.clear
86
+
87
+ begin
88
+ @proxy_client&.close
89
+ rescue
90
+ nil
91
+ end
92
+ @proxy_client = nil
93
+ end
94
+ end
95
+
96
+ # Evict and close the client for the given URL.
97
+ #
98
+ # This forces a new connection to be established on the next request to this host.
99
+ #
100
+ # @param url [String] the request URL whose host client should be evicted
101
+ # @return [void]
102
+ def evict(url)
103
+ endpoint = Async::HTTP::Endpoint.parse(url)
104
+ key = host_key(endpoint)
105
+
106
+ @mutex.synchronize do
107
+ client = @clients.delete(key)
108
+ begin
109
+ client&.close
110
+ rescue
111
+ nil
112
+ end
113
+ end
114
+ end
115
+
116
+ # @return [Integer] number of clients in the pool
117
+ def size
118
+ @mutex.synchronize { @clients.size }
119
+ end
120
+
121
+ private
122
+
123
+ def evict_lru
124
+ lru_key, lru_client = @clients.first
125
+ return unless lru_key
126
+
127
+ @clients.delete(lru_key)
128
+ begin
129
+ lru_client.close
130
+ rescue
131
+ nil
132
+ end
133
+ end
134
+
135
+ def host_key(endpoint)
136
+ url = endpoint.url.dup
137
+ url.path = ""
138
+ url.fragment = nil
139
+ url.query = nil
140
+ url
141
+ end
142
+
143
+ def make_client(endpoint)
144
+ client = @proxy_url ? make_proxied_client(endpoint) : make_direct_client(endpoint)
145
+ ::Protocol::HTTP::AcceptEncoding.new(client)
146
+ end
147
+
148
+ def make_direct_client(endpoint)
149
+ configured_endpoint = configure_endpoint(endpoint)
150
+ Async::HTTP::Client.new(configured_endpoint, retries: @retries)
151
+ end
152
+
153
+ def make_proxied_client(endpoint)
154
+ require "async/http/proxy"
155
+
156
+ @proxy_client ||= create_proxy_client
157
+ configured_endpoint = configure_endpoint(endpoint)
158
+
159
+ proxy = @proxy_client.proxy(configured_endpoint)
160
+ Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries)
161
+ end
162
+
163
+ def create_proxy_client
164
+ proxy_endpoint = Async::HTTP::Endpoint.parse(@proxy_url)
165
+ proxy_endpoint = configure_endpoint(proxy_endpoint) if @connection_timeout
166
+ Async::HTTP::Client.new(proxy_endpoint)
167
+ end
168
+
169
+ def configure_endpoint(endpoint)
170
+ return endpoint unless @connection_timeout
171
+
172
+ Async::HTTP::Endpoint.new(
173
+ endpoint.url,
174
+ timeout: @connection_timeout
175
+ )
176
+ end
177
+ end
178
+ end