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,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