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.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +322 -0
- data/CHANGELOG.md +30 -0
- data/MIT-LICENSE +20 -0
- data/README.md +653 -0
- data/VERSION +1 -0
- data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
- data/lib/patient_http/callback_args.rb +176 -0
- data/lib/patient_http/callback_validator.rb +52 -0
- data/lib/patient_http/class_helper.rb +26 -0
- data/lib/patient_http/client.rb +80 -0
- data/lib/patient_http/client_pool.rb +178 -0
- data/lib/patient_http/configuration.rb +365 -0
- data/lib/patient_http/encryptor.rb +69 -0
- data/lib/patient_http/error.rb +76 -0
- data/lib/patient_http/external_storage.rb +134 -0
- data/lib/patient_http/http_error.rb +106 -0
- data/lib/patient_http/http_headers.rb +99 -0
- data/lib/patient_http/lifecycle_manager.rb +174 -0
- data/lib/patient_http/payload.rb +160 -0
- data/lib/patient_http/payload_store/active_record_store.rb +102 -0
- data/lib/patient_http/payload_store/base.rb +150 -0
- data/lib/patient_http/payload_store/file_store.rb +92 -0
- data/lib/patient_http/payload_store/redis_store.rb +98 -0
- data/lib/patient_http/payload_store/s3_store.rb +94 -0
- data/lib/patient_http/payload_store.rb +11 -0
- data/lib/patient_http/processor.rb +538 -0
- data/lib/patient_http/processor_observer.rb +48 -0
- data/lib/patient_http/rails/engine.rb +21 -0
- data/lib/patient_http/redirect_error.rb +136 -0
- data/lib/patient_http/redirect_helper.rb +90 -0
- data/lib/patient_http/request.rb +158 -0
- data/lib/patient_http/request_error.rb +150 -0
- data/lib/patient_http/request_helper.rb +230 -0
- data/lib/patient_http/request_task.rb +308 -0
- data/lib/patient_http/request_template.rb +114 -0
- data/lib/patient_http/response.rb +183 -0
- data/lib/patient_http/response_reader.rb +135 -0
- data/lib/patient_http/synchronous_executor.rb +241 -0
- data/lib/patient_http/task_handler.rb +55 -0
- data/lib/patient_http/time_helper.rb +32 -0
- data/lib/patient_http.rb +313 -0
- data/patient_http.gemspec +48 -0
- metadata +161 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Represents an HTTP response from an async request.
|
|
5
|
+
#
|
|
6
|
+
# This class encapsulates the response data including status, headers, body,
|
|
7
|
+
# and metadata about the request that generated it.
|
|
8
|
+
class Response
|
|
9
|
+
UNDEFINED = Object.new.freeze
|
|
10
|
+
private_constant :UNDEFINED
|
|
11
|
+
|
|
12
|
+
# @return [Integer] HTTP status code
|
|
13
|
+
attr_reader :status
|
|
14
|
+
|
|
15
|
+
# @return [HttpHeaders] response headers
|
|
16
|
+
attr_reader :headers
|
|
17
|
+
|
|
18
|
+
# @return [Float] request duration in seconds
|
|
19
|
+
attr_reader :duration
|
|
20
|
+
|
|
21
|
+
# @return [String] request ID
|
|
22
|
+
attr_reader :request_id
|
|
23
|
+
|
|
24
|
+
# @return [String] request URL
|
|
25
|
+
attr_reader :url
|
|
26
|
+
|
|
27
|
+
# @return [Symbol] HTTP method
|
|
28
|
+
attr_reader :http_method
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>] URLs visited during redirect chain (empty if no redirects)
|
|
31
|
+
attr_reader :redirects
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Reconstruct a Response from a hash
|
|
35
|
+
#
|
|
36
|
+
# @param hash [Hash] hash representation
|
|
37
|
+
# @return [Response] reconstructed response
|
|
38
|
+
def load(hash)
|
|
39
|
+
new(
|
|
40
|
+
status: hash["status"],
|
|
41
|
+
headers: hash["headers"],
|
|
42
|
+
body: Payload.load(hash["body"])&.value,
|
|
43
|
+
duration: hash["duration"],
|
|
44
|
+
request_id: hash["request_id"],
|
|
45
|
+
url: hash["url"],
|
|
46
|
+
http_method: hash["http_method"]&.to_sym,
|
|
47
|
+
callback_args: hash["callback_args"],
|
|
48
|
+
redirects: hash["redirects"]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Initialize a Response from an Async::HTTP::Response
|
|
54
|
+
#
|
|
55
|
+
# @param status [Integer] HTTP status code
|
|
56
|
+
# @param headers [Hash, HttpHeaders] response headers
|
|
57
|
+
# @param body [String, nil] response body
|
|
58
|
+
# @param duration [Float] request duration in seconds
|
|
59
|
+
# @param request_id [String] the request ID
|
|
60
|
+
# @param url [String] the request URL
|
|
61
|
+
# @param http_method [Symbol] the HTTP method
|
|
62
|
+
# @param callback_args [Hash, nil] callback arguments (string keys)
|
|
63
|
+
# @param redirects [Array<String>, nil] URLs visited during redirect chain
|
|
64
|
+
def initialize(status:, headers:, body:, duration:, request_id:, url:, http_method:, callback_args: nil, redirects: nil)
|
|
65
|
+
@status = status
|
|
66
|
+
@headers = HttpHeaders.new(headers)
|
|
67
|
+
|
|
68
|
+
encoding, encoded_body, charset = Payload.encode(body, @headers["content-type"])
|
|
69
|
+
@payload = Payload.new(encoding, encoded_body, charset) unless body.nil?
|
|
70
|
+
@body = UNDEFINED
|
|
71
|
+
|
|
72
|
+
@duration = duration
|
|
73
|
+
@request_id = request_id
|
|
74
|
+
@url = url
|
|
75
|
+
@http_method = http_method
|
|
76
|
+
@callback_args_data = callback_args || {}
|
|
77
|
+
@redirects = redirects || []
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns the callback arguments as a CallbackArgs object.
|
|
81
|
+
#
|
|
82
|
+
# @return [CallbackArgs] the callback arguments
|
|
83
|
+
def callback_args
|
|
84
|
+
@callback_args ||= CallbackArgs.load(@callback_args_data)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns the response body, decoding it from the payload if necessary.
|
|
88
|
+
#
|
|
89
|
+
# @return [String, nil] The decoded response body or nil if there was no body.
|
|
90
|
+
def body
|
|
91
|
+
@body = @payload&.value if @body.equal?(UNDEFINED)
|
|
92
|
+
@body
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if response is successful (2xx status)
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def success?
|
|
99
|
+
status >= 200 && status < 300
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Check if response is a redirect (3xx status)
|
|
103
|
+
#
|
|
104
|
+
# @return [Boolean]
|
|
105
|
+
def redirect?
|
|
106
|
+
status >= 300 && status < 400
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check if response is a client error (4xx status)
|
|
110
|
+
#
|
|
111
|
+
# @return [Boolean]
|
|
112
|
+
def client_error?
|
|
113
|
+
status >= 400 && status < 500
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if response is a server error (5xx status)
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def server_error?
|
|
120
|
+
status >= 500 && status < 600
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if response is any error (4xx or 5xx status)
|
|
124
|
+
#
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
def error?
|
|
127
|
+
status >= 400 && status < 600
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get the Content-Type header
|
|
131
|
+
#
|
|
132
|
+
# @return [String, nil]
|
|
133
|
+
def content_type
|
|
134
|
+
headers["content-type"]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Return true if Content-Type indicates JSON.
|
|
138
|
+
#
|
|
139
|
+
# @return [Boolean]
|
|
140
|
+
def json?
|
|
141
|
+
type = content_type.to_s.downcase
|
|
142
|
+
type.match?(%r{\Aapplication/[^ ]*json\b}) || type == "text/json"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Parse response body as JSON
|
|
146
|
+
#
|
|
147
|
+
# @return [Hash, Array] parsed JSON
|
|
148
|
+
# @raise [RuntimeError] if Content-Type is not application/json
|
|
149
|
+
# @raise [JSON::ParserError] if body is not valid JSON
|
|
150
|
+
def json
|
|
151
|
+
unless json?
|
|
152
|
+
raise "Response Content-Type is not application/json (got: #{content_type.inspect})"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
JSON.parse(body)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Serialize to JSON hash.
|
|
159
|
+
#
|
|
160
|
+
# @return [Hash]
|
|
161
|
+
def as_json
|
|
162
|
+
{
|
|
163
|
+
"status" => status,
|
|
164
|
+
"headers" => headers.to_h,
|
|
165
|
+
"body" => @payload&.as_json,
|
|
166
|
+
"duration" => duration,
|
|
167
|
+
"request_id" => request_id,
|
|
168
|
+
"url" => url,
|
|
169
|
+
"http_method" => http_method.to_s,
|
|
170
|
+
"callback_args" => @callback_args_data,
|
|
171
|
+
"redirects" => @redirects
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Serialize to JSON string.
|
|
176
|
+
#
|
|
177
|
+
# @param options [Hash] options to pass to JSON.generate (for ActiveSupport compatibility)
|
|
178
|
+
# @return [String] JSON representation
|
|
179
|
+
def to_json(options = nil)
|
|
180
|
+
JSON.generate(as_json, options)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Reads and validates HTTP response bodies.
|
|
5
|
+
#
|
|
6
|
+
# Encapsulates the logic for reading async HTTP responses with size validation
|
|
7
|
+
# and building Response objects from the raw response data.
|
|
8
|
+
class ResponseReader
|
|
9
|
+
# Initialize the reader.
|
|
10
|
+
#
|
|
11
|
+
# @param processor [Processor] the processor object
|
|
12
|
+
def initialize(processor)
|
|
13
|
+
@processor = processor
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Read the response body with size validation.
|
|
17
|
+
#
|
|
18
|
+
# Reads the async HTTP response body asynchronously to completion, which allows
|
|
19
|
+
# the connection to be reused. The async-http client handles connection pooling
|
|
20
|
+
# and keep-alive internally. Using iteration instead of read() ensures non-blocking
|
|
21
|
+
# I/O that yields to the reactor.
|
|
22
|
+
#
|
|
23
|
+
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
24
|
+
# @param headers_hash [Hash] the response headers
|
|
25
|
+
# @return [String, nil] the response body or nil if no body present
|
|
26
|
+
# @raise [ResponseTooLargeError] if body exceeds max_response_size
|
|
27
|
+
def read_body(async_response, headers_hash)
|
|
28
|
+
return nil unless async_response.body
|
|
29
|
+
|
|
30
|
+
validate_content_length(headers_hash)
|
|
31
|
+
body = read_body_chunks(async_response)
|
|
32
|
+
apply_charset_encoding(body, headers_hash)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def max_response_size
|
|
38
|
+
@processor.config.max_response_size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def logger
|
|
42
|
+
@processor.config.logger
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Validate content-length header doesn't exceed max size.
|
|
46
|
+
#
|
|
47
|
+
# @param headers_hash [Hash] the response headers
|
|
48
|
+
# @raise [ResponseTooLargeError] if content-length exceeds max_response_size
|
|
49
|
+
def validate_content_length(headers_hash)
|
|
50
|
+
content_length = headers_hash["content-length"]&.to_i
|
|
51
|
+
if content_length && content_length > max_response_size
|
|
52
|
+
raise ResponseTooLargeError.new(
|
|
53
|
+
"Response body size (#{content_length} bytes) exceeds maximum allowed size (#{max_response_size} bytes)"
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Read body chunks while checking size.
|
|
59
|
+
#
|
|
60
|
+
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
61
|
+
# @return [String, nil] the response body in ASCII-8BIT encoding, or nil if interrupted
|
|
62
|
+
# @raise [ResponseTooLargeError] if body size exceeds max_response_size during read
|
|
63
|
+
def read_body_chunks(async_response)
|
|
64
|
+
chunks = []
|
|
65
|
+
total_size = 0
|
|
66
|
+
finished = false
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
async_response.body.each do |chunk|
|
|
70
|
+
# Check if processor is stopping/stopped (early exit during shutdown)
|
|
71
|
+
if @processor.stopping? || @processor.stopped?
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
total_size += chunk.bytesize
|
|
76
|
+
|
|
77
|
+
if total_size > max_response_size
|
|
78
|
+
raise ResponseTooLargeError.new(
|
|
79
|
+
"Response body size exceeded maximum allowed size (#{max_response_size} bytes)"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
chunks << chunk
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
finished = true
|
|
87
|
+
|
|
88
|
+
# Join chunks and force to binary encoding to preserve raw bytes
|
|
89
|
+
chunks.join.force_encoding(Encoding::ASCII_8BIT)
|
|
90
|
+
ensure
|
|
91
|
+
# Always close the body if we were interrupted or if an error occurred
|
|
92
|
+
# This ensures the connection is properly released back to the pool
|
|
93
|
+
async_response.body.close unless finished
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Extract charset from Content-Type header.
|
|
98
|
+
#
|
|
99
|
+
# @param headers_hash [Hash] the response headers
|
|
100
|
+
# @return [String, nil] the charset name or nil if not specified
|
|
101
|
+
def extract_charset(headers_hash)
|
|
102
|
+
content_type = headers_hash["content-type"]
|
|
103
|
+
return nil unless content_type
|
|
104
|
+
|
|
105
|
+
match = content_type.match(/;\s*charset\s*=\s*([^;\s]+)/i)
|
|
106
|
+
return nil unless match
|
|
107
|
+
|
|
108
|
+
charset = match[1].strip
|
|
109
|
+
charset.gsub(/\A["']|["']\z/, "")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Apply charset encoding to response body.
|
|
113
|
+
#
|
|
114
|
+
# Sets the string encoding based on the charset specified in the Content-Type header.
|
|
115
|
+
# Falls back to ASCII-8BIT if charset is invalid or not recognized.
|
|
116
|
+
#
|
|
117
|
+
# @param body [String] the response body
|
|
118
|
+
# @param headers_hash [Hash] the response headers
|
|
119
|
+
# @return [String] the body with proper encoding set
|
|
120
|
+
def apply_charset_encoding(body, headers_hash)
|
|
121
|
+
return body unless body
|
|
122
|
+
|
|
123
|
+
charset = extract_charset(headers_hash)
|
|
124
|
+
return body unless charset
|
|
125
|
+
|
|
126
|
+
begin
|
|
127
|
+
encoding = Encoding.find(charset)
|
|
128
|
+
body.force_encoding(encoding)
|
|
129
|
+
rescue ArgumentError
|
|
130
|
+
logger&.warn("[PatientHttp] Unknown charset '#{charset}' in Content-Type header")
|
|
131
|
+
body
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Handles synchronous/inline execution of HTTP requests.
|
|
5
|
+
#
|
|
6
|
+
# Used for testing or when synchronous execution is needed.
|
|
7
|
+
# Accepts configuration and optional callback hooks so it has
|
|
8
|
+
# no dependency on any module-level singleton state.
|
|
9
|
+
class SynchronousExecutor
|
|
10
|
+
include RedirectHelper
|
|
11
|
+
|
|
12
|
+
# @param task [RequestTask] the request task to execute
|
|
13
|
+
# @param config [Configuration] the pool configuration
|
|
14
|
+
# @param on_complete [Proc, nil] hook called with response on success
|
|
15
|
+
# @param on_error [Proc, nil] hook called with error on failure
|
|
16
|
+
def initialize(task, config:, on_complete: nil, on_error: nil)
|
|
17
|
+
@task = task
|
|
18
|
+
@config = config
|
|
19
|
+
@on_complete = on_complete
|
|
20
|
+
@on_error = on_error
|
|
21
|
+
@proxy_client = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Execute the request synchronously.
|
|
25
|
+
# @return [void]
|
|
26
|
+
def call
|
|
27
|
+
Async do
|
|
28
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
http_client = nil
|
|
32
|
+
response_data = nil
|
|
33
|
+
|
|
34
|
+
loop do
|
|
35
|
+
http_client&.close
|
|
36
|
+
@proxy_client&.close
|
|
37
|
+
@proxy_client = nil
|
|
38
|
+
http_client = create_http_client
|
|
39
|
+
timeout = @task.request.timeout || @config.request_timeout
|
|
40
|
+
|
|
41
|
+
response_data = Async::Task.current.with_timeout(timeout) do
|
|
42
|
+
headers = @task.request.headers.to_h.merge("x-request-id" => @task.id)
|
|
43
|
+
headers["user-agent"] ||= @config.user_agent if @config.user_agent
|
|
44
|
+
body = Protocol::HTTP::Body::Buffered.wrap([@task.request.body.to_s]) if @task.request.body
|
|
45
|
+
|
|
46
|
+
endpoint = Async::HTTP::Endpoint.parse(@task.request.url)
|
|
47
|
+
endpoint = configure_endpoint(endpoint) if @config.connection_timeout
|
|
48
|
+
|
|
49
|
+
verb = @task.request.http_method.to_s.upcase
|
|
50
|
+
options = {
|
|
51
|
+
headers: headers,
|
|
52
|
+
body: body,
|
|
53
|
+
scheme: endpoint.scheme,
|
|
54
|
+
authority: endpoint.authority
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
request = Protocol::HTTP::Request[verb, endpoint.path, **options]
|
|
58
|
+
async_response = http_client.call(request)
|
|
59
|
+
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
60
|
+
|
|
61
|
+
body_content = read_response_body(async_response, headers_hash)
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
status: async_response.status,
|
|
65
|
+
headers: headers_hash,
|
|
66
|
+
body: body_content
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check for redirect
|
|
71
|
+
break unless should_follow_redirect?(@task, response_data)
|
|
72
|
+
|
|
73
|
+
redirect_error = check_redirect_error(@task, response_data)
|
|
74
|
+
if redirect_error
|
|
75
|
+
invoke_callback(redirect_error, :error)
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
location = response_data[:headers]["location"]
|
|
80
|
+
@task = @task.redirect_task(location: location, status: response_data[:status])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
84
|
+
duration = end_time - start_time
|
|
85
|
+
|
|
86
|
+
response = Response.new(
|
|
87
|
+
status: response_data[:status],
|
|
88
|
+
headers: response_data[:headers],
|
|
89
|
+
body: response_data[:body],
|
|
90
|
+
duration: duration,
|
|
91
|
+
request_id: @task.id,
|
|
92
|
+
url: @task.request.url,
|
|
93
|
+
http_method: @task.request.http_method,
|
|
94
|
+
callback_args: @task.callback_args,
|
|
95
|
+
redirects: @task.redirects
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if @task.raise_error_responses && !response.success?
|
|
99
|
+
http_error = HttpError.new(response)
|
|
100
|
+
invoke_callback(http_error, :error)
|
|
101
|
+
else
|
|
102
|
+
invoke_callback(response, :response)
|
|
103
|
+
end
|
|
104
|
+
rescue => e
|
|
105
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
|
+
duration = end_time - start_time
|
|
107
|
+
|
|
108
|
+
error = RequestError.from_exception(
|
|
109
|
+
e,
|
|
110
|
+
request_id: @task.id,
|
|
111
|
+
duration: duration,
|
|
112
|
+
url: @task.request.url,
|
|
113
|
+
http_method: @task.request.http_method,
|
|
114
|
+
callback_args: @task.callback_args
|
|
115
|
+
)
|
|
116
|
+
invoke_callback(error, :error)
|
|
117
|
+
ensure
|
|
118
|
+
http_client&.close
|
|
119
|
+
@proxy_client&.close
|
|
120
|
+
@proxy_client = nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
# Create HTTP client with config settings (retries, proxy, connection timeout).
|
|
128
|
+
#
|
|
129
|
+
# @return [Protocol::HTTP::AcceptEncoding] wrapped HTTP client
|
|
130
|
+
def create_http_client
|
|
131
|
+
endpoint = Async::HTTP::Endpoint.parse(@task.request.url)
|
|
132
|
+
endpoint = configure_endpoint(endpoint) if @config.connection_timeout
|
|
133
|
+
|
|
134
|
+
client = if @config.proxy_url
|
|
135
|
+
create_proxied_client(endpoint)
|
|
136
|
+
else
|
|
137
|
+
Async::HTTP::Client.new(endpoint, retries: @config.retries)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
Protocol::HTTP::AcceptEncoding.new(client)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Create a proxied HTTP client.
|
|
144
|
+
#
|
|
145
|
+
# @param endpoint [Async::HTTP::Endpoint] the target endpoint
|
|
146
|
+
# @return [Async::HTTP::Client] the proxied client
|
|
147
|
+
def create_proxied_client(endpoint)
|
|
148
|
+
require "async/http/proxy"
|
|
149
|
+
|
|
150
|
+
proxy_endpoint = Async::HTTP::Endpoint.parse(@config.proxy_url)
|
|
151
|
+
proxy_endpoint = configure_endpoint(proxy_endpoint) if @config.connection_timeout
|
|
152
|
+
@proxy_client = Async::HTTP::Client.new(proxy_endpoint)
|
|
153
|
+
|
|
154
|
+
proxy = @proxy_client.proxy(endpoint)
|
|
155
|
+
Async::HTTP::Client.new(proxy.wrap_endpoint(endpoint), retries: @config.retries)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Configure endpoint with connection timeout if specified.
|
|
159
|
+
#
|
|
160
|
+
# @param endpoint [Async::HTTP::Endpoint] the endpoint to configure
|
|
161
|
+
# @return [Async::HTTP::Endpoint] the configured endpoint
|
|
162
|
+
def configure_endpoint(endpoint)
|
|
163
|
+
Async::HTTP::Endpoint.new(
|
|
164
|
+
endpoint.url,
|
|
165
|
+
timeout: @config.connection_timeout
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Read the response body with size validation.
|
|
170
|
+
#
|
|
171
|
+
# @param async_response [Async::HTTP::Protocol::Response] the async HTTP response
|
|
172
|
+
# @param headers_hash [Hash] the response headers
|
|
173
|
+
# @return [String, nil] the response body
|
|
174
|
+
def read_response_body(async_response, headers_hash)
|
|
175
|
+
return nil unless async_response.body
|
|
176
|
+
|
|
177
|
+
content_length = headers_hash["content-length"]&.to_i
|
|
178
|
+
if content_length && content_length > @config.max_response_size
|
|
179
|
+
raise ResponseTooLargeError.new(
|
|
180
|
+
"Response body size (#{content_length} bytes) exceeds maximum allowed size (#{@config.max_response_size} bytes)"
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
chunks = []
|
|
185
|
+
total_size = 0
|
|
186
|
+
|
|
187
|
+
async_response.body.each do |chunk|
|
|
188
|
+
total_size += chunk.bytesize
|
|
189
|
+
if total_size > @config.max_response_size
|
|
190
|
+
raise ResponseTooLargeError.new(
|
|
191
|
+
"Response body size exceeded maximum allowed size (#{@config.max_response_size} bytes)"
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
chunks << chunk
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
body = chunks.join.force_encoding(Encoding::ASCII_8BIT)
|
|
198
|
+
|
|
199
|
+
charset = extract_charset(headers_hash)
|
|
200
|
+
if charset
|
|
201
|
+
begin
|
|
202
|
+
encoding = Encoding.find(charset)
|
|
203
|
+
body.force_encoding(encoding)
|
|
204
|
+
rescue ArgumentError
|
|
205
|
+
# Invalid charset, keep binary
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
body
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Extract charset from Content-Type header.
|
|
213
|
+
def extract_charset(headers_hash)
|
|
214
|
+
content_type = headers_hash["content-type"]
|
|
215
|
+
return nil unless content_type
|
|
216
|
+
|
|
217
|
+
match = content_type.match(/;\s*charset\s*=\s*([^;\s]+)/i)
|
|
218
|
+
return nil unless match
|
|
219
|
+
|
|
220
|
+
charset = match[1].strip
|
|
221
|
+
charset.gsub(/\A["']|["']\z/, "")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Invoke callback synchronously.
|
|
225
|
+
#
|
|
226
|
+
# @param result [Response, Error] the result to pass to callback
|
|
227
|
+
# @param type [Symbol] :response or :error
|
|
228
|
+
def invoke_callback(result, type)
|
|
229
|
+
callback_class = @task.callback.is_a?(Class) ? @task.callback : ClassHelper.resolve_class_name(@task.callback)
|
|
230
|
+
callback = callback_class.new
|
|
231
|
+
|
|
232
|
+
if type == :response
|
|
233
|
+
@on_complete&.call(result)
|
|
234
|
+
callback.on_complete(result)
|
|
235
|
+
else
|
|
236
|
+
@on_error&.call(result)
|
|
237
|
+
callback.on_error(result)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Abstract base class for handling task lifecycle operations.
|
|
5
|
+
#
|
|
6
|
+
# TaskHandler abstracts the job system integration, allowing RequestTask
|
|
7
|
+
# to work with any job system without direct dependencies. Implementations
|
|
8
|
+
# handle completion callbacks, error callbacks, and job retry operations.
|
|
9
|
+
#
|
|
10
|
+
# @abstract Subclass and implement all methods to create a concrete handler.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating a custom handler
|
|
13
|
+
# class MyTaskHandler < PatientHttp::TaskHandler
|
|
14
|
+
# def on_complete(response, callback)
|
|
15
|
+
# # Trigger completion callback
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def on_error(error, callback)
|
|
19
|
+
# # Trigger error callback
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# def retry
|
|
23
|
+
# # Re-enqueue the job
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
class TaskHandler
|
|
27
|
+
# Trigger the completion callback with the response.
|
|
28
|
+
#
|
|
29
|
+
# @param response [Response] the HTTP response object
|
|
30
|
+
# @param callback [String] callback class name
|
|
31
|
+
# @return [void]
|
|
32
|
+
def on_complete(response, callback)
|
|
33
|
+
raise NotImplementedError, "#{self.class}#on_complete must be implemented"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Trigger the error callback with the error.
|
|
37
|
+
#
|
|
38
|
+
# @param error [Error] the error object
|
|
39
|
+
# @param callback [String] callback class name
|
|
40
|
+
# @return [void]
|
|
41
|
+
def on_error(error, callback)
|
|
42
|
+
raise NotImplementedError, "#{self.class}#on_error must be implemented"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Re-enqueue the original job for retry.
|
|
46
|
+
#
|
|
47
|
+
# Called when a request cannot be completed (e.g., processor shutdown)
|
|
48
|
+
# and needs to be retried later.
|
|
49
|
+
#
|
|
50
|
+
# @return [String] the new job ID
|
|
51
|
+
def retry
|
|
52
|
+
raise NotImplementedError, "#{self.class}#retry must be implemented"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Helper module for time-related operations using monotonic and wall clock time.
|
|
5
|
+
#
|
|
6
|
+
# This module provides utilities for accurate timing measurements that are immune
|
|
7
|
+
# to system clock changes, as well as conversion between monotonic and wall clock time.
|
|
8
|
+
module TimeHelper
|
|
9
|
+
extend self
|
|
10
|
+
|
|
11
|
+
# Get the current monotonic time.
|
|
12
|
+
#
|
|
13
|
+
# Monotonic time is guaranteed to be non-decreasing and immune to system clock changes.
|
|
14
|
+
#
|
|
15
|
+
# @return [Float] current monotonic time in seconds since an unspecified starting point
|
|
16
|
+
def monotonic_time
|
|
17
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Convert a monotonic timestamp to wall clock time.
|
|
21
|
+
#
|
|
22
|
+
# @param monotonic_timestamp [Float] monotonic timestamp to convert
|
|
23
|
+
# @return [Time] wall clock time corresponding to the monotonic timestamp
|
|
24
|
+
def wall_clock_time(monotonic_timestamp)
|
|
25
|
+
return nil unless monotonic_timestamp
|
|
26
|
+
|
|
27
|
+
now = Time.now
|
|
28
|
+
elapsed = monotonic_time - monotonic_timestamp
|
|
29
|
+
now - elapsed
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|