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,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Base class for redirect-related errors.
|
|
5
|
+
# These errors occur when redirect handling fails due to too many redirects
|
|
6
|
+
# or a redirect loop.
|
|
7
|
+
class RedirectError < Error
|
|
8
|
+
# @return [String] Request URL
|
|
9
|
+
attr_reader :url
|
|
10
|
+
|
|
11
|
+
# @return [Symbol] HTTP method
|
|
12
|
+
attr_reader :http_method
|
|
13
|
+
|
|
14
|
+
# @return [Float] Request duration in seconds
|
|
15
|
+
attr_reader :duration
|
|
16
|
+
|
|
17
|
+
# @return [String] Unique request identifier
|
|
18
|
+
attr_reader :request_id
|
|
19
|
+
|
|
20
|
+
# @return [Array<String>] URLs that were visited during redirect chain
|
|
21
|
+
attr_reader :redirects
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Reconstruct a RedirectError from a hash
|
|
25
|
+
#
|
|
26
|
+
# @param hash [Hash] hash representation
|
|
27
|
+
# @return [RedirectError] reconstructed error
|
|
28
|
+
def load(hash)
|
|
29
|
+
error_class = ClassHelper.resolve_class_name(hash["error_class"])
|
|
30
|
+
error_class.new(
|
|
31
|
+
url: hash["url"],
|
|
32
|
+
http_method: hash["http_method"]&.to_sym,
|
|
33
|
+
duration: hash["duration"],
|
|
34
|
+
request_id: hash["request_id"],
|
|
35
|
+
redirects: hash["redirects"] || [],
|
|
36
|
+
callback_args: hash["callback_args"]
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Initializes a new RedirectError.
|
|
42
|
+
#
|
|
43
|
+
# @param message [String] Error message
|
|
44
|
+
# @param url [String] Request URL
|
|
45
|
+
# @param http_method [Symbol, String] HTTP method
|
|
46
|
+
# @param duration [Float] Request duration in seconds
|
|
47
|
+
# @param request_id [String] Unique request identifier
|
|
48
|
+
# @param redirects [Array<String>] URLs visited during redirect chain
|
|
49
|
+
# @param callback_args [Hash, nil] callback arguments (string keys)
|
|
50
|
+
def initialize(message, url:, http_method:, duration:, request_id:, redirects:, callback_args: nil)
|
|
51
|
+
super(message)
|
|
52
|
+
@url = url
|
|
53
|
+
@http_method = http_method&.to_sym
|
|
54
|
+
@duration = duration
|
|
55
|
+
@request_id = request_id
|
|
56
|
+
@redirects = redirects || []
|
|
57
|
+
@callback_args_data = callback_args || {}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the error type symbol.
|
|
61
|
+
#
|
|
62
|
+
# @return [Symbol] the error type
|
|
63
|
+
def error_type
|
|
64
|
+
:redirect
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Class] the class of the exception. This is for compatibility with RequestError.
|
|
68
|
+
def error_class
|
|
69
|
+
self.class
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the callback arguments as a CallbackArgs object.
|
|
73
|
+
#
|
|
74
|
+
# @return [CallbackArgs] the callback arguments
|
|
75
|
+
def callback_args
|
|
76
|
+
@callback_args ||= CallbackArgs.load(@callback_args_data)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convert to hash with string keys for serialization
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] hash representation
|
|
82
|
+
def as_json
|
|
83
|
+
{
|
|
84
|
+
"error_class" => self.class.name,
|
|
85
|
+
"url" => url,
|
|
86
|
+
"http_method" => http_method.to_s,
|
|
87
|
+
"duration" => duration,
|
|
88
|
+
"request_id" => request_id,
|
|
89
|
+
"redirects" => redirects,
|
|
90
|
+
"callback_args" => @callback_args_data
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Error raised when too many redirects are encountered.
|
|
96
|
+
class TooManyRedirectsError < RedirectError
|
|
97
|
+
# @param url [String] The URL that would have been redirected to
|
|
98
|
+
# @param http_method [Symbol, String] HTTP method
|
|
99
|
+
# @param duration [Float] Request duration in seconds
|
|
100
|
+
# @param request_id [String] Unique request identifier
|
|
101
|
+
# @param redirects [Array<String>] URLs visited during redirect chain
|
|
102
|
+
# @param callback_args [Hash, nil] callback arguments (string keys)
|
|
103
|
+
def initialize(url:, http_method:, duration:, request_id:, redirects:, callback_args: nil)
|
|
104
|
+
super(
|
|
105
|
+
"Too many redirects (#{redirects.size}) while requesting #{http_method.to_s.upcase} #{redirects.first || url}",
|
|
106
|
+
url: url,
|
|
107
|
+
http_method: http_method,
|
|
108
|
+
duration: duration,
|
|
109
|
+
request_id: request_id,
|
|
110
|
+
redirects: redirects,
|
|
111
|
+
callback_args: callback_args
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Error raised when a recursive redirect is detected.
|
|
117
|
+
class RecursiveRedirectError < RedirectError
|
|
118
|
+
# @param url [String] The URL that caused the loop
|
|
119
|
+
# @param http_method [Symbol, String] HTTP method
|
|
120
|
+
# @param duration [Float] Request duration in seconds
|
|
121
|
+
# @param request_id [String] Unique request identifier
|
|
122
|
+
# @param redirects [Array<String>] URLs visited during redirect chain
|
|
123
|
+
# @param callback_args [Hash, nil] callback arguments (string keys)
|
|
124
|
+
def initialize(url:, http_method:, duration:, request_id:, redirects:, callback_args: nil)
|
|
125
|
+
super(
|
|
126
|
+
"Recursive redirect detected: #{url} was already visited in redirect chain",
|
|
127
|
+
url: url,
|
|
128
|
+
http_method: http_method,
|
|
129
|
+
duration: duration,
|
|
130
|
+
request_id: request_id,
|
|
131
|
+
redirects: redirects,
|
|
132
|
+
callback_args: callback_args
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Shared redirect-checking logic used by both the async Processor
|
|
5
|
+
# and the SynchronousExecutor.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module RedirectHelper
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# Check if a redirect response should be followed.
|
|
12
|
+
#
|
|
13
|
+
# @param task [RequestTask] the request task
|
|
14
|
+
# @param response_data [Hash] the response data with status, headers, body
|
|
15
|
+
# @return [Boolean] true if the redirect should be followed
|
|
16
|
+
def should_follow_redirect?(task, response_data)
|
|
17
|
+
status = response_data[:status]
|
|
18
|
+
return false unless FOLLOWABLE_REDIRECT_STATUSES.include?(status)
|
|
19
|
+
return false if task.max_redirects == 0
|
|
20
|
+
|
|
21
|
+
location = response_data[:headers]["location"]
|
|
22
|
+
return false if location.nil? || location.empty?
|
|
23
|
+
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check for either too-many-redirects or recursive redirect.
|
|
28
|
+
#
|
|
29
|
+
# @param task [RequestTask] the request task
|
|
30
|
+
# @param response_data [Hash] the response data with status, headers, body
|
|
31
|
+
# @return [RedirectError, nil] error if redirect should not proceed, nil otherwise
|
|
32
|
+
def check_redirect_error(task, response_data)
|
|
33
|
+
location = response_data[:headers]["location"]
|
|
34
|
+
redirect_url = resolve_redirect_url(task.request.url, location)
|
|
35
|
+
|
|
36
|
+
check_too_many_redirects(task, location) || check_recursive_redirect(task, redirect_url)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Check if the redirect count has exceeded the maximum.
|
|
40
|
+
#
|
|
41
|
+
# @param task [RequestTask] the request task
|
|
42
|
+
# @param location [String] the redirect location URL
|
|
43
|
+
# @return [TooManyRedirectsError, nil] error if exceeded, nil otherwise
|
|
44
|
+
def check_too_many_redirects(task, location)
|
|
45
|
+
return nil if task.redirects.size < task.max_redirects
|
|
46
|
+
|
|
47
|
+
TooManyRedirectsError.new(
|
|
48
|
+
url: location,
|
|
49
|
+
http_method: task.request.http_method,
|
|
50
|
+
duration: task.duration,
|
|
51
|
+
request_id: task.id,
|
|
52
|
+
redirects: task.redirects + [task.request.url],
|
|
53
|
+
callback_args: task.callback_args
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if the redirect URL has already been visited (redirect loop).
|
|
58
|
+
#
|
|
59
|
+
# @param task [RequestTask] the request task
|
|
60
|
+
# @param redirect_url [String] the resolved redirect URL
|
|
61
|
+
# @return [RecursiveRedirectError, nil] error if loop detected, nil otherwise
|
|
62
|
+
def check_recursive_redirect(task, redirect_url)
|
|
63
|
+
visited_urls = task.redirects + [task.request.url]
|
|
64
|
+
return nil unless visited_urls.include?(redirect_url)
|
|
65
|
+
|
|
66
|
+
RecursiveRedirectError.new(
|
|
67
|
+
url: redirect_url,
|
|
68
|
+
http_method: task.request.http_method,
|
|
69
|
+
duration: task.duration,
|
|
70
|
+
request_id: task.id,
|
|
71
|
+
redirects: visited_urls,
|
|
72
|
+
callback_args: task.callback_args
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Resolve a redirect URL, handling relative URLs.
|
|
77
|
+
#
|
|
78
|
+
# @param base_url [String] The base URL
|
|
79
|
+
# @param location [String] The Location header value
|
|
80
|
+
# @return [String] The resolved absolute URL
|
|
81
|
+
def resolve_redirect_url(base_url, location)
|
|
82
|
+
base_uri = URI.parse(base_url)
|
|
83
|
+
redirect_uri = URI.parse(location)
|
|
84
|
+
|
|
85
|
+
return location if redirect_uri.absolute?
|
|
86
|
+
|
|
87
|
+
base_uri.merge(redirect_uri).to_s
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Represents an async HTTP request that will be processed by the async processor.
|
|
5
|
+
#
|
|
6
|
+
# @example Creating a request
|
|
7
|
+
# request = PatientHttp::Request.new(:get, "https://api.example.com/users/123")
|
|
8
|
+
#
|
|
9
|
+
# @example Creating a POST request with JSON body
|
|
10
|
+
# request = PatientHttp::Request.new(
|
|
11
|
+
# :post,
|
|
12
|
+
# "https://api.example.com/users",
|
|
13
|
+
# json: {name: "John", email: "john@example.com"}
|
|
14
|
+
# )
|
|
15
|
+
class Request
|
|
16
|
+
UNDEFINED = Object.new.freeze
|
|
17
|
+
private_constant :UNDEFINED
|
|
18
|
+
|
|
19
|
+
# Valid HTTP methods
|
|
20
|
+
VALID_METHODS = %i[get post put patch delete].freeze
|
|
21
|
+
|
|
22
|
+
# @return [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
|
|
23
|
+
attr_reader :http_method
|
|
24
|
+
|
|
25
|
+
# @return [String] The request URL
|
|
26
|
+
attr_reader :url
|
|
27
|
+
|
|
28
|
+
# @return [HttpHeaders] Request headers
|
|
29
|
+
attr_reader :headers
|
|
30
|
+
|
|
31
|
+
# @return [Numeric, nil] Overall timeout in seconds
|
|
32
|
+
attr_reader :timeout
|
|
33
|
+
|
|
34
|
+
# @return [Integer, nil] Maximum number of redirects to follow (nil uses config default, 0 disables)
|
|
35
|
+
attr_reader :max_redirects
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Reconstruct a Request from a hash
|
|
39
|
+
#
|
|
40
|
+
# @param hash [Hash] hash representation
|
|
41
|
+
# @return [Request] reconstructed request
|
|
42
|
+
def load(hash)
|
|
43
|
+
new(
|
|
44
|
+
hash["http_method"].to_sym,
|
|
45
|
+
hash["url"],
|
|
46
|
+
headers: hash["headers"],
|
|
47
|
+
body: Payload.load(hash["body"])&.value,
|
|
48
|
+
timeout: hash["timeout"],
|
|
49
|
+
max_redirects: hash["max_redirects"]
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Initializes a new Request.
|
|
55
|
+
#
|
|
56
|
+
# @param http_method [Symbol, String] HTTP method (:get, :post, :put, :patch, :delete).
|
|
57
|
+
# @param url [String, URI::Generic] The request URL.
|
|
58
|
+
# @param headers [Hash, HttpHeaders] Request headers.
|
|
59
|
+
# @param body [String, nil] Request body.
|
|
60
|
+
# @param json [Object, nil] JSON body to be serialized (alternative to body).
|
|
61
|
+
# @param params [Hash, nil] Query parameters to append to the URL.
|
|
62
|
+
# @param timeout [Numeric, nil] Overall timeout in seconds.
|
|
63
|
+
# @param max_redirects [Integer, nil] Maximum redirects to follow (nil uses config, 0 disables).
|
|
64
|
+
def initialize(
|
|
65
|
+
http_method,
|
|
66
|
+
url,
|
|
67
|
+
headers: {},
|
|
68
|
+
body: nil,
|
|
69
|
+
json: nil,
|
|
70
|
+
params: nil,
|
|
71
|
+
timeout: nil,
|
|
72
|
+
max_redirects: nil
|
|
73
|
+
)
|
|
74
|
+
@http_method = http_method.is_a?(String) ? http_method.downcase.to_sym : http_method
|
|
75
|
+
|
|
76
|
+
unless url.nil? || url.is_a?(String) || url.is_a?(URI::Generic)
|
|
77
|
+
raise ArgumentError.new("url must be a String or URI, got: #{url.class}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
@url = normalized_url(url, params)
|
|
81
|
+
@headers = headers.is_a?(HttpHeaders) ? headers : HttpHeaders.new(headers)
|
|
82
|
+
@body = (body == "") ? nil : body
|
|
83
|
+
@timeout = timeout
|
|
84
|
+
@max_redirects = max_redirects
|
|
85
|
+
|
|
86
|
+
if json
|
|
87
|
+
raise ArgumentError.new("Cannot provide both body and json") if @body
|
|
88
|
+
|
|
89
|
+
@body = JSON.generate(json)
|
|
90
|
+
@headers["content-type"] ||= "application/json; charset=utf-8"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
validate!
|
|
94
|
+
|
|
95
|
+
encoding, encoded_body, charset = Payload.encode(@body, @headers["content-type"])
|
|
96
|
+
@payload = Payload.new(encoding, encoded_body, charset) unless @body.nil?
|
|
97
|
+
@body = UNDEFINED
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the request body, decoding it from the payload if necessary.
|
|
101
|
+
#
|
|
102
|
+
# @return [String, nil] The decoded request body or nil if there was no body.
|
|
103
|
+
def body
|
|
104
|
+
@body = @payload&.value if @body.equal?(UNDEFINED)
|
|
105
|
+
@body
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Serialize to JSON hash.
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash]
|
|
111
|
+
def as_json
|
|
112
|
+
{
|
|
113
|
+
"http_method" => @http_method.to_s,
|
|
114
|
+
"url" => @url.to_s,
|
|
115
|
+
"headers" => @headers.to_h,
|
|
116
|
+
"body" => @payload&.as_json,
|
|
117
|
+
"timeout" => @timeout,
|
|
118
|
+
"max_redirects" => @max_redirects
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def normalized_url(url, params)
|
|
125
|
+
uri = url.is_a?(URI::Generic) ? url.dup : URI(url.to_s)
|
|
126
|
+
return uri.to_s unless params&.any?
|
|
127
|
+
|
|
128
|
+
serialized_params = URI.encode_www_form(params)
|
|
129
|
+
uri.query = [uri.query, serialized_params].compact.reject(&:empty?).join("&")
|
|
130
|
+
uri.to_s
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Validate the request has required HTTP parameters.
|
|
134
|
+
# @raise [ArgumentError] if method or url is invalid
|
|
135
|
+
# @return [self] for chaining
|
|
136
|
+
def validate!
|
|
137
|
+
unless VALID_METHODS.include?(@http_method)
|
|
138
|
+
raise ArgumentError.new("method must be one of #{VALID_METHODS.inspect}, got: #{@http_method.inspect}")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
raise ArgumentError.new("url is required") if @url.nil? || (@url.is_a?(String) && @url.empty?)
|
|
142
|
+
|
|
143
|
+
unless @url.is_a?(String) || @url.is_a?(URI::Generic)
|
|
144
|
+
raise ArgumentError.new("url must be a String or URI, got: #{@url.class}")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if %i[get delete].include?(@http_method) && !@body.nil?
|
|
148
|
+
raise ArgumentError.new("body is not allowed for #{@http_method.upcase} requests")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
if @body && !@body.is_a?(String)
|
|
152
|
+
raise ArgumentError.new("body must be a String, got: #{@body.class}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
self
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Error object representing an exception from making an HTTP request. Note that this
|
|
5
|
+
# is not for HTTP error responses (4xx/5xx), but from actual exceptions raised
|
|
6
|
+
# during the request (timeouts, connection errors, SSL errors, etc).
|
|
7
|
+
#
|
|
8
|
+
# This is how errors are passed back to the error continuation jobs for processing.
|
|
9
|
+
class RequestError < Error
|
|
10
|
+
# Valid error types
|
|
11
|
+
ERROR_TYPES = [:timeout, :connection, :ssl, :response_too_large, :unknown].freeze
|
|
12
|
+
|
|
13
|
+
# @return [String] Request URL
|
|
14
|
+
attr_reader :url
|
|
15
|
+
|
|
16
|
+
# @return [Symbol] HTTP method
|
|
17
|
+
attr_reader :http_method
|
|
18
|
+
|
|
19
|
+
# @return [Float] Request duration in seconds
|
|
20
|
+
attr_reader :duration
|
|
21
|
+
|
|
22
|
+
# @return [String] Unique request identifier
|
|
23
|
+
attr_reader :request_id
|
|
24
|
+
|
|
25
|
+
# @return [Symbol] Categorized error type. This provides a higher level categorization
|
|
26
|
+
# of the error (e.g., :connection is used to group IO and socket errors).
|
|
27
|
+
attr_reader :error_type
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Reconstruct a RequestError from a hash
|
|
31
|
+
#
|
|
32
|
+
# @param hash [Hash] hash representation
|
|
33
|
+
# @return [RequestError] reconstructed error
|
|
34
|
+
def load(hash)
|
|
35
|
+
new(
|
|
36
|
+
class_name: hash["class_name"],
|
|
37
|
+
message: hash["message"],
|
|
38
|
+
backtrace: hash["backtrace"],
|
|
39
|
+
request_id: hash["request_id"],
|
|
40
|
+
error_type: hash["error_type"]&.to_sym,
|
|
41
|
+
duration: hash["duration"],
|
|
42
|
+
url: hash["url"],
|
|
43
|
+
http_method: hash["http_method"],
|
|
44
|
+
callback_args: hash["callback_args"]
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Create a RequestError from an exception using pattern matching
|
|
49
|
+
#
|
|
50
|
+
# @param exception [Exception] the exception to convert
|
|
51
|
+
# @param duration [Float] request duration in seconds
|
|
52
|
+
# @param request_id [String] the request ID
|
|
53
|
+
# @param url [String] the request URL
|
|
54
|
+
# @param http_method [Symbol, String] the HTTP method
|
|
55
|
+
# @param callback_args [Hash, nil] callback arguments (string keys)
|
|
56
|
+
# @return [RequestError] the error object
|
|
57
|
+
def from_exception(exception, duration:, request_id:, url:, http_method:, callback_args: nil)
|
|
58
|
+
type = error_type(exception)
|
|
59
|
+
|
|
60
|
+
new(
|
|
61
|
+
class_name: exception.class.name,
|
|
62
|
+
message: exception.message,
|
|
63
|
+
backtrace: exception.backtrace || [],
|
|
64
|
+
request_id: request_id,
|
|
65
|
+
error_type: type,
|
|
66
|
+
duration: duration,
|
|
67
|
+
url: url,
|
|
68
|
+
http_method: http_method,
|
|
69
|
+
callback_args: callback_args
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Determine error type from exception.
|
|
74
|
+
#
|
|
75
|
+
# @param exception [Exception] the exception to categorize
|
|
76
|
+
# @return [Symbol] the error type
|
|
77
|
+
def error_type(exception)
|
|
78
|
+
case exception
|
|
79
|
+
in Async::TimeoutError
|
|
80
|
+
:timeout
|
|
81
|
+
in OpenSSL::SSL::SSLError
|
|
82
|
+
:ssl
|
|
83
|
+
in Errno::ECONNREFUSED | Errno::ECONNRESET | Errno::EHOSTUNREACH | Errno::EPIPE | SocketError | IOError
|
|
84
|
+
:connection
|
|
85
|
+
else
|
|
86
|
+
if exception.is_a?(PatientHttp::ResponseTooLargeError)
|
|
87
|
+
:response_too_large
|
|
88
|
+
else
|
|
89
|
+
:unknown
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Initializes a new RequestError.
|
|
96
|
+
#
|
|
97
|
+
# @param class_name [String] Name of the exception class
|
|
98
|
+
# @param message [String] Exception message
|
|
99
|
+
# @param backtrace [Array<String>] Exception backtrace
|
|
100
|
+
# @param error_type [Symbol] Categorized error type
|
|
101
|
+
# @param duration [Float] Request duration in seconds
|
|
102
|
+
# @param request_id [String] Unique request identifier
|
|
103
|
+
# @param url [String] Request URL
|
|
104
|
+
# @param http_method [Symbol, String] HTTP method
|
|
105
|
+
# @param callback_args [Hash, nil] callback arguments (string keys)
|
|
106
|
+
def initialize(class_name:, message:, backtrace:, error_type:, duration:, request_id:, url:, http_method:,
|
|
107
|
+
callback_args: nil)
|
|
108
|
+
super(message)
|
|
109
|
+
set_backtrace(backtrace)
|
|
110
|
+
@class_name = class_name
|
|
111
|
+
@error_type = error_type
|
|
112
|
+
@duration = duration
|
|
113
|
+
@request_id = request_id
|
|
114
|
+
@url = url
|
|
115
|
+
@http_method = http_method&.to_sym
|
|
116
|
+
@callback_args_data = callback_args || {}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Convert to hash with string keys for serialization
|
|
120
|
+
#
|
|
121
|
+
# @return [Hash] hash representation
|
|
122
|
+
def as_json
|
|
123
|
+
{
|
|
124
|
+
"class_name" => @class_name,
|
|
125
|
+
"message" => message,
|
|
126
|
+
"backtrace" => backtrace,
|
|
127
|
+
"request_id" => request_id,
|
|
128
|
+
"error_type" => error_type.to_s,
|
|
129
|
+
"duration" => duration,
|
|
130
|
+
"url" => url,
|
|
131
|
+
"http_method" => http_method.to_s,
|
|
132
|
+
"callback_args" => @callback_args_data
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get the actual Exception class constant from the class_name
|
|
137
|
+
#
|
|
138
|
+
# @return [Class, nil] the exception class or nil if not found
|
|
139
|
+
def error_class
|
|
140
|
+
ClassHelper.resolve_class_name(@class_name)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Returns the callback arguments as a CallbackArgs object.
|
|
144
|
+
#
|
|
145
|
+
# @return [CallbackArgs] the callback arguments
|
|
146
|
+
def callback_args
|
|
147
|
+
@callback_args ||= CallbackArgs.load(@callback_args_data)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|