patient_http 1.6.1 → 1.7.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -7
  3. data/CHANGELOG.md +29 -0
  4. data/README.md +539 -515
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +28 -22
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +367 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +35 -29
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +70 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +142 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +113 -84
  40. data/lib/patient_http/request_template.rb +87 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +66 -65
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +67 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +311 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
@@ -1,17 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # A reference to a named secret that can be used as a header or query parameter
5
- # value when building a {Request}.
4
+ # A reference to a named secret. Use it as a header or query parameter value
5
+ # in a {Request}. Create one with {PatientHttp.secret}.
6
6
  #
7
- # A SecretReference holds only the secret's name -- never its value. When a request
8
- # is serialized (for example, to be enqueued in a job system), the reference is
9
- # serialized as a lightweight marker (`{"$secret" => name}`) so the sensitive value
10
- # is never written to the queue or logs. The actual value is resolved on the
11
- # processor side at the moment the request is sent, using the secrets registered on
12
- # the {Configuration}.
7
+ # The reference holds only the name of the secret, not its value. A
8
+ # serialized request stores the marker `{"$secret" => name}`, so the value
9
+ # isn't written to the job queue or to logs. The processor resolves the value
10
+ # from the secrets registered in the {Configuration} when it sends the
11
+ # request.
13
12
  #
14
- # @example Referencing a secret when building a request
13
+ # @example Refer to secrets in a request
15
14
  # PatientHttp.get(
16
15
  # "https://api.example.com/data",
17
16
  # callback: MyCallback,
@@ -19,28 +18,28 @@ module PatientHttp
19
18
  # params: {"api_key" => PatientHttp.secret(:api_key)}
20
19
  # )
21
20
  class SecretReference
22
- # Key used in serialized JSON to indicate a secret reference.
21
+ # The key that identifies a secret reference in serialized JSON.
23
22
  REFERENCE_KEY = "$secret"
24
23
 
25
- # @return [String] the name of the referenced secret
24
+ # @return [String] The secret name.
26
25
  attr_reader :name
27
26
 
28
27
  class << self
29
- # Check if a value is a secret reference (either a SecretReference instance or a
30
- # serialized marker hash).
28
+ # Returns whether a value is a secret reference. The value can be a
29
+ # `SecretReference` or a serialized marker hash.
31
30
  #
32
- # @param value [Object] the value to check
33
- # @return [Boolean] true if the value is a secret reference
31
+ # @param value [Object] The value to check.
32
+ # @return [Boolean] `true` if the value is a secret reference.
34
33
  def reference?(value)
35
34
  value.is_a?(SecretReference) ||
36
35
  (value.is_a?(Hash) && value.key?(REFERENCE_KEY))
37
36
  end
38
37
 
39
- # Reconstruct a SecretReference from a serialized marker hash. Any other value
40
- # (including an existing SecretReference) is returned unchanged.
38
+ # Creates a reference from a serialized marker hash. Other values are
39
+ # returned unchanged.
41
40
  #
42
- # @param value [Object] a serialized marker hash or any other value
43
- # @return [Object] a SecretReference for a marker hash, otherwise the original value
41
+ # @param value [Object] A serialized marker hash, or any other value.
42
+ # @return [Object] A `SecretReference` for a marker hash, or the value.
44
43
  def load(value)
45
44
  return value unless value.is_a?(Hash) && value.key?(REFERENCE_KEY)
46
45
 
@@ -48,34 +47,42 @@ module PatientHttp
48
47
  end
49
48
  end
50
49
 
51
- # Initialize a new SecretReference.
50
+ # Creates a reference.
52
51
  #
53
- # @param name [String, Symbol] the name of the secret to reference
54
- # @raise [ArgumentError] if the name is empty
52
+ # @param name [String, Symbol] The secret name.
53
+ # @raise [ArgumentError] If the name is empty.
55
54
  def initialize(name)
56
55
  @name = name.to_s
57
56
  raise ArgumentError.new("secret name cannot be empty") if @name.empty?
58
57
  end
59
58
 
60
- # Serialize to a marker hash. Only the name is included; the value is never present.
59
+ # Returns the reference as a marker hash. The hash has the name, not the
60
+ # value.
61
61
  #
62
- # @return [Hash] the marker hash
62
+ # @return [Hash] The marker hash.
63
63
  def as_json
64
64
  {REFERENCE_KEY => name}
65
65
  end
66
66
 
67
+ # Returns whether another object is a reference to the same secret.
68
+ #
69
+ # @param other [Object] The object to compare.
70
+ # @return [Boolean] `true` if the names are equal.
67
71
  def ==(other)
68
72
  other.is_a?(SecretReference) && other.name == name
69
73
  end
70
74
  alias_method :eql?, :==
71
75
 
76
+ # Returns a hash code based on the secret name.
77
+ #
78
+ # @return [Integer] The hash code.
72
79
  def hash
73
80
  [self.class, name].hash
74
81
  end
75
82
 
76
- # Inspect the reference. Only the name is shown (there is no value to leak).
83
+ # Returns a description of the reference. It shows only the name.
77
84
  #
78
- # @return [String]
85
+ # @return [String] The description.
79
86
  def inspect
80
87
  "#<PatientHttp::SecretReference name=#{name.inspect}>"
81
88
  end
@@ -1,77 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Handles synchronous/inline execution of HTTP requests.
4
+ # Runs a request synchronously on the calling thread, and then calls the
5
+ # callback service.
5
6
  #
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.
7
+ # Use it in tests, and wherever a request must run inline. It doesn't depend
8
+ # on module-level state: you give it the configuration and optional hooks.
9
+ #
10
+ # The request uses a {ClientPool} that exists only for this call. The
11
+ # connection timeout, TCP settings, protocol, proxy, and retry rules are the
12
+ # same as for requests on a processor.
13
+ #
14
+ # @example Run a request
15
+ # executor = PatientHttp::SynchronousExecutor.new(
16
+ # task,
17
+ # config: config,
18
+ # on_complete: ->(response) { StatsD.increment("complete") },
19
+ # on_error: ->(error) { StatsD.increment("error") }
20
+ # )
21
+ # executor.call
9
22
  class SynchronousExecutor
10
23
  include RedirectHelper
24
+ include ImmediateRetries
11
25
 
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
26
+ # Creates an executor.
27
+ #
28
+ # @param task [RequestTask] The task to run.
29
+ # @param config [Configuration] The configuration for the request.
30
+ # @param on_complete [Proc, nil] A hook that runs with the response before
31
+ # the callback service's `on_complete` method.
32
+ # @param on_error [Proc, nil] A hook that runs with the error before the
33
+ # callback service's `on_error` method.
16
34
  def initialize(task, config:, on_complete: nil, on_error: nil)
17
35
  @task = task
18
36
  @config = config
19
37
  @on_complete = on_complete
20
38
  @on_error = on_error
21
- @proxy_client = nil
22
39
  @request_preparer = RequestPreparer.new(config)
23
40
  @response_reader = ResponseReader.new(nil, config: config)
41
+ @client_pool = ClientPool.from_config(config)
24
42
  end
25
43
 
26
- # Execute the request synchronously.
44
+ # Runs the request, and then calls the hook and the callback service with
45
+ # the response or error.
46
+ #
27
47
  # @return [void]
28
48
  def call
29
49
  Async do
30
50
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
51
 
32
52
  begin
33
- http_client = nil
34
53
  response_data = nil
35
54
  redirect_error = nil
36
55
 
37
56
  loop do
38
- http_client&.close
39
- @proxy_client&.close
40
- @proxy_client = nil
41
- outgoing = @request_preparer.prepare(@task.request, @task.id)
42
- http_client = create_http_client(outgoing.url)
43
- timeout = @task.request.timeout || @config.request_timeout
44
-
45
- response_data = Async::Task.current.with_timeout(timeout) do
46
- headers = outgoing.headers.to_h
47
- body = Protocol::HTTP::Body::Buffered.wrap([@task.request.body.to_s]) if @task.request.body
48
-
49
- endpoint = Async::HTTP::Endpoint.parse(outgoing.url)
50
- endpoint = configure_endpoint(endpoint) if @config.connection_timeout
51
-
52
- verb = @task.request.http_method.to_s.upcase
53
- options = {
54
- headers: headers,
55
- body: body,
56
- scheme: endpoint.scheme,
57
- authority: endpoint.authority
58
- }
59
-
60
- request = Protocol::HTTP::Request[verb, endpoint.path, **options]
61
- async_response = http_client.call(request)
62
- # Note: headers that appear multiple times (e.g. set-cookie) are
63
- # flattened to a single joined string value.
64
- headers_hash = async_response.headers.to_h.transform_values(&:to_s)
65
-
66
- chunks = @response_reader.read_raw_body(async_response, headers_hash)
67
- body_content = @response_reader.decode_body(chunks, headers_hash)
68
-
69
- {
70
- status: async_response.status,
71
- headers: ResponseReader.rewrite_content_encoding(headers_hash),
72
- body: body_content
73
- }
74
- end
57
+ response_data = perform_request
75
58
 
76
59
  # Check for redirect
77
60
  break unless should_follow_redirect?(@task, response_data)
@@ -123,65 +106,54 @@ module PatientHttp
123
106
  )
124
107
  invoke_callback(error, :error)
125
108
  ensure
126
- http_client&.close
127
- @proxy_client&.close
128
- @proxy_client = nil
109
+ @client_pool.close
129
110
  end
130
111
  end
131
112
  end
132
113
 
133
114
  private
134
115
 
135
- # Create HTTP client with config settings (retries, proxy, connection timeout).
136
- #
137
- # The client is not wrapped in a Protocol::HTTP::AcceptEncoding middleware.
138
- # That wrapper overwrites the request's accept-encoding header, which would
139
- # ignore a caller opting out of compression, so response bodies are decoded
140
- # by ResponseReader here exactly as they are on the async path.
141
- #
142
- # @param url [String] the resolved request URL
143
- # @return [Async::HTTP::Client] the HTTP client
144
- def create_http_client(url)
145
- endpoint = Async::HTTP::Endpoint.parse(url)
146
- endpoint = configure_endpoint(endpoint) if @config.connection_timeout
147
-
148
- if @config.proxy_url
149
- create_proxied_client(endpoint)
150
- else
151
- Async::HTTP::Client.new(endpoint, retries: @config.retries)
152
- end
153
- end
116
+ attr_reader :config
154
117
 
155
- # Create a proxied HTTP client.
118
+ # Sends the request of the current task and reads the full response.
156
119
  #
157
- # @param endpoint [Async::HTTP::Endpoint] the target endpoint
158
- # @return [Async::HTTP::Client] the proxied client
159
- def create_proxied_client(endpoint)
160
- require "async/http/proxy"
161
-
162
- proxy_endpoint = Async::HTTP::Endpoint.parse(@config.proxy_url)
163
- proxy_endpoint = configure_endpoint(proxy_endpoint) if @config.connection_timeout
164
- @proxy_client = Async::HTTP::Client.new(proxy_endpoint)
165
-
166
- proxy = @proxy_client.proxy(endpoint)
167
- Async::HTTP::Client.new(proxy.wrap_endpoint(endpoint), retries: @config.retries)
168
- end
169
-
170
- # Configure endpoint with connection timeout if specified.
120
+ # {ResponseReader} decodes the body. A `Protocol::HTTP::AcceptEncoding`
121
+ # wrapper isn't used, because it replaces an `accept-encoding` header that
122
+ # the caller set to turn off compression.
171
123
  #
172
- # @param endpoint [Async::HTTP::Endpoint] the endpoint to configure
173
- # @return [Async::HTTP::Endpoint] the configured endpoint
174
- def configure_endpoint(endpoint)
175
- Async::HTTP::Endpoint.new(
176
- endpoint.url,
177
- timeout: @config.connection_timeout
178
- )
124
+ # @return [Hash] The response data, with the `:status`, `:headers`, and
125
+ # `:body` keys.
126
+ def perform_request
127
+ outgoing = @request_preparer.prepare(@task.request, @task.id)
128
+ timeout = @task.request.timeout || @config.request_timeout
129
+
130
+ Async::Task.current.with_timeout(timeout) do
131
+ headers = outgoing.headers.to_h
132
+ body = Protocol::HTTP::Body::Buffered.wrap([@task.request.body.to_s]) if @task.request.body
133
+
134
+ endpoint = Async::HTTP::Endpoint.parse(outgoing.url)
135
+ async_response = request_with_immediate_retries(
136
+ @client_pool, @task.request, endpoint, headers, body
137
+ )
138
+ # Note: headers that appear multiple times (e.g. set-cookie) are
139
+ # flattened to a single joined string value.
140
+ headers_hash = async_response.headers.to_h.transform_values(&:to_s)
141
+
142
+ chunks = @response_reader.read_raw_body(async_response, headers_hash)
143
+ body_content = @response_reader.decode_body(chunks, headers_hash)
144
+
145
+ {
146
+ status: async_response.status,
147
+ headers: ResponseReader.rewrite_content_encoding(headers_hash),
148
+ body: body_content
149
+ }
150
+ end
179
151
  end
180
152
 
181
- # Invoke callback synchronously.
153
+ # Calls the hook and the callback service with a result.
182
154
  #
183
- # @param result [Response, Error] the result to pass to callback
184
- # @param type [Symbol] :response or :error
155
+ # @param result [Response, Error] The result.
156
+ # @param type [Symbol] `:response` or `:error`.
185
157
  def invoke_callback(result, type)
186
158
  callback_class = @task.callback.is_a?(Class) ? @task.callback : ClassHelper.resolve_class_name(@task.callback)
187
159
  callback = callback_class.new
@@ -1,53 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Abstract base class for handling task lifecycle operations.
4
+ # The abstract base class that connects a {RequestTask} to a job system.
5
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.
6
+ # The processor calls the task handler to deliver a result and to re-enqueue
7
+ # a request that didn't finish. Because of this class, {RequestTask} doesn't
8
+ # depend on a job system.
9
9
  #
10
- # @abstract Subclass and implement all methods to create a concrete handler.
10
+ # The processor calls {#on_complete} and {#on_error} on its completion worker
11
+ # threads. The methods must be thread-safe and idempotent, and they should
12
+ # return quickly, for example after they enqueue a job.
11
13
  #
12
- # @example Creating a custom handler
14
+ # @abstract Subclass it and implement all methods.
15
+ #
16
+ # @example Create a task handler
13
17
  # class MyTaskHandler < PatientHttp::TaskHandler
14
18
  # def on_complete(response, callback)
15
- # # Trigger completion callback
19
+ # MyJobSystem.enqueue(callback, :on_complete, response.as_json)
16
20
  # end
17
21
  #
18
22
  # def on_error(error, callback)
19
- # # Trigger error callback
23
+ # MyJobSystem.enqueue(callback, :on_error, error.as_json)
20
24
  # end
21
25
  #
22
26
  # def retry
23
- # # Re-enqueue the job
27
+ # MyJobSystem.enqueue_job(@job_id)
24
28
  # end
25
29
  # end
26
30
  class TaskHandler
27
- # Trigger the completion callback with the response.
31
+ # Delivers a response to the callback service's `on_complete` method.
28
32
  #
29
- # @param response [Response] the HTTP response object
30
- # @param callback [String] callback class name
33
+ # @param response [Response] The HTTP response.
34
+ # @param callback [String] The callback service class name.
31
35
  # @return [void]
32
36
  def on_complete(response, callback)
33
37
  raise NotImplementedError, "#{self.class}#on_complete must be implemented"
34
38
  end
35
39
 
36
- # Trigger the error callback with the error.
40
+ # Delivers an error to the callback service's `on_error` method.
37
41
  #
38
- # @param error [Error] the error object
39
- # @param callback [String] callback class name
42
+ # @param error [Error] The error.
43
+ # @param callback [String] The callback service class name.
40
44
  # @return [void]
41
45
  def on_error(error, callback)
42
46
  raise NotImplementedError, "#{self.class}#on_error must be implemented"
43
47
  end
44
48
 
45
- # Re-enqueue the original job for retry.
49
+ # Re-enqueues the original job, so the request runs again later.
46
50
  #
47
- # Called when a request cannot be completed (e.g., processor shutdown)
48
- # and needs to be retried later.
51
+ # The processor calls this method for a request that didn't finish, for
52
+ # example when the processor shuts down.
49
53
  #
50
- # @return [String] the new job ID
54
+ # @return [String] The new job ID.
51
55
  def retry
52
56
  raise NotImplementedError, "#{self.class}#retry must be implemented"
53
57
  end
@@ -1,26 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PatientHttp
4
- # Helper module for time-related operations using monotonic and wall clock time.
4
+ # Measures time with the monotonic clock, which system clock changes don't
5
+ # affect, and converts monotonic times to wall clock times.
5
6
  #
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.
7
+ # @api private
8
8
  module TimeHelper
9
9
  extend self
10
10
 
11
- # Get the current monotonic time.
11
+ # Returns the current monotonic time.
12
12
  #
13
13
  # Monotonic time is guaranteed to be non-decreasing and immune to system clock changes.
14
14
  #
15
- # @return [Float] current monotonic time in seconds since an unspecified starting point
15
+ # @return [Float] Current monotonic time in seconds since an unspecified starting point.
16
16
  def monotonic_time
17
17
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
18
18
  end
19
19
 
20
- # Convert a monotonic timestamp to wall clock time.
20
+ # Converts a monotonic timestamp to wall clock time.
21
21
  #
22
- # @param monotonic_timestamp [Float] monotonic timestamp to convert
23
- # @return [Time] wall clock time corresponding to the monotonic timestamp
22
+ # @param monotonic_timestamp [Float] Monotonic timestamp to convert.
23
+ # @return [Time] Wall clock time corresponding to the monotonic timestamp.
24
24
  def wall_clock_time(monotonic_timestamp)
25
25
  return nil unless monotonic_timestamp
26
26