patient_http 1.6.1 → 1.8.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 +18 -8
  3. data/CHANGELOG.md +43 -0
  4. data/README.md +564 -508
  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 +53 -26
  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 +405 -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 +117 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +178 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +115 -84
  40. data/lib/patient_http/request_template.rb +119 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +166 -118
  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 +84 -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 +327 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +21 -5
data/README.md CHANGED
@@ -6,230 +6,224 @@
6
6
 
7
7
  *Built for APIs that like to think.*
8
8
 
9
- Generic async HTTP connection pool for Ruby applications using Fiber-based concurrency.
9
+ This gem runs HTTP requests on a dedicated async I/O processor and passes each result to a callback service. Application threads don't wait for HTTP responses, so they're free to do other work while requests are in flight.
10
10
 
11
11
  ## Motivation
12
12
 
13
- Applications that make HTTP requests from within threaded environments often find that threads block waiting for I/O. A single slow API response holds an entire thread hostage, preventing it from doing other work. When many threads are blocked on HTTP I/O simultaneously, throughput collapses.
13
+ In a threaded application, a thread that makes an HTTP request waits for the response. A slow API response holds the thread for the full duration, so the thread can't do other work. When many threads wait on HTTP requests at the same time, throughput drops.
14
14
 
15
- PatientHttp solves this by running HTTP requests in a dedicated processor thread that uses Ruby's Fiber scheduler for non-blocking I/O. Application threads hand off HTTP requests to the processor and return immediately. The processor handles hundreds of concurrent HTTP connections using fibers, then notifies the application when responses arrive via a pluggable callback mechanism.
15
+ This gem runs HTTP requests in a dedicated processor thread that uses Ruby's fiber scheduler for non-blocking I/O. An application thread hands off a request to the processor and returns immediately. The processor runs hundreds of HTTP requests at the same time. When a response arrives, the processor passes it to your callback service through a job system.
16
16
 
17
- This design keeps application threads free to do other work while HTTP requests are in flight.
17
+ ## Quick start
18
18
 
19
- In general you will want to use this gem through an integration like [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) or [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue). These gems provide a request handler that integrates with their respective job processing systems, allowing you to enqueue HTTP requests directly from your application code without coupling it to the underlying processor implementation. See the [integration](#integration) section for details.
19
+ The processor needs a process to run in and a job system to deliver the results. Install the integration gem for your job system, which handles both:
20
20
 
21
- The [patient_llm](https://github.com/bdurand/patient_llm) gem provides an integration for making large language model requests asynchronously. This was the original motivation for building PatientHttp because LLM requests can take much longer than typical HTTP requests.
21
+ - [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) for Sidekiq
22
+ - [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) for Solid Queue
22
23
 
23
- ## Quick Start
24
+ For large language model (LLM) requests, use [patient_llm](https://github.com/bdurand/patient_llm), which builds on this gem. LLM requests can take much longer than typical HTTP requests, which was the original reason for this gem.
24
25
 
25
- ### 1. Implement a TaskHandler
26
+ ### 1. Install the gem
26
27
 
27
- The `TaskHandler` is the integration point between the pool and your application. It defines what happens when a request completes, fails, or needs to be retried.
28
+ Add the integration gem for your job system to your Gemfile. The integration gem depends on this gem, so you don't need to add `patient_http` too.
28
29
 
29
30
  ```ruby
30
- class MyTaskHandler < PatientHttp::TaskHandler
31
- def initialize(job_id)
32
- @job_id = job_id
33
- end
31
+ gem "patient_http-sidekiq"
32
+ ```
34
33
 
35
- def on_complete(response, callback)
36
- # Enqueue a message for your application to process the response.
37
- # Keep this lightweight and thread-safe -- it runs on a completion
38
- # worker thread, concurrently with other completions.
39
- MyJobSystem.enqueue(callback, :on_complete, response.as_json)
40
- end
34
+ No other setup is required. When the integration gem loads, it registers the request handler and connects the processor to the startup and shutdown of your job system. You don't need to write an initializer or call a setup method.
41
35
 
42
- def on_error(error, callback)
43
- MyJobSystem.enqueue(callback, :on_error, error.as_json)
36
+ ### 2. Create a callback service
37
+
38
+ Define a callback service class with `on_complete` and `on_error` instance methods. The callback service runs in a background job, so it can do slow work.
39
+
40
+ ```ruby
41
+ class FetchUserCallback
42
+ def on_complete(response)
43
+ user_id = response.callback_args[:user_id]
44
+ User.find(user_id).update!(external_data: response.json)
44
45
  end
45
46
 
46
- def retry
47
- # Re-enqueue the original job for retry when the processor
48
- # shuts down with in-flight requests
49
- MyJobSystem.enqueue_job(@job_id)
47
+ def on_error(error)
48
+ user_id = error.callback_args[:user_id]
49
+ Rails.logger.error("Failed to fetch user #{user_id}: #{error.message}")
50
50
  end
51
51
  end
52
52
  ```
53
53
 
54
- > **Important:** TaskHandler callbacks run on the processor's completion worker threads (see `completion_threads`), not the reactor thread, so they no longer block the event loop. Keep them lightweight anyway -- typically just enqueuing a message for another system to pick up. Heavy callbacks compete with the reactor for the GVL, and because a task stays in the capacity count until its result is delivered, callbacks that back up consume request capacity.
55
- >
56
- > Callbacks must be thread-safe. Results are delivered concurrently on `completion_threads` workers (default 2), so two callbacks can run at the same time and in an order unrelated to the order the requests completed. Set `completion_threads: 1` to serialize delivery.
57
- >
58
- > Callbacks must also be idempotent. A callback that raises is retried `completion_retries` times (default 2), so one that raises after enqueuing its message enqueues it again. Set `completion_retries: 0` if that is not acceptable.
59
-
60
- ### 2. Create and Enqueue Requests
54
+ ### 3. Make HTTP requests
61
55
 
62
56
  ```ruby
63
- # Configure the processor
64
- config = PatientHttp::Configuration.new(
65
- max_connections: 256,
66
- request_timeout: 60
57
+ PatientHttp.get(
58
+ "https://api.example.com/users/123",
59
+ callback: FetchUserCallback,
60
+ callback_args: {user_id: 123}
67
61
  )
62
+ ```
68
63
 
69
- # Start the processor
70
- processor = PatientHttp::Processor.new(config)
71
- processor.start
64
+ The call returns immediately. The processor runs the request, and when the request finishes, a background job calls your callback's `on_complete` method. If the request fails, the job calls `on_error` instead.
72
65
 
73
- # Build a request
74
- request = PatientHttp::Request.new(
75
- :get,
76
- "https://api.example.com/users/123",
77
- headers: {"Authorization" => "Bearer token"}
78
- )
66
+ Every option has a working default. To change the defaults, see [Configuration](#configuration).
79
67
 
80
- # Create a task with your handler
81
- task = PatientHttp::RequestTask.new(
82
- request: request,
83
- task_handler: MyTaskHandler.new("job-123"),
84
- callback: "FetchDataCallback",
85
- callback_args: {user_id: 123}
86
- )
68
+ ### Run requests without a job system
87
69
 
88
- # Enqueue it
89
- processor.enqueue(task)
70
+ In consoles, tests, and development environments without a job system, run requests inline:
71
+
72
+ ```ruby
73
+ PatientHttp.inline!
74
+ ```
75
+
76
+ After this call, each request made with the `PatientHttp` module methods runs immediately on the calling thread. The request goes through the full request lifecycle, including timeouts, redirects, and error handling, and the callback runs before the request method returns.
77
+
78
+ ```ruby
79
+ PatientHttp.inline!
80
+ PatientHttp.get("https://api.example.com/users/123", callback: FetchUserCallback)
81
+ # FetchUserCallback#on_complete has already run.
90
82
  ```
91
83
 
92
- ### 3. Process Callbacks
84
+ Requests that callbacks make also run inline. To check whether the inline handler is registered, call `PatientHttp.inline?`. To run one request inline without registering a handler, call `PatientHttp.execute_inline(request:, callback:)`.
85
+
86
+ ## Usage
87
+
88
+ ### Make requests
93
89
 
94
- When the HTTP request completes, your `TaskHandler#on_complete` is called with the `Response` and callback class name. Your handler is responsible for invoking the callback in whatever way makes sense for your application (e.g., enqueuing a background job).
90
+ The `PatientHttp` module has a method for each HTTP method: `get`, `head`, `post`, `put`, `patch`, `delete`, and `query`. The methods take the same options. `PatientHttp.request` takes the HTTP method as its first argument.
95
91
 
96
92
  ```ruby
97
- class FetchDataCallback
98
- def on_complete(response)
99
- user_id = response.callback_args[:user_id]
100
- data = response.json
101
- User.find(user_id).update!(external_data: data)
102
- end
93
+ PatientHttp.post(
94
+ "https://api.example.com/users",
95
+ json: {name: "John", email: "john@example.com"},
96
+ callback: CreateUserCallback,
97
+ callback_args: {source: "signup"}
98
+ )
99
+ ```
103
100
 
104
- def on_error(error)
105
- user_id = error.callback_args[:user_id]
106
- Rails.logger.error("Failed for user #{user_id}: #{error.message}")
107
- end
108
- end
101
+ The methods take these options:
102
+
103
+ | Option | Description |
104
+ | --- | --- |
105
+ | `callback:` | Required. The callback service class, or its name. |
106
+ | `callback_args:` | A Hash of arguments that the callback reads from the response or error. See [Callback arguments](#callback-arguments). |
107
+ | `headers:` | The request headers. |
108
+ | `body:` | The request body. GET, HEAD, and DELETE requests can't have a body. |
109
+ | `json:` | An object to send as a JSON body. Can't be combined with `body:`. |
110
+ | `params:` | Query parameters to add to the URL. |
111
+ | `timeout:` | The request timeout in seconds. |
112
+ | `read_timeout:` | The maximum number of seconds to wait for data from the server. See [Streamed and long-running responses](#streamed-and-long-running-responses). |
113
+ | `max_response_size:` | The maximum response body size in bytes for this request. Overrides the configured `max_response_size`. |
114
+ | `raise_error_responses:` | Whether to treat non-2xx responses as errors. See [Handle HTTP error responses](#handle-http-error-responses). |
115
+ | `max_redirects:` | The maximum number of redirects to follow for this request. `0` turns off redirects. |
116
+ | `follow_method_changing_redirects:` | Whether to follow redirects that change the HTTP method. See [Redirects](#redirects). |
117
+ | `redirect_strip_headers:` | The names of headers to remove from redirected requests. |
118
+ | `preprocessors:` | The names of registered [preprocessors](#request-preprocessors) to run on the request. |
119
+ | `processor:` | The name of the processor that runs the request. See [Named processors](#named-processors). |
120
+
121
+ For more control, build a `PatientHttp::Request` object and pass it to `PatientHttp.execute`:
122
+
123
+ ```ruby
124
+ request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
125
+ headers: {"Authorization" => PatientHttp.secret(:api_token)},
126
+ params: {include: "profile"},
127
+ timeout: 30
128
+ )
129
+ PatientHttp.execute(request: request, callback: FetchUserCallback, callback_args: {user_id: 123})
109
130
  ```
110
131
 
111
- ## Handling HTTP Error Responses
132
+ Application code that uses these methods never names the job system. To move to another job system, change the integration gem in your Gemfile.
133
+
134
+ ### Handle HTTP error responses
112
135
 
113
- By default, HTTP error status codes (4xx, 5xx) are treated as completed requests. You can check the status using helper methods on the response:
136
+ By default, HTTP error status codes (4xx and 5xx) are treated as completed requests and passed to `on_complete`. To check the status, use `response.success?`, `response.client_error?`, or `response.server_error?`:
114
137
 
115
138
  ```ruby
116
- def on_complete(response)
117
- if response.success? # 2xx
118
- process_data(response.json)
119
- elsif response.client_error? # 4xx
120
- handle_client_error(response)
121
- elsif response.server_error? # 5xx
122
- handle_server_error(response)
139
+ class ApiCallback
140
+ def on_complete(response)
141
+ if response.success?
142
+ process_data(response.json)
143
+ elsif response.client_error?
144
+ handle_client_error(response.status, response.body)
145
+ elsif response.server_error?
146
+ handle_server_error(response.status, response.body)
147
+ end
148
+ end
149
+
150
+ def on_error(error)
151
+ Rails.logger.error("Request failed: #{error.message}")
123
152
  end
124
153
  end
125
154
  ```
126
155
 
127
- To treat non-2xx responses as errors instead, set `raise_error_responses: true` on the `RequestTask`:
156
+ The `on_error` callback runs when the request raises an exception, such as a timeout or a connection failure. To treat HTTP errors as exceptions too, set the `raise_error_responses` option. With this option, a non-2xx response calls `on_error` with a `PatientHttp::HttpError`:
128
157
 
129
158
  ```ruby
130
- task = PatientHttp::RequestTask.new(
131
- request: request,
132
- task_handler: handler,
133
- callback: "ApiCallback",
134
- raise_error_responses: true
135
- )
159
+ PatientHttp.get("https://api.example.com/data", callback: ApiCallback, raise_error_responses: true)
136
160
  ```
137
161
 
138
- When enabled, non-2xx responses call `TaskHandler#on_error` with an `HttpError` that provides access to the response:
162
+ An `HttpError` gives you access to the request and the response:
139
163
 
140
164
  ```ruby
141
165
  def on_error(error)
142
166
  if error.is_a?(PatientHttp::HttpError)
143
- puts error.status # HTTP status code
144
- puts error.url # Request URL
145
- puts error.response.body # Response body
167
+ puts error.status # HTTP status code
168
+ puts error.url # Request URL
169
+ puts error.http_method # HTTP method
170
+ puts error.response.body # Response body
171
+ puts error.response.headers # Response headers
172
+ puts error.response.json # Response body parsed as JSON
146
173
  end
147
174
  end
148
175
  ```
149
176
 
150
- ## Request Templates
151
-
152
- For repeated requests to the same API, use `RequestTemplate` to share configuration:
153
-
154
- ```ruby
155
- template = PatientHttp::RequestTemplate.new(
156
- base_url: "https://api.example.com",
157
- headers: {"Authorization" => "Bearer #{ENV['API_KEY']}"},
158
- timeout: 60
159
- )
160
-
161
- # Build requests from the template
162
- get_request = template.get("/users/123")
163
- post_request = template.post("/users", json: {name: "John"})
164
- ```
165
-
166
- Templates support all HTTP methods (`get`, `head`, `post`, `put`, `patch`, `delete`, `query`) and handle URL joining, header merging, and query parameter encoding.
177
+ A 4xx response raises a `PatientHttp::ClientError`, and a 5xx response raises a `PatientHttp::ServerError`. Both are subclasses of `HttpError`.
167
178
 
168
- ## Standard Interface
179
+ To make `raise_error_responses` the default for every request, set it in the [configuration](#configuration).
169
180
 
170
- The `PatientHttp` module provides a standard interface for building and dispatching requests without needing to directly interact with the processor or task handlers. This allows you to write application code that makes HTTP requests without coupling it to the underlying async processing infrastructure.
181
+ ### Callback arguments
171
182
 
172
- You will need to register a request handler with `PatientHttp.register_handler` that defines how requests are dispatched to your job queue or background processing system. Once registered, you can use the `PatientHttp` class methods or the `RequestHelper` mixin to make async HTTP requests with callbacks.
183
+ To pass data to your callbacks, use the `callback_args` option:
173
184
 
174
185
  ```ruby
175
- # The handler receives keyword arguments for the request, callback, and any additional callback arguments.
176
- PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
177
- # Example integration point. Adapt this to your app.
178
- # Build a RequestTask and enqueue it to your processor.
179
- task = PatientHttp::RequestTask.new(
180
- request: request,
181
- task_handler: MyTaskHandler.new,
182
- callback: callback,
183
- callback_args: callback_args,
184
- raise_error_responses: raise_error_responses
185
- )
186
-
187
- processor.enqueue(task)
188
- end
189
-
190
- # Now you can make requests directly through the PatientHttp interface with the .request,
191
- # .get, .head, .post, .patch, .put, .delete, and .query class methods:
192
186
  PatientHttp.get(
193
- "https://api.example.com/users/123",
187
+ "https://api.example.com/users/#{user_id}",
194
188
  callback: FetchUserCallback,
195
- callback_args: {user_id: 123}
189
+ callback_args: {user_id: user_id, requested_at: Time.now.iso8601}
196
190
  )
197
191
  ```
198
192
 
199
- If you are using the [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) gem or the [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gem, the appropriate handler will automatically be registered for you.
200
-
201
- ### Inline Execution
202
-
203
- For consoles, tests, and development environments where no job system is configured, you can register a handler that executes requests inline — synchronously, in-process — instead of dispatching them to a queue:
193
+ The `response.callback_args` and `error.callback_args` methods return the arguments:
204
194
 
205
195
  ```ruby
206
- PatientHttp.inline!
196
+ response.callback_args[:user_id]
197
+ response.callback_args["user_id"]
207
198
  ```
208
199
 
209
- Now every request made through the `PatientHttp` interface (or the `RequestHelper` mixin) runs immediately through the full request lifecycle (timeouts, redirects, error wrapping) and invokes its callback on the calling thread before returning. Callbacks can make further requests; those execute inline as well.
200
+ The `callback_args` value follows these rules:
210
201
 
211
- ```ruby
212
- PatientHttp.inline!
213
- PatientHttp.get("https://api.example.com/users/123", callback: FetchUserCallback)
214
- # FetchUserCallback#on_complete has already been invoked by this point
215
- ```
202
+ - It must be a Hash, or respond to `to_h`, and contain only JSON-native types: `nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, and `Hash`.
203
+ - Hash keys are converted to strings, including the keys of nested hashes and of hashes in arrays.
204
+ - You can read the arguments with symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`.
205
+ - Reading a key that isn't set raises a `KeyError`. To get a default value instead, use `callback_args.fetch(:user_id, nil)`.
206
+
207
+ ### Use request templates
216
208
 
217
- Inline requests run against `PatientHttp.default_configuration` by default (or a lazily created default configuration that includes any secrets registered with `PatientHttp.register_secret` — see [Secrets](#secrets)). You can also pass an explicit configuration:
209
+ To share settings across requests to the same API, use `PatientHttp::RequestTemplate`:
218
210
 
219
211
  ```ruby
220
- PatientHttp.inline!(config: PatientHttp::Configuration.new(raise_error_responses: true))
221
- ```
212
+ template = PatientHttp::RequestTemplate.new(
213
+ base_url: "https://api.example.com",
214
+ headers: {"Authorization" => PatientHttp.secret(:api_token)},
215
+ timeout: 60
216
+ )
222
217
 
223
- Use `PatientHttp.inline?` to check whether the inline handler is the currently registered handler. To execute a single request inline without registering a handler, use `PatientHttp.execute_inline(request:, callback:)`.
218
+ request = template.get("/users/123")
219
+ PatientHttp.execute(request: request, callback: FetchUserCallback)
220
+ ```
224
221
 
225
- ### RequestHelper Mixin
222
+ A template has a method for each HTTP method. The template joins each path with the base URL, and merges the headers and query parameters of each request with its own. If the template doesn't set a `timeout`, the configured `request_timeout` applies. A template can also set defaults for `read_timeout` and `max_response_size`. A value given to a request overrides the template value.
226
223
 
227
- Use `PatientHttp::RequestHelper` when you want a simple API for creating and dispatching async HTTP requests directly from your class.
224
+ ### Use the RequestHelper module
228
225
 
229
- 1. Register a request handler with `PatientHttp.register_handler` that defines how requests are dispatched to your job queue or background processing system.
230
- 2. Include `PatientHttp::RequestHelper` in your class.
231
- 3. Optionally define a `request_template` for shared `base_url`, headers, and timeout.
232
- 4. Call `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`, `async_delete`, `async_query`, or `async_request`.
226
+ For a class that makes many requests, include `PatientHttp::RequestHelper`. The module adds the `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`, `async_delete`, `async_query`, and `async_request` methods. To set shared options such as `base_url`, `headers`, and `timeout`, use the `request_template` class method:
233
227
 
234
228
  ```ruby
235
229
  class ApiClient
@@ -237,435 +231,466 @@ class ApiClient
237
231
 
238
232
  request_template(
239
233
  base_url: "https://api.example.com",
240
- headers: {"Authorization" => "Bearer #{ENV["API_KEY"]}"},
234
+ headers: {"Authorization" => PatientHttp.secret(:api_token)},
241
235
  timeout: 60
242
236
  )
243
237
 
244
238
  def fetch_user(user_id)
245
- async_get(
246
- "/users/#{user_id}",
247
- callback: FetchUserCallback,
248
- callback_args: {user_id: user_id}
249
- )
239
+ async_get("/users/#{user_id}", callback: FetchUserCallback, callback_args: {user_id: user_id})
250
240
  end
251
241
 
252
242
  def update_user(user_id, data)
253
- async_patch(
254
- "/users/#{user_id}",
255
- json: data,
256
- callback: UpdateUserCallback,
257
- callback_args: {user_id: user_id}
258
- )
243
+ async_patch("/users/#{user_id}", json: data, callback: UpdateUserCallback, callback_args: {user_id: user_id})
259
244
  end
260
245
  end
261
246
  ```
262
247
 
263
- ## Callback Arguments
248
+ The `async_*` methods take the same options as the `PatientHttp` module methods. Paths are relative to the template's `base_url`. A subclass uses the template of its superclass unless it declares its own.
264
249
 
265
- Pass custom data through the request/response cycle using `callback_args`:
250
+ ### Streamed and long-running responses
266
251
 
267
- ```ruby
268
- task = PatientHttp::RequestTask.new(
269
- request: request,
270
- task_handler: handler,
271
- callback: "FetchDataCallback",
272
- callback_args: {user_id: 123, request_timestamp: Time.now.iso8601}
273
- )
274
- ```
252
+ The `timeout` option limits the full request, from the time it is sent until the last byte of the body arrives. A streamed response, such as server-sent events from an LLM API, can continue for many minutes. A large `timeout` lets the stream finish, but it cannot find a stream that stops.
275
253
 
276
- Callback arguments are available on both `Response` and `Error` objects:
254
+ To find a stream that stops, set `read_timeout`. This is the maximum number of seconds to wait for data from the server. The first timer starts before the connection opens. It covers the time to get a connection, send the request, and wait for the response headers. The timer starts again each time a body chunk arrives. A stream that sends data at regular intervals can continue for longer than `read_timeout`, until `timeout` stops it.
277
255
 
278
256
  ```ruby
279
- response.callback_args[:user_id] # Symbol access
280
- response.callback_args["user_id"] # String access
257
+ PatientHttp.post(
258
+ "https://api.example.com/v1/generate",
259
+ json: {prompt: "...", stream: true},
260
+ callback: GenerateCallback,
261
+ timeout: 30 * 60, # Limit for the full stream.
262
+ read_timeout: 60, # Limit for each wait for data.
263
+ max_response_size: 8 * 1024 * 1024
264
+ )
281
265
  ```
282
266
 
283
- Callback args must contain only JSON-native types (`nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, `Hash`). Hash keys are converted to strings for serialization.
284
-
285
- ## Response and Error Objects
267
+ When the read timeout stops a request, the `on_error` callback receives a `RequestError` with an `error_type` of `:timeout` and an `error_class` of `PatientHttp::ReadTimeoutError`. When `timeout` stops a request, the `error_class` is `Async::TimeoutError`. A request stopped by either timeout isn't retried, because the server may have processed it.
286
268
 
287
- The `PatientHttp::Response` and error objects are designed to be serializable and deserializable as JSON, making them safe to pass through job queues and across process boundaries. This allows you to enqueue the response or error data in your `TaskHandler` callbacks and process them asynchronously in another context.
269
+ Do not set `read_timeout` for a request where the server does all of its work before it sends the response headers. For that request, the wait for the headers can be longer than the read timeout, and the request fails.
288
270
 
289
- Both response and error objects provide `as_json` and `to_json` methods for serialization:
271
+ The callback receives the full body when the stream ends. A streamed body is usually larger than the equivalent JSON body, so set `max_response_size` on the request to increase the limit for these requests only.
290
272
 
291
- ```ruby
292
- def on_complete(response, callback)
293
- # Serialize the response for background processing
294
- MyJobSystem.enqueue(callback, :on_complete, response.as_json)
295
- end
273
+ ## Configuration
296
274
 
297
- def on_error(error, callback)
298
- # Serialize the error for background processing
299
- MyJobSystem.enqueue(callback, :on_error, error.as_json)
300
- end
301
- ```
275
+ All configuration is optional. To set options, call `PatientHttp.configure` in an initializer. If an integration gem is loaded, the method yields that gem's configuration, which adds the options for the job system to the options below.
302
276
 
303
- When deserializing, use the `load` class methods to reconstruct the objects:
277
+ Every call yields the same configuration object, so options accumulate. Several initializers can each set options without overwriting one another.
304
278
 
305
279
  ```ruby
306
- response = PatientHttp::Response.load(json_data)
307
- error = PatientHttp::HttpError.load(json_data)
308
- ```
309
-
310
- The `Response` object includes the HTTP status code, headers, body, and callback arguments. Error objects (`HttpError`, `RedirectError`, `RequestError`) include the error message, context about the request, and callback arguments.
280
+ PatientHttp.configure do |config|
281
+ # Maximum concurrent HTTP requests (default: 256).
282
+ config.max_connections = 256
311
283
 
312
- Request and response headers are case insensitive. A request header with a `nil` or empty string value is never sent: setting a header to `nil` or `""` removes it, and a header hash such as `{"X-Header" => nil}` does not set the header at all. Headers that appear multiple times in the response (such as `set-cookie`) are flattened into a single joined string value.
284
+ # Maximum connections to each host (default: nil, no limit).
285
+ config.max_connections_per_host = 32
313
286
 
314
- Response bodies are automatically encoded for JSON serialization. Binary content is Base64 encoded, and large text content is gzipped and then Base64 encoded to reduce payload size. Decoding is handled transparently when you access the `body` or `json` methods on the `Response` object.
287
+ # Default timeout for HTTP requests in seconds (default: 60).
288
+ config.request_timeout = 60
315
289
 
316
- ### Payload Stores
290
+ # Default maximum number of seconds to wait for data from the server
291
+ # (default: nil, no limit). The timer covers the wait for the response
292
+ # headers and starts again each time a body chunk arrives. See "Streamed and
293
+ # long-running responses".
294
+ config.read_timeout = nil
317
295
 
318
- For large request/response payloads, you can configure external storage to keep serialized JSON payloads small. Payloads exceeding the configured threshold are automatically stored externally and fetched on demand.
296
+ # Timeout for graceful shutdown in seconds (default: 30).
297
+ config.shutdown_timeout = 30
319
298
 
320
- If you are using a job queue or background processing system, this allows you to handle large requests or responses without hitting size limits or memory constraints on queue message payloads. The use of external storage is transparent to your application code.
299
+ # Maximum response body size in bytes (default: 1MB). Larger responses raise
300
+ # ResponseTooLargeError. A request can set its own max_response_size.
301
+ config.max_response_size = 1024 * 1024
321
302
 
322
- ```ruby
323
- # Register a payload store (see below for options; the file adapter should only be used for development/testing)
324
- config.register_payload_store(:my_store, adapter: :file, directory: "/tmp/payloads")
303
+ # Default User-Agent header for all requests (default: "PatientHttp").
304
+ config.user_agent = "MyApp/1.0"
325
305
 
326
- # Use the ExternalStorage class to set and fetch stored payloads in your callbacks.
327
- storage = PatientHttp::ExternalStorage.new(config)
306
+ # Whether to raise HttpError for non-2xx responses by default (default: false).
307
+ config.raise_error_responses = false
328
308
 
329
- large_response_data = storage.store(large_response.as_json)
330
- # Returns a reference like: {"$ref" => {"store" => "my_store", "key" => "abc123"}}
309
+ # Maximum number of redirects to follow (default: 5; 0 turns off redirects).
310
+ config.max_redirects = 5
331
311
 
332
- small_response_data = storage.store(small_response.as_json, max_size: 1024)
333
- # Will not store the payload and returns the original data hash if the JSON payload is under 1KB.
312
+ # Whether to follow redirects that change the HTTP method, such as POST to
313
+ # GET on a 302 (default: true). If false, the request gets the redirect
314
+ # response.
315
+ config.follow_method_changing_redirects = true
334
316
 
335
- storage.storage_ref?(large_response_data) # => true
336
- storage.storage_ref?(small_response_data) # => false
317
+ # Names of headers to remove from all redirected requests (default: []).
318
+ # Names are case insensitive. Authorization and Cookie are always removed on
319
+ # cross-origin redirects.
320
+ config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
337
321
 
338
- storage.fetch(large_response_data) # Fetches the original data from the store
339
- storage.fetch(small_response_data) # Raises an error since this is not a reference
340
-
341
- storage.delete(large_response_data) # Deletes the stored payload
342
- ```
322
+ # Maximum number of host clients to pool (default: 100).
323
+ config.connection_pool_size = 100
343
324
 
344
- #### File Store
325
+ # Timeout in seconds to open a connection, including the TCP connect and the
326
+ # TLS handshake (default: nil, no limit). It doesn't limit the wait for a
327
+ # response; request_timeout does that.
328
+ config.connection_timeout = 10
345
329
 
346
- For development and testing:
330
+ # TCP keepalive for pooled connections (default: nil, the kernel sends no
331
+ # probes). A number sets the idle seconds before the first probe. A Hash also
332
+ # sets the interval and the probe count, for example
333
+ # {idle: 30, interval: 10, count: 3}. The Hash must contain :idle. The
334
+ # :interval default is 10 seconds, and the :count default is 3 probes.
335
+ config.tcp_keepalive = 30
347
336
 
348
- ```ruby
349
- config.register_payload_store(:files, adapter: :file, directory: "/tmp/payloads")
350
- ```
337
+ # Seconds that sent data can stay unacknowledged before the kernel closes the
338
+ # connection (default: nil, the kernel default applies). Sets
339
+ # TCP_USER_TIMEOUT, which is available only on Linux.
340
+ config.tcp_user_timeout = 30
351
341
 
352
- #### Redis Store
342
+ # HTTP or HTTPS proxy URL (default: nil). Supports authentication, for
343
+ # example "http://user:pass@proxy.example.com:8080".
344
+ config.proxy_url = "http://proxy.example.com:8080"
353
345
 
354
- For production with shared state across processes (requires the `redis` gem; the client must respond to `set`, `get`, `del`, and `exists`):
346
+ # Number of retries for failed requests (default: 3).
347
+ config.retries = 3
355
348
 
356
- ```ruby
357
- redis = Redis.new(url: ENV["REDIS_URL"])
358
- config.register_payload_store(:redis, adapter: :redis, redis: redis, ttl: 86400)
359
- ```
349
+ # HTTP protocol, :http1 or :http2 (default: nil, negotiated with the server,
350
+ # with HTTP/2 preferred for HTTPS). The :http1 value also limits the TLS ALPN
351
+ # advertisement to http/1.1, which can work around proxies that intercept SSL
352
+ # and don't handle HTTP/2 correctly.
353
+ config.protocol = nil
360
354
 
361
- Options: `redis:` (required), `ttl:` (seconds, optional), `key_prefix:` (default: `"patient_http:payloads:"`)
355
+ # Number of threads that decode responses and deliver results (default: 2).
356
+ config.completion_threads = 2
362
357
 
363
- #### S3 Store
358
+ # Number of times to retry result delivery before the failure is reported
359
+ # (default: 2).
360
+ config.completion_retries = 2
364
361
 
365
- For durable storage across instances (requires `aws-sdk-s3` gem):
362
+ # Size in bytes above which payloads are stored externally when a payload
363
+ # store is configured (default: 64KB).
364
+ config.payload_store_threshold = 64 * 1024
366
365
 
367
- ```ruby
368
- s3 = Aws::S3::Resource.new
369
- bucket = s3.bucket("my-payloads-bucket")
370
- config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
366
+ # Logger (default: a Logger that writes errors to standard error).
367
+ config.logger = Rails.logger
368
+ end
371
369
  ```
372
370
 
373
- Options: `bucket:` (required), `key_prefix:` (default: `"patient_http/payloads/"`)
371
+ `PatientHttp.configuration` returns the same object outside a `configure` block.
374
372
 
375
- #### ActiveRecord Store
373
+ For the options that each job system adds, see the documentation for [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq#configuration) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue#configuration).
376
374
 
377
- For database-backed storage with transactional guarantees:
375
+ ### Named processors
378
376
 
379
- ```ruby
380
- config.register_payload_store(:database, adapter: :active_record)
381
- ```
377
+ Named processors are available only when an integration gem is loaded. The integration gems add the `config.processor` method.
382
378
 
383
- This requires a database migration. Copy the migration from the gem:
379
+ By default, all requests share one processor and one `max_connections` limit. If one process runs workloads with very different profiles, such as slow LLM API calls and fast webhook deliveries, a burst of one workload can use all the capacity that the other needs. Named processor profiles keep the workloads separate:
384
380
 
385
381
  ```ruby
386
- # db/migrate/XXXXXX_create_patient_http_payloads.rb
387
- class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
388
- def change
389
- create_table :patient_http_payloads, id: false do |t|
390
- t.string :key, null: false, limit: 36
391
- t.text :data, null: false
392
- t.timestamps
393
- end
394
-
395
- add_index :patient_http_payloads, :key, unique: true
396
- add_index :patient_http_payloads, :created_at
397
- end
382
+ PatientHttp.configure do |config|
383
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
384
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
398
385
  end
399
386
  ```
400
387
 
401
- Options: `model:` (optional, defaults to built-in `PatientHttp::PayloadStore::ActiveRecordStore::Payload`)
402
-
403
- #### Custom Stores
388
+ Each profile runs as an independent processor in the process, with its own capacity, timeouts, and threads. Profile options override the top-level configuration. The profiles share every option that they don't override, such as secrets, preprocessors, payload stores, encryption, and the logger. The `:default` processor always exists.
404
389
 
405
- Implement your own by subclassing `PatientHttp::PayloadStore::Base`:
390
+ To send a request to a processor, set the `processor:` option on the request method, on a `Request`, or on a `RequestTemplate`:
406
391
 
407
392
  ```ruby
408
- class MyStore < PatientHttp::PayloadStore::Base
409
- register :my_store, self
410
-
411
- def store(key, data)
412
- # Store the hash and return the key
413
- end
414
-
415
- def fetch(key)
416
- # Return the hash or nil if not found
417
- end
393
+ PatientHttp.post(url, callback: MyCallback, processor: :llm)
394
+ ```
418
395
 
419
- def delete(key)
420
- # Delete the data (idempotent)
421
- end
422
- end
396
+ For how the processor name is kept through retries and crash recovery, see the documentation for your integration gem.
423
397
 
424
- config.register_payload_store(:custom, adapter: :my_store, **options)
425
- ```
398
+ ### Tuning tips
426
399
 
427
- Multiple stores can be registered for migration purposes. The last registered store is used for new writes; all registered stores remain available for reads.
400
+ - `max_connections`: Set this based on your system's resources. Each connection uses memory and a file descriptor. A tuned system with enough resources can handle thousands of concurrent connections.
401
+ - `max_connections_per_host`: Limits the sockets open to each host. The default is no limit. For high concurrency, set a value such as 32, so that one host can't use every file descriptor. Make sure that the process file descriptor limit covers `max_connections`, plus idle pooled connections, plus the application's own connections.
402
+ - `request_timeout`: Set this based on the response times of the APIs that you call. AI APIs can take minutes to respond while they generate content.
403
+ - `read_timeout`: Limits each wait for data from the server, including the wait for the response headers. Set it on requests that stream their response, so that a stream that stops fails before `request_timeout`. Do not set it as a default if the APIs that you call do their work before they send the response headers.
404
+ - `connection_timeout`: Limits only the TCP connect and the TLS handshake. Set it to fail fast when a host doesn't answer. It doesn't limit the wait for a response, because `request_timeout` controls the full exchange.
405
+ - `tcp_keepalive`: The kernel sends probes on an idle pooled connection. The probes keep NAT and firewall mappings open, and let the kernel find a dead peer before the pool sends a request on the connection. Set this option when connections stay idle in the pool between requests.
406
+ - `tcp_user_timeout`: The kernel closes a connection when the peer doesn't acknowledge sent data. This option is available only on Linux. A request to a peer that stopped without notice fails after this timeout, without waiting for `request_timeout`. Acknowledged data isn't affected, so a slow response continues.
407
+ - `connection_pool_size`: Sets the maximum number of hosts whose connections are kept open. Increase it if your application calls many different hosts.
408
+ - `max_response_size`: Limits the size of HTTP responses to prevent high memory use from unexpectedly large responses. For a compressed response, the limit applies to the decompressed body. To increase the limit for some requests only, set `max_response_size` on those requests. For large responses, consider a [payload store](#payload-stores).
409
+ - `completion_threads`: Increase this when result delivery does heavy work, such as serialization or encryption, and finished requests wait for a thread. If the value is greater than 1, results are delivered concurrently, so `TaskHandler` callbacks and completion-time observers must be thread-safe. Set it to 1 to deliver results one at a time.
410
+ - `completion_retries`: The number of times to retry result delivery before the failure is reported to observers through `completion_failed`. A retry calls `on_complete` or `on_error` again. As a result, a handler that raises an error *after* it enqueues its message delivers that message twice. Make handlers idempotent, or set `completion_retries` to 0 to report the first failure without a retry.
411
+ - `shutdown_timeout`: Must be less than the process supervisor's stop timeout, so that in-flight requests and their results finish before a hard kill.
428
412
 
429
- ## Encryption
413
+ ### Response compression
430
414
 
431
- When using PatientHttp with a job queue system, request and response data is serialized into the queue (Redis, database, etc.). If this data contains sensitive information, you should encrypt it.
415
+ Requests ask for `gzip` compression by default. A completion worker thread decompresses the response body. To change this, set the `accept-encoding` header on a request:
432
416
 
433
- PatientHttp provides encryption helpers through the `Configuration` object, but it is up to the `TaskHandler` implementation to ensure that serialized data is actually encrypted. If you are using an integration gem like [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) or [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue), the `TaskHandler` provided by the gem handles encryption automatically — you just need to configure the encryption key or callables on the `Configuration` object.
417
+ - `identity` turns off compression.
418
+ - Any other encoding is delivered still encoded, with its `content-encoding` header, so that you can decode it yourself.
434
419
 
435
- If you are writing a custom `TaskHandler`, use `Configuration#encryptor` as the helper and call `encrypt` / `decrypt` explicitly wherever your handler serializes or deserializes data.
420
+ ## Sensitive and large payloads
436
421
 
437
- ### Using an encryption key
422
+ Requests and responses are serialized into your job queue so that they can move between processes. As a result, you need to decide how to keep sensitive values out of the queue, and where to put payloads that are too large for it.
438
423
 
439
- The simplest option is `encryption_key=`, which sets up [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) automatically using AES-256-GCM:
424
+ These features cover both cases. They work together:
440
425
 
441
- ```ruby
442
- config = PatientHttp::Configuration.new
443
- config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
444
- ```
426
+ | Feature | When to use it | How to register it |
427
+ | --- | --- | --- |
428
+ | [Secrets](#secrets) | A sensitive header or query parameter, such as an API token. | `config.register_secret` |
429
+ | [Preprocessors](#request-preprocessors) | A signature calculated from the final request, such as AWS SigV4. | `config.register_preprocessor` |
430
+ | [Encryption](#encryption) | The request or response body is sensitive. | `config.encryption_key` |
431
+ | [Payload stores](#payload-stores) | Payloads are too large for the queue. | `config.register_payload_store` |
445
432
 
446
- To support key rotation, pass an array — the first key encrypts new data, and all keys attempt decryption:
433
+ Use secrets and preprocessors first, because they keep sensitive values out of the queue. Use encryption when the payload itself is sensitive, and a payload store when the payload is too large.
447
434
 
448
- ```ruby
449
- config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
450
- ```
435
+ ### Secrets
451
436
 
452
- ### Using custom callables
437
+ If you put an API token directly on a request, the token is written to your job queue. A secret lets you refer to the value by name. The serialized request stores only a marker, `{"$secret" => "name"}`. The processor resolves the value from the configuration when it sends the request.
453
438
 
454
- For custom encryption libraries, provide callables that accept and return raw bytes (String):
439
+ Register a secret with a value, or with a block that runs each time the secret is resolved:
455
440
 
456
441
  ```ruby
457
- config.encryption { |bytes| MyEncryption.encrypt(bytes) }
458
- config.decryption { |bytes| MyEncryption.decrypt(bytes) }
442
+ PatientHttp.configure do |config|
443
+ config.register_secret(:authorization, "Bearer #{ENV["API_TOKEN"]}")
444
+ config.register_secret(:api_key) { ENV["MY_API_KEY"] }
445
+ end
459
446
  ```
460
447
 
461
- Or pass any object that responds to `#call`:
448
+ You can also register a secret at the module level. Use this in a library, or in an initializer that loads before the rest of your configuration:
462
449
 
463
450
  ```ruby
464
- config.encryption(->(bytes) { MyEncryption.encrypt(bytes) })
465
- config.decryption(->(bytes) { MyEncryption.decrypt(bytes) })
451
+ PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
466
452
  ```
467
453
 
468
- ### Wiring encryption into a custom TaskHandler
454
+ Module-level secrets are added to the configuration when it's created, so load order doesn't matter. To check whether a secret is registered, call `PatientHttp.secret_registered?(name)`.
469
455
 
470
- If you are writing your own `TaskHandler` (rather than using one from an integration gem), you must wire in encryption yourself. `Configuration#encryptor` returns an `Encryptor` built from the configured callables. Call it directly at every serialization boundary:
456
+ Use a secret reference as a header or query parameter value:
471
457
 
472
458
  ```ruby
473
- class MyTaskHandler < PatientHttp::TaskHandler
474
- def initialize(job_id, configuration:)
475
- @job_id = job_id
476
- @configuration = configuration
477
- end
459
+ PatientHttp.get(
460
+ "https://api.example.com/data",
461
+ callback: MyCallback,
462
+ headers: {"Authorization" => PatientHttp.secret(:authorization)},
463
+ params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
464
+ )
465
+ ```
478
466
 
479
- def on_complete(response, callback)
480
- # Encrypt the serialized response before enqueuing
481
- encrypted = @configuration.encryptor.encrypt(response.as_json)
482
- MyJobSystem.enqueue(callback, :on_complete, encrypted)
483
- end
467
+ The secret query parameter isn't added to the serialized URL. The other parameters, such as `page`, are added as usual. The processor resolves both secrets immediately before it sends the request. If a secret isn't registered, the request fails with a `PatientHttp::SecretManager::SecretNotFoundError`, which is passed to `on_error`.
484
468
 
485
- def on_error(error, callback)
486
- encrypted = @configuration.encryptor.encrypt(error.as_json)
487
- MyJobSystem.enqueue(callback, :on_error, encrypted)
488
- end
469
+ ### Request preprocessors
489
470
 
490
- def retry
491
- MyJobSystem.enqueue_job(@job_id)
492
- end
493
- end
471
+ A preprocessor changes a request immediately before it's sent, usually to sign it. Signing schemes such as AWS SigV4 calculate values from the final request and set several headers, so a static header value can't express them.
494
472
 
495
- # Keep a configuration reference and use config.encryptor where needed
496
- handler = MyTaskHandler.new("job-123", configuration: config)
497
- ```
498
-
499
- In your callback, decrypt before processing:
473
+ Like secrets, preprocessors are registered in the configuration and referenced by name. The signing code and its credentials stay in the processor and aren't written to the queue.
500
474
 
501
475
  ```ruby
502
- class FetchDataCallback
503
- def initialize(configuration:)
504
- @configuration = configuration
505
- end
506
-
507
- def on_complete(data)
508
- response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
509
- # ...
476
+ PatientHttp.configure do |config|
477
+ config.register_preprocessor(:aws_sigv4) do |request|
478
+ signer = Aws::Sigv4::Signer.new(
479
+ service: "execute-api",
480
+ region: "us-east-1",
481
+ credentials_provider: Aws::CredentialProviderChain.new.resolve
482
+ )
483
+ signature = signer.sign_request(
484
+ http_method: request.http_method.to_s.upcase,
485
+ url: request.url,
486
+ headers: request.headers.to_h,
487
+ body: request.body.to_s
488
+ )
489
+ signature.headers.each { |name, value| request.headers[name] = value }
510
490
  end
511
491
  end
492
+
493
+ PatientHttp.post("https://api.example.com/data", callback: MyCallback, json: {value: 1}, preprocessors: :aws_sigv4)
512
494
  ```
513
495
 
514
- ### How it works
496
+ The preprocessor receives a `PatientHttp::OutgoingRequest`. At that time, secret references are resolved, and the `x-request-id` and default `User-Agent` headers are set. The object has these methods:
515
497
 
516
- Encrypted data is stored as `{"__encrypted__" => true, "value" => "<base64>"}`. The `Encryptor` JSON-serializes the original hash, passes the bytes to your callable, and Base64-encodes the result. Decryption reverses the process. Hashes without the `"__encrypted__"` key are passed through unchanged, so un-encrypted historical data continues to work while you roll out encryption.
498
+ - `http_method`, `url`, and `body`: Read-only. The URL includes the resolved secret query parameters.
499
+ - `headers`: The request headers. You can change them. Names are case insensitive.
500
+ - `add_param(name, value)`: Adds a query parameter to the URL, for signing schemes that use query parameters.
517
501
 
518
- ## Secrets
502
+ To run several preprocessors, pass an array. They run in order, and each one sees the changes of the ones before it. `RequestTemplate` and `request_template` in `RequestHelper` take a `preprocessors:` option as a default. If a preprocessor isn't registered, the request fails with a `PatientHttp::RequestPreparer::PreprocessorNotFoundError`, which is passed to `on_error`.
519
503
 
520
- Requests are serialized into your job queue before they run. If you put a sensitive value — an API token in an `Authorization` header, or an API key in a query parameter — directly on the request, that value is written into the queue. Requests can be encrypted in the queue, but a better practice is to avoid putting sensitive values on the request at all.
504
+ When a request follows a same-origin redirect, the preprocessors run again for the redirect URL, so signatures stay valid. On a cross-origin redirect, the preprocessors are removed, as are the `Authorization` and `Cookie` headers. As a result, signed credentials aren't sent to another origin.
521
505
 
522
- The secret manager lets you reference a sensitive values in headers or query parameters by name instead. The serialized request stores only a reference marker (`{"$secret" => "name"}`), never the value. The actual value lives on the `Configuration` (which exists on the processor side) and is resolved at the moment the request is sent.
506
+ ### Encryption
523
507
 
524
- ### Defining secrets
508
+ When the request or response body is sensitive, encrypt it. After you set a key, the integration gems encrypt data before they write it to the queue, and decrypt it when they read it.
525
509
 
526
- Register named secrets on the `Configuration`. A value can be given directly, or as a block that is evaluated lazily each time the secret is resolved (useful for reading from the environment on demand):
510
+ The simplest option is `encryption_key`. It uses [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) with AES-256-GCM:
527
511
 
528
512
  ```ruby
529
- config = PatientHttp::Configuration.new
530
- config.register_secret(:authorization, "Bearer #{ENV['API_TOKEN']}") # static value
531
- config.register_secret(:api_key) { ENV["MY_API_KEY"] } # lazy block
513
+ PatientHttp.configure do |config|
514
+ config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
515
+ end
532
516
  ```
533
517
 
534
- If a secret is not found when resolving a request, a `PatientHttp::SecretManager::SecretNotFoundError` is raised, which surfaces through the normal request error path.
535
-
536
- #### Module-level registration
537
-
538
- If the `Configuration` is owned by an integration gem (patient_http-sidekiq, patient_http-solid_queue), your application code may not have a convenient reference to it — or may load before it exists. In that case, register secrets at the module level instead:
518
+ To rotate keys, pass an array. The first key encrypts data, and all keys are tried for decryption:
539
519
 
540
520
  ```ruby
541
- PatientHttp.register_secret(:authorization, "Bearer #{ENV['API_TOKEN']}")
542
- PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
521
+ PatientHttp.configure do |config|
522
+ config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
523
+ end
543
524
  ```
544
525
 
545
- Module-level secrets are applied to `PatientHttp.default_configuration` — immediately if one is already set, or as soon as one is set later — so registration order between your application code and the integration gem's configuration does not matter. Integration gems set the default configuration at the end of their configure step; you can also set it yourself:
526
+ To use another encryption library, provide callables that take and return raw bytes as a String:
546
527
 
547
528
  ```ruby
548
- PatientHttp.default_configuration = config
529
+ PatientHttp.configure do |config|
530
+ config.encryption { |bytes| MyEncryption.encrypt(bytes) }
531
+ config.decryption { |bytes| MyEncryption.decrypt(bytes) }
532
+ end
549
533
  ```
550
534
 
551
- Use `PatientHttp.secret_registered?(name)` to check whether a secret is available, either at the module level or on the default configuration.
535
+ You can also pass any object that responds to `call`.
536
+
537
+ Encrypted data is stored as `{"__encrypted__" => true, "value" => "<base64>"}`. The `Encryptor` serializes the original hash to JSON, passes the bytes to your callable, and encodes the result with Base64. A hash without the `"__encrypted__"` key is returned unchanged, so data that was written before you turned on encryption can still be read.
552
538
 
553
- ### Referencing secrets when building a request
539
+ If you write your own `TaskHandler`, see [Build a custom integration](#build-a-custom-integration) for how to add encryption.
554
540
 
555
- Use `PatientHttp.secret(name)` anywhere you would put a sensitive header or query parameter value. No value is needed (or available) at build time:
541
+ ### Payload stores
542
+
543
+ When you register a payload store, any serialized payload larger than `payload_store_threshold` is written to the store instead of the job queue. The queue gets a small reference, which is resolved when the payload is needed. Queue messages stay small, and your application code doesn't change.
556
544
 
557
545
  ```ruby
558
- PatientHttp.get(
559
- "https://api.example.com/data",
560
- callback: MyCallback,
561
- headers: {"Authorization" => PatientHttp.secret(:api_token)},
562
- params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
563
- )
546
+ PatientHttp.configure do |config|
547
+ config.register_payload_store(:redis, adapter: :redis, redis: Redis.new(url: ENV["REDIS_URL"]), ttl: 86_400)
548
+ config.payload_store_threshold = 64 * 1024
549
+ end
564
550
  ```
565
551
 
566
- The request serializes the secret header as `{"$secret" => "api_token"}` and keeps the secret query parameter out of the URL (non-secret params like `page` are still folded into the URL as usual). The processor dereferences both just before sending: the header is set to its resolved value and the resolved query parameter is appended to the URL.
552
+ These adapters are available:
567
553
 
568
- ## Request Preprocessors
554
+ | Adapter | Options | Notes |
555
+ | --- | --- | --- |
556
+ | `:file` | `directory:` | For development and tests only. Hosts don't share the files. |
557
+ | `:redis` | `redis:` (required), `ttl:`, `key_prefix:` (default `"patient_http:payloads:"`) | Requires the `redis` gem. The client must respond to `set`, `get`, `del`, and `exists`. |
558
+ | `:s3` | `bucket:` (required), `key_prefix:` (default `"patient_http/payloads/"`) | Requires the `aws-sdk-s3` gem. |
559
+ | `:active_record` | `model:` | Requires a database table. See the following section. |
569
560
 
570
- Preprocessors let you modify a request just before it is sent — most usefully, to sign it. Signing schemes like AWS SigV4 need to compute values over the final outgoing request (method, URL, headers, body) and set multiple headers, which cannot be expressed as a static header value at build time.
561
+ The Active Record adapter needs the `patient_http_payloads` table. In a Rails app, load the engine in an initializer, and then install and run the migration:
571
562
 
572
- Like secrets, preprocessors are registered on the `Configuration` and referenced from requests by name only. The serialized request carries just the name, so the signing logic and its credentials live on the processor side and are never written to the job queue.
563
+ ```ruby
564
+ require "patient_http/rails/engine"
565
+ ```
573
566
 
574
- ### Defining preprocessors
567
+ ```bash
568
+ bin/rails patient_http:install:migrations
569
+ bin/rails db:migrate
570
+ ```
575
571
 
576
- Register a named preprocessor as a block or callable taking a single argument:
572
+ You can also add the migration yourself:
577
573
 
578
574
  ```ruby
579
- config = PatientHttp::Configuration.new
580
- config.register_preprocessor(:aws_sigv4) do |request|
581
- signer = Aws::Sigv4::Signer.new(
582
- service: "execute-api",
583
- region: "us-east-1",
584
- credentials_provider: Aws::CredentialProviderChain.new.resolve
585
- )
586
- signature = signer.sign_request(
587
- http_method: request.http_method.to_s.upcase,
588
- url: request.url,
589
- headers: request.headers.to_h,
590
- body: request.body.to_s
591
- )
592
- signature.headers.each { |name, value| request.headers[name] = value }
575
+ class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
576
+ def change
577
+ create_table :patient_http_payloads, id: false do |t|
578
+ t.string :key, null: false, limit: 36
579
+ t.text :data, null: false
580
+ t.timestamps
581
+ end
582
+
583
+ add_index :patient_http_payloads, :key, unique: true
584
+ add_index :patient_http_payloads, :created_at
585
+ end
593
586
  end
594
587
  ```
595
588
 
596
- The argument is a `PatientHttp::OutgoingRequest` — a view of the request as it is about to be sent, after all secret references have been resolved and the `x-request-id` and default `User-Agent` headers have been set. It exposes:
589
+ To write your own adapter, subclass `PatientHttp::PayloadStore::Base`, and implement `store_json`, `fetch`, and `delete`. The adapter must be thread-safe.
597
590
 
598
- - `http_method`, `url`, and `body` (read-only; the URL includes any resolved secret query params)
599
- - `headers` — mutable, case-insensitive headers
600
- - `add_param(name, value)` — appends a query parameter to the URL, for signed-query-param schemes
591
+ ```ruby
592
+ class MyStore < PatientHttp::PayloadStore::Base
593
+ register :my_store, self
601
594
 
602
- ### Attaching preprocessors to a request
595
+ def store_json(key, json)
596
+ # Store the JSON string and return the key.
597
+ end
603
598
 
604
- Reference registered preprocessors by name when building a request:
599
+ def fetch(key)
600
+ # Return the parsed hash, or nil if the key isn't found.
601
+ end
605
602
 
606
- ```ruby
607
- PatientHttp.post(
608
- "https://api.example.com/data",
609
- callback: MyCallback,
610
- json: {value: 1},
611
- preprocessors: :aws_sigv4
612
- )
603
+ def delete(key)
604
+ # Delete the data. Don't raise an error if the key doesn't exist.
605
+ end
606
+ end
607
+
608
+ PatientHttp.configure do |config|
609
+ config.register_payload_store(:custom, adapter: :my_store, **options)
610
+ end
613
611
  ```
614
612
 
615
- Multiple preprocessors can be given as an array; they run in order, each seeing the changes made by the ones before it. `RequestTemplate` and the `RequestHelper` mixin's `request_template` also accept `preprocessors:` as a template-wide default, and the mixin's `async_*` helpers accept `preprocessors:` per request.
613
+ To move to a new store, register both stores. The last store registered is used for new writes. All registered stores are available for reads.
616
614
 
617
- If a request references a preprocessor name that is not registered, a `PatientHttp::RequestPreparer::PreprocessorNotFoundError` is raised, which surfaces through the normal request error path.
615
+ To store and fetch payloads yourself, use `PatientHttp::ExternalStorage`:
616
+
617
+ ```ruby
618
+ storage = PatientHttp::ExternalStorage.new(PatientHttp.configuration)
618
619
 
619
- When redirects are followed, preprocessors are re-run against each redirect URL so signatures stay valid. On cross-origin redirects they are dropped entirely, consistent with the stripping of `Authorization` and `Cookie` headers, so signed credentials are never sent to an unexpected origin.
620
+ data = storage.store(response.as_json, max_size: 1024) # Returns the original hash if it's 1KB or smaller.
621
+ storage.storage_ref?(data) # Returns true if the hash was stored.
622
+ storage.fetch(data) # Returns the original hash.
623
+ storage.delete(data) # Deletes the stored payload.
624
+ ```
620
625
 
621
626
  ## Redirects
622
627
 
623
- Redirect responses (300, 301, 302, 303, 307, and 308) with a `Location` header are followed automatically, up to `max_redirects` hops. A 300 response is followed only when the server names a preferred choice in `Location`. Redirect loops raise `RecursiveRedirectError` and exceeding the limit raises `TooManyRedirectsError`. Any redirect that is not followed is delivered to the callback as a normal response.
628
+ A redirect response (300, 301, 302, 303, 307, or 308) with a `Location` header is followed automatically, up to `max_redirects` times. A 300 response is followed only when the `Location` header names the server's preferred choice. A redirect loop raises a `RecursiveRedirectError`, and too many redirects raise a `TooManyRedirectsError`. A redirect that isn't followed is passed to the callback as a normal response.
624
629
 
625
630
  The HTTP method of the redirected request follows RFC 9110:
626
631
 
627
632
  | Status | Method |
628
633
  | --- | --- |
629
- | 301, 302 | `POST` becomes `GET` and the body is dropped. Other methods (including `HEAD`, `PUT`, `DELETE`, and `QUERY`) are preserved with their body. |
630
- | 303 | `GET` and `HEAD` are preserved. Every other method becomes `GET` and the body is dropped. |
631
- | 300, 307, 308 | The method and body are preserved. |
634
+ | 301, 302 | `POST` changes to `GET`, and the body is removed. Other methods, including `HEAD`, `PUT`, `DELETE`, and `QUERY`, keep their method and body. |
635
+ | 303 | `GET` and `HEAD` keep their method. All other methods change to `GET`, and the body is removed. |
636
+ | 300, 307, 308 | The method and body don't change. |
637
+
638
+ The QUERY specification states that the POST-to-GET exception for 301 and 302 doesn't apply to `QUERY`. As a result, a redirected `QUERY` is sent again as a `QUERY` with its body, and a 303 changes it to a `GET`.
632
639
 
633
- The QUERY specification states that the POST-to-GET exception on 301 and 302 does not apply to `QUERY`, so a redirected `QUERY` is re-sent as a `QUERY` with its body, and a 303 turns it into a `GET`.
640
+ ### Prevent method changes
634
641
 
635
- ### Preventing method changes
642
+ To stop following redirects that change the HTTP method, set `follow_method_changing_redirects: false`. For example, a `POST` that gets a 302 then completes with the 302 response, instead of a `GET` to the new location. Redirects that keep the method are still followed, such as a `PUT` that gets a 301, or any method that gets a 307.
636
643
 
637
- Set `follow_method_changing_redirects: false` to stop following redirects that would change the HTTP method. A `POST` that receives a 302 then completes with the 302 response instead of being retried as a `GET`. Redirects that preserve the method (a `PUT` on a 301, or any method on a 307) are still followed. The option can be set on the `Configuration` or on a single `Request`; the request value wins when both are set.
644
+ You can set the option in the configuration or on a request. If both are set, the request value applies.
638
645
 
639
646
  ```ruby
640
- config = PatientHttp::Configuration.new(follow_method_changing_redirects: false)
647
+ PatientHttp.configure do |config|
648
+ config.follow_method_changing_redirects = false
649
+ end
641
650
 
642
- # Or per request
643
- request = PatientHttp::Request.new(:post, "https://api.example.com/submit", body: payload, follow_method_changing_redirects: false)
651
+ # Or for one request.
652
+ PatientHttp.post(url, callback: MyCallback, body: payload, follow_method_changing_redirects: false)
644
653
  ```
645
654
 
646
- ### Stripping headers on redirects
655
+ ### Remove headers on redirects
647
656
 
648
- `Authorization` and `Cookie` headers are always removed on cross-origin redirects. To make sure other sensitive headers are never sent to a redirect target, list them in `redirect_strip_headers`. Header names are matched case insensitively. Listed headers are removed from every redirected request, same-origin or not.
657
+ The `Authorization` and `Cookie` headers are always removed on cross-origin redirects. To keep other sensitive headers from being sent to a redirect target, list them in `redirect_strip_headers`. Header names are case insensitive. The listed headers are removed from every redirected request, same-origin or cross-origin.
649
658
 
650
659
  ```ruby
651
- config = PatientHttp::Configuration.new(redirect_strip_headers: ["X-Api-Key", "X-Internal-Token"])
652
-
653
- # Or per request; these are stripped in addition to the configured headers
654
- request = PatientHttp::Request.new(:get, "https://api.example.com/data", headers: headers, redirect_strip_headers: "X-Signature")
660
+ PatientHttp.configure do |config|
661
+ config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
662
+ end
655
663
 
656
- # The same options are accepted by PatientHttp.request, the async_* helpers, and RequestTemplate
664
+ # Or for one request. These headers are removed in addition to the configured headers.
657
665
  PatientHttp.get("https://api.example.com/data", callback: FetchCallback, redirect_strip_headers: "X-Signature")
658
666
  ```
659
667
 
660
- Per-request header names survive serialization into the job queue, so they apply no matter which process follows the redirect.
668
+ The header names for a request are serialized with it into the job queue, so they apply in the process that follows the redirect.
661
669
 
662
- Stripping applies to the headers set on the request. Preprocessors run again on each same-origin redirect and can add headers after the strip, so a header that a preprocessor sets is sent to the redirect target. When a redirect changes the method and drops the body, the headers that describe the body (`Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location`) are removed as well.
670
+ Only the headers set on the request are removed. Preprocessors run again on each same-origin redirect and can add headers after they're removed. As a result, a header that a preprocessor sets is sent to the redirect target.
671
+
672
+ When a redirect changes the method and removes the body, the headers that describe the body are removed as well: `Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location`.
673
+
674
+ ## Response and error objects
675
+
676
+ `PatientHttp::Response` and the error objects can be serialized to JSON, so they can move through job queues and between processes. Both have `as_json` and `to_json` methods. To create an object from JSON data, use the `load` class method:
677
+
678
+ ```ruby
679
+ response = PatientHttp::Response.load(json_data)
680
+ error = PatientHttp::Error.load(json_data)
681
+ ```
682
+
683
+ A `Response` has the HTTP status code, headers, body, and callback arguments. The error classes, `HttpError`, `RedirectError`, and `RequestError`, have the error message, details about the request, and callback arguments. All error classes are subclasses of `PatientHttp::Error`, and `error.error_type` returns a symbol that identifies the kind of error, such as `:timeout`, `:connection`, or `:http_error`.
684
+
685
+ Request and response header names are case insensitive. A request header with a `nil` or empty value is never sent. If you set a header to `nil` or `""`, the header is removed, and a header Hash such as `{"X-Header" => nil}` doesn't set the header. A response header that occurs more than one time, such as `set-cookie`, becomes one string with the values joined.
686
+
687
+ Response bodies are encoded for JSON serialization. Binary content is encoded with Base64. Large text content is compressed with gzip and then encoded with Base64 to reduce the payload size. The `body` and `json` methods of `Response` decode the body for you.
663
688
 
664
689
  ## Troubleshooting
665
690
 
666
691
  ### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
667
692
 
668
- On some Ruby versions you may see a warning like this in your logs:
693
+ On some Ruby versions, a warning like this can appear in your logs:
669
694
 
670
695
  ```
671
696
  warn: Async::Task: Async::Pool::Controller Gardener [...]
@@ -674,114 +699,122 @@ warn: Async::Task: Async::Pool::Controller Gardener [...]
674
699
  | → .../async-pool-x.y.z/lib/async/pool/controller.rb:132 in `synchronize'
675
700
  ```
676
701
 
677
- This is caused by [Ruby bug #20907](https://bugs.ruby-lang.org/issues/20907) (see also [socketry/async#424](https://github.com/socketry/async/issues/424)): under the fiber scheduler, a fiber interrupted while waiting on a `ConditionVariable` fails to re-acquire its mutex before unwinding, raising a spurious `ThreadError`. It appears whenever a pooled HTTP client is closed while its connection pool's background "gardener" task is idle — for example when a connection is evicted after a connection error, when the least recently used client is evicted because the pool is full, or when the processor shuts down.
702
+ The cause is [Ruby bug #20907](https://bugs.ruby-lang.org/issues/20907). For more information, see [socketry/async#424](https://github.com/socketry/async/issues/424). With the fiber scheduler, a fiber that's interrupted while it waits on a `ConditionVariable` doesn't get its mutex again before it exits, which raises a false `ThreadError`. The warning appears when a pooled HTTP client closes while its connection pool's background gardener task is idle. For example, this happens when the pool evicts a connection after a connection error, when the pool is full and evicts the least recently used client, or when the processor shuts down.
678
703
 
679
- The warning is harmless — connections are still closed correctly; only the log noise is wrong. The fix is to upgrade Ruby: the bug is fixed in Ruby 3.2.7+, 3.3.7+, and 3.4+.
704
+ The warning is harmless. The connections still close correctly. To remove the warning, upgrade Ruby. The bug is fixed in Ruby 3.2.7 and later, 3.3.7 and later, and 3.4 and later.
680
705
 
681
- ## Configuration
706
+ ## Build a custom integration
682
707
 
683
- ```ruby
684
- config = PatientHttp::Configuration.new(
685
- # Maximum concurrent HTTP requests (default: 256)
686
- max_connections: 256,
708
+ This section explains how to integrate the gem with a job system that doesn't have an integration gem. If you use Sidekiq or Solid Queue, the integration gems do all of this for you.
687
709
 
688
- # Default timeout for HTTP requests in seconds (default: 60)
689
- request_timeout: 60,
710
+ An integration has these parts:
690
711
 
691
- # Timeout for graceful shutdown in seconds (default: 30)
692
- shutdown_timeout: 30,
712
+ - A `TaskHandler` that delivers results to your job system.
713
+ - A `Processor` that runs requests.
714
+ - A registered request handler, so that application code can use the `PatientHttp` module methods.
693
715
 
694
- # Maximum response body size in bytes (default: 1MB)
695
- max_response_size: 1024 * 1024,
716
+ ### Create a task handler
696
717
 
697
- # Default User-Agent header (default: "PatientHttp")
698
- user_agent: "MyApp/1.0",
718
+ The `TaskHandler` connects the processor to your job system:
699
719
 
700
- # Treat non-2xx responses as errors by default (default: false)
701
- raise_error_responses: false,
720
+ ```ruby
721
+ class MyTaskHandler < PatientHttp::TaskHandler
722
+ def initialize(job_id)
723
+ @job_id = job_id
724
+ end
702
725
 
703
- # Maximum redirects to follow (default: 5, 0 disables)
704
- max_redirects: 5,
726
+ def on_complete(response, callback)
727
+ MyJobSystem.enqueue(callback, :on_complete, response.as_json)
728
+ end
705
729
 
706
- # Follow redirects that must change the HTTP method, such as POST to GET on
707
- # a 302 (default: true). When false, those requests receive the redirect response.
708
- follow_method_changing_redirects: true,
730
+ def on_error(error, callback)
731
+ MyJobSystem.enqueue(callback, :on_error, error.as_json)
732
+ end
733
+
734
+ def retry
735
+ # Re-enqueue the original job when the processor shuts down before the
736
+ # request finishes.
737
+ MyJobSystem.enqueue_job(@job_id)
738
+ end
739
+ end
740
+ ```
709
741
 
710
- # Header names (case insensitive) always stripped from redirected requests
711
- # (default: []). Authorization and Cookie are always stripped on cross-origin
712
- # redirects.
713
- redirect_strip_headers: ["X-Api-Key", "X-Internal-Token"],
742
+ > [!IMPORTANT]
743
+ > Task handler callbacks run on the processor's completion worker threads (see `completion_threads`), not on the reactor thread, so they don't block the event loop. Keep them lightweight anyway. Usually, a callback only enqueues a message for another system. Heavy callbacks compete with the reactor for the GVL. A task also counts against capacity until its result is delivered, so slow callbacks reduce request capacity.
744
+ >
745
+ > Callbacks must be thread-safe. By default, two completion worker threads deliver results concurrently. As a result, two callbacks can run at the same time, in an order unrelated to the order in which the requests finished. To deliver results one at a time, set `completion_threads` to 1.
746
+ >
747
+ > Callbacks must also be idempotent. A callback that raises an error is retried `completion_retries` times (default 2). If a callback raises an error after it enqueues its message, the message is enqueued again. If that isn't acceptable, set `completion_retries` to 0.
714
748
 
715
- # Maximum number of hosts to maintain persistent connections for (default: 100)
716
- connection_pool_size: 100,
749
+ The task handler is responsible for encryption. The base class doesn't encrypt or decrypt data. Use `Configuration#encryptor` each time you serialize data:
717
750
 
718
- # Connection timeout in seconds (default: nil, uses request_timeout)
719
- connection_timeout: 10,
751
+ ```ruby
752
+ def on_complete(response, callback)
753
+ encrypted = @configuration.encryptor.encrypt(response.as_json)
754
+ MyJobSystem.enqueue(callback, :on_complete, encrypted)
755
+ end
756
+ ```
720
757
 
721
- # HTTP/HTTPS proxy URL (default: nil)
722
- proxy_url: "http://proxy.example.com:8080",
758
+ Decrypt the data before you create the object again:
723
759
 
724
- # Retries for failed requests (default: 3)
725
- retries: 3,
760
+ ```ruby
761
+ response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
762
+ ```
726
763
 
727
- # Force the HTTP protocol to :http1 or :http2 (default: nil, negotiates with
728
- # the server, preferring HTTP/2 for HTTPS). Forcing :http1 also limits the TLS
729
- # ALPN advertisement to http/1.1, which can work around SSL-intercepting
730
- # proxies that mishandle HTTP/2.
731
- protocol: nil,
764
+ ### Run a processor
732
765
 
733
- # Logger instance (default: Logger to STDERR at ERROR level)
734
- logger: Logger.new($stdout)
735
- )
766
+ ```ruby
767
+ config = PatientHttp::Configuration.new(max_connections: 256, request_timeout: 60)
768
+ processor = PatientHttp::Processor.new(config)
769
+ processor.start
736
770
 
737
- # Register named secrets to reference sensitive headers/params indirectly (see Secrets)
738
- config.register_secret(:api_token, ENV["MY_API_TOKEN"])
771
+ task = PatientHttp::RequestTask.new(
772
+ request: PatientHttp::Request.new(:get, "https://api.example.com/users/123"),
773
+ task_handler: MyTaskHandler.new("job-123"),
774
+ callback: "FetchUserCallback",
775
+ callback_args: {user_id: 123}
776
+ )
777
+ processor.enqueue(task)
739
778
  ```
740
779
 
741
- ### Tuning Tips
780
+ If the processor isn't running, `enqueue` raises a `PatientHttp::NotRunningError`. If the processor is at `max_connections`, it raises a `PatientHttp::MaxCapacityError`.
742
781
 
743
- - **max_connections**: Each connection uses memory and file descriptors. A tuned system can handle thousands.
744
- - **max_connections_per_host**: Bounds sockets per host (default unlimited). Set a value such as 32 for high-concurrency deployments so one host cannot consume every file descriptor. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections.
745
- - **request_timeout**: Set based on expected API response times. AI/LLM APIs may need minutes.
746
- - **connection_pool_size**: Increase for applications calling many different API hosts.
747
- - **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage. The limit applies to the inflated bytes of compressed responses.
748
- - **Response compression**: Requests ask for `gzip` by default and the body is inflated on a completion worker thread. Set `accept-encoding` on a request to change this: `identity` skips compression, and any other encoding is delivered still encoded with its `content-encoding` header kept so you can decode it yourself.
749
- - **completion_threads**: Number of threads that decode responses and deliver results (default 2). Increase when callbacks do heavier work (serialization, encryption) and completions back up behind them. Any value above 1 delivers results concurrently, so `TaskHandler` callbacks and completion-time observers must be thread-safe. Use 1 to serialize delivery.
750
- - **completion_retries**: Delivery retries before a result is reported through `completion_failed` (default 2). A retry calls `on_complete`/`on_error` again, so a handler that raises *after* enqueuing its message delivers that message twice. Make handlers idempotent, or set `completion_retries: 0` to report the first failure without retrying.
751
- - **shutdown_timeout**: Set below the process supervisor's termination window so the drain (including handed-off completions) finishes before a hard kill.
782
+ A process can run several named processors, each with its own capacity and threads:
752
783
 
753
- ## Processor Lifecycle
784
+ ```ruby
785
+ PatientHttp::Processor.new(config, name: :llm)
786
+ ```
787
+
788
+ A request has an optional `processor` name, which is serialized with the request. Integrations use this name to send the request to a processor.
754
789
 
755
- The processor transitions through these states:
790
+ ### Processor lifecycle
756
791
 
757
792
  ```
758
793
  stopped -> starting -> running -> draining -> stopping -> stopped
759
794
  ```
760
795
 
761
- - **stopped**: Not processing requests
762
- - **starting**: Initializing the reactor thread
763
- - **running**: Accepting and processing requests
764
- - **draining**: Rejecting new requests, completing in-flight ones
765
- - **stopping**: Shutting down, re-enqueuing incomplete requests
796
+ - `stopped`: The processor doesn't run requests.
797
+ - `starting`: The processor starts the reactor thread.
798
+ - `running`: The processor accepts and runs requests.
799
+ - `draining`: The processor rejects new requests and finishes in-flight requests.
800
+ - `stopping`: The processor shuts down and re-enqueues requests that didn't finish.
766
801
 
767
802
  ```ruby
768
- processor = PatientHttp::Processor.new(config)
769
-
770
- processor.start # Start processing
803
+ processor.start # Start the processor.
771
804
  processor.running? # => true
772
805
 
773
- processor.drain # Stop accepting new requests
806
+ processor.drain # Stop accepting new requests.
774
807
  processor.draining? # => true
775
808
 
776
- processor.stop(timeout: 25) # Graceful shutdown
809
+ processor.stop(timeout: 25) # Shut down gracefully.
777
810
  processor.stopped? # => true
778
811
  ```
779
812
 
780
- When the processor stops with in-flight requests, it calls `TaskHandler#retry` on each incomplete task so they can be re-enqueued.
813
+ When the processor stops with in-flight requests, it calls `TaskHandler#retry` for each request that didn't finish, so that the job system can enqueue it again.
781
814
 
782
- ### Observing the Processor
815
+ ### Observe the processor
783
816
 
784
- Register observers to monitor processor events:
817
+ To receive processor events, subclass `PatientHttp::ProcessorObserver`, and override the methods for the events that you need:
785
818
 
786
819
  ```ruby
787
820
  class MetricsObserver < PatientHttp::ProcessorObserver
@@ -805,27 +838,48 @@ end
805
838
  processor.observe(MetricsObserver.new)
806
839
  ```
807
840
 
808
- Observers can also track the full task pipeline:
841
+ Observers can also track each task through the processor:
809
842
 
810
- - `request_enqueued(request_task)` is called when a task is announced to the processor (before the task is visible to the reactor). It is guaranteed to arrive before `request_start`, so observers can set up durable tracking (e.g. a crash-recovery registry entry) before `Processor#enqueue` returns or raises.
811
- - `request_rejected(request_task)` is called when an announced task is not accepted (not running or at capacity), so observers can tear down anything they set up in `request_enqueued`.
812
- - `request_requeued(request_task)` is called when an incomplete task is re-enqueued through its task handler (processor shutdown or reactor failure). The job system owns the request again once this is sent.
843
+ - `request_enqueued(request_task)`: Runs when a task is given to the processor, before the reactor can see it. This method always runs before `request_start`. As a result, observers can set up durable tracking, such as a crash-recovery registry entry, before `Processor#enqueue` returns or raises an error.
844
+ - `request_rejected(request_task)`: Runs when the processor doesn't accept a task, because it isn't running or is at capacity. Remove anything that `request_enqueued` set up.
845
+ - `request_requeued(request_task)`: Runs when a task that didn't finish is re-enqueued through its task handler, because the processor shut down or the reactor failed. After this call, the job system owns the request again.
846
+ - `completion_failed(request_task, error)`: Runs when a result can't be delivered after all retries. `request_end` doesn't run in this case, so durable tracking stays in place for external recovery.
813
847
 
814
- Use `Processor#tracked_request_ids` to get the IDs of all tasks in the pipeline (queued, pending, and in-flight), for example to keep heartbeats alive for tasks that have not started yet.
848
+ To get the IDs of all queued, pending, and in-flight tasks, call `Processor#tracked_request_ids`. For example, use it to update heartbeats for tasks that haven't started yet.
815
849
 
816
- ## Testing
850
+ For the thread that runs each observer method, see the `ProcessorObserver` documentation. Observers must be thread-safe.
817
851
 
818
- Use `SynchronousExecutor` to execute requests synchronously in tests. This class can be used in place of the async processor for testing your request handling logic without needing to start the full async infrastructure.
852
+ ### Register a request handler
819
853
 
820
- It is integrated automatically in the [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gems.
854
+ To let application code use the `PatientHttp` module methods, register a request handler. The handler builds each task:
821
855
 
822
856
  ```ruby
823
- task = PatientHttp::RequestTask.new(
824
- request: request,
825
- task_handler: handler,
826
- callback: "MyCallback"
827
- )
857
+ PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
858
+ task = PatientHttp::RequestTask.new(
859
+ request: request,
860
+ task_handler: MyTaskHandler.new(MyJobSystem.current_job_id),
861
+ callback: callback,
862
+ callback_args: callback_args,
863
+ raise_error_responses: raise_error_responses
864
+ )
865
+ processor.enqueue(task)
866
+ task.id
867
+ end
868
+ ```
869
+
870
+ To raise an error if a handler is already registered, use `PatientHttp.register_handler!`. To check whether a handler is registered, call `PatientHttp.handler_registered?`.
871
+
872
+ To make `PatientHttp.configure` yield your own configuration class, register the integration as the configuration provider. The provider must respond to `new_configuration`, which returns a new configuration, and to `configure`:
873
+
874
+ ```ruby
875
+ PatientHttp.register_configuration_provider(MyIntegration)
876
+ ```
877
+
878
+ ### Test an integration
828
879
 
880
+ To test integration code without the async processor, use `SynchronousExecutor`. It runs a request on the calling thread, runs the optional hooks, and then calls the callback service:
881
+
882
+ ```ruby
829
883
  executor = PatientHttp::SynchronousExecutor.new(
830
884
  task,
831
885
  config: config,
@@ -836,25 +890,23 @@ executor = PatientHttp::SynchronousExecutor.new(
836
890
  executor.call
837
891
  ```
838
892
 
839
- ## Integration
840
-
841
- For Sidekiq integration, see the [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) gem which provides workers, lifecycle hooks, crash recovery, and a Web UI built on this library.
842
-
843
- For Solid Queue integration, see the [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gem which provides similar functionality for Solid Queue.
844
-
845
- When using an integration gem, you can use the [standard interface](#standard-interface) to make requests without coupling your code to the underlying processor or task handler implementations.
893
+ ## Installation
846
894
 
847
- For large language model (LLM) requests, see the [patient_llm](https://github.com/bdurand/patient_llm) gem which provides an integration for making LLM requests asynchronously via a variety of protocols.
895
+ Most applications need only the integration gem for their job system, which depends on this gem:
848
896
 
849
- ## Installation
897
+ ```ruby
898
+ gem "patient_http-sidekiq"
899
+ # or
900
+ gem "patient_http-solid_queue"
901
+ ```
850
902
 
851
- Add this line to your application's Gemfile:
903
+ To use this gem on its own, add it to your Gemfile:
852
904
 
853
905
  ```ruby
854
906
  gem "patient_http"
855
907
  ```
856
908
 
857
- Then execute:
909
+ Then install it:
858
910
 
859
911
  ```bash
860
912
  bundle install
@@ -864,13 +916,17 @@ bundle install
864
916
 
865
917
  Open a pull request on [GitHub](https://github.com/bdurand/patient_http).
866
918
 
867
- Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
919
+ Follow the [standardrb](https://github.com/testdouble/standard) style, and run `standardrb --fix` before you submit a pull request.
920
+
921
+ Run the tests:
868
922
 
869
- The [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gems each provide a test application for integration testing.
923
+ ```bash
924
+ bundle exec rspec
925
+ ```
870
926
 
871
- Running the full test suite requires running the included docker-compose.yml file to start up a valkey and s3mock server.
927
+ The [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gems each have a test app for integration testing.
872
928
 
873
- ## Further Reading
929
+ ## Further reading
874
930
 
875
931
  - [Architecture](ARCHITECTURE.md)
876
932