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