patient_http 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -7
  3. data/CHANGELOG.md +39 -0
  4. data/README.md +540 -514
  5. data/VERSION +1 -1
  6. data/lib/patient_http/callback_args.rb +53 -48
  7. data/lib/patient_http/callback_validator.rb +11 -7
  8. data/lib/patient_http/class_helper.rb +6 -7
  9. data/lib/patient_http/client.rb +28 -22
  10. data/lib/patient_http/client_pool.rb +134 -39
  11. data/lib/patient_http/completion_executor.rb +20 -20
  12. data/lib/patient_http/configuration.rb +367 -119
  13. data/lib/patient_http/connection_endpoint.rb +150 -0
  14. data/lib/patient_http/encryptor.rb +28 -18
  15. data/lib/patient_http/error.rb +24 -18
  16. data/lib/patient_http/external_storage.rb +42 -38
  17. data/lib/patient_http/http_error.rb +30 -26
  18. data/lib/patient_http/http_headers.rb +57 -26
  19. data/lib/patient_http/immediate_retries.rb +98 -0
  20. data/lib/patient_http/inline_task_handler.rb +15 -10
  21. data/lib/patient_http/lifecycle_manager.rb +39 -40
  22. data/lib/patient_http/outgoing_request.rb +25 -23
  23. data/lib/patient_http/payload.rb +28 -26
  24. data/lib/patient_http/payload_store/active_record_store.rb +31 -34
  25. data/lib/patient_http/payload_store/base.rb +42 -46
  26. data/lib/patient_http/payload_store/file_store.rb +22 -26
  27. data/lib/patient_http/payload_store/redis_store.rb +28 -34
  28. data/lib/patient_http/payload_store/s3_store.rb +25 -28
  29. data/lib/patient_http/payload_store.rb +2 -0
  30. data/lib/patient_http/processor.rb +111 -79
  31. data/lib/patient_http/processor_observer.rb +65 -59
  32. data/lib/patient_http/rails/engine.rb +13 -8
  33. data/lib/patient_http/redirect_error.rb +50 -41
  34. data/lib/patient_http/redirect_helper.rb +38 -38
  35. data/lib/patient_http/request.rb +70 -46
  36. data/lib/patient_http/request_error.rb +47 -42
  37. data/lib/patient_http/request_helper.rb +142 -119
  38. data/lib/patient_http/request_preparer.rb +13 -10
  39. data/lib/patient_http/request_task.rb +113 -84
  40. data/lib/patient_http/request_template.rb +87 -64
  41. data/lib/patient_http/response.rb +58 -52
  42. data/lib/patient_http/response_reader.rb +66 -65
  43. data/lib/patient_http/secret_manager.rb +34 -30
  44. data/lib/patient_http/secret_reference.rb +33 -26
  45. data/lib/patient_http/synchronous_executor.rb +67 -95
  46. data/lib/patient_http/task_handler.rb +23 -19
  47. data/lib/patient_http/time_helper.rb +8 -8
  48. data/lib/patient_http.rb +311 -186
  49. data/patient_http.gemspec +3 -2
  50. metadata +20 -4
data/README.md CHANGED
@@ -6,230 +6,222 @@
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!
90
74
  ```
91
75
 
92
- ### 3. Process Callbacks
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.
93
77
 
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).
78
+ ```ruby
79
+ PatientHttp.inline!
80
+ PatientHttp.get("https://api.example.com/users/123", callback: FetchUserCallback)
81
+ # FetchUserCallback#on_complete has already run.
82
+ ```
83
+
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
89
+
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
+ | `raise_error_responses:` | Whether to treat non-2xx responses as errors. See [Handle HTTP error responses](#handle-http-error-responses). |
113
+ | `max_redirects:` | The maximum number of redirects to follow for this request. `0` turns off redirects. |
114
+ | `follow_method_changing_redirects:` | Whether to follow redirects that change the HTTP method. See [Redirects](#redirects). |
115
+ | `redirect_strip_headers:` | The names of headers to remove from redirected requests. |
116
+ | `preprocessors:` | The names of registered [preprocessors](#request-preprocessors) to run on the request. |
117
+ | `processor:` | The name of the processor that runs the request. See [Named processors](#named-processors). |
118
+
119
+ For more control, build a `PatientHttp::Request` object and pass it to `PatientHttp.execute`:
120
+
121
+ ```ruby
122
+ request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
123
+ headers: {"Authorization" => PatientHttp.secret(:api_token)},
124
+ params: {include: "profile"},
125
+ timeout: 30
126
+ )
127
+ PatientHttp.execute(request: request, callback: FetchUserCallback, callback_args: {user_id: 123})
109
128
  ```
110
129
 
111
- ## Handling HTTP Error Responses
130
+ Application code that uses these methods never names the job system. To move to another job system, change the integration gem in your Gemfile.
112
131
 
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:
132
+ ### Handle HTTP error responses
133
+
134
+ 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
135
 
115
136
  ```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)
137
+ class ApiCallback
138
+ def on_complete(response)
139
+ if response.success?
140
+ process_data(response.json)
141
+ elsif response.client_error?
142
+ handle_client_error(response.status, response.body)
143
+ elsif response.server_error?
144
+ handle_server_error(response.status, response.body)
145
+ end
146
+ end
147
+
148
+ def on_error(error)
149
+ Rails.logger.error("Request failed: #{error.message}")
123
150
  end
124
151
  end
125
152
  ```
126
153
 
127
- To treat non-2xx responses as errors instead, set `raise_error_responses: true` on the `RequestTask`:
154
+ 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
155
 
129
156
  ```ruby
130
- task = PatientHttp::RequestTask.new(
131
- request: request,
132
- task_handler: handler,
133
- callback: "ApiCallback",
134
- raise_error_responses: true
135
- )
157
+ PatientHttp.get("https://api.example.com/data", callback: ApiCallback, raise_error_responses: true)
136
158
  ```
137
159
 
138
- When enabled, non-2xx responses call `TaskHandler#on_error` with an `HttpError` that provides access to the response:
160
+ An `HttpError` gives you access to the request and the response:
139
161
 
140
162
  ```ruby
141
163
  def on_error(error)
142
164
  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
165
+ puts error.status # HTTP status code
166
+ puts error.url # Request URL
167
+ puts error.http_method # HTTP method
168
+ puts error.response.body # Response body
169
+ puts error.response.headers # Response headers
170
+ puts error.response.json # Response body parsed as JSON
146
171
  end
147
172
  end
148
173
  ```
149
174
 
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.
175
+ A 4xx response raises a `PatientHttp::ClientError`, and a 5xx response raises a `PatientHttp::ServerError`. Both are subclasses of `HttpError`.
167
176
 
168
- ## Standard Interface
177
+ To make `raise_error_responses` the default for every request, set it in the [configuration](#configuration).
169
178
 
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.
179
+ ### Callback arguments
171
180
 
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.
181
+ To pass data to your callbacks, use the `callback_args` option:
173
182
 
174
183
  ```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
184
  PatientHttp.get(
193
- "https://api.example.com/users/123",
185
+ "https://api.example.com/users/#{user_id}",
194
186
  callback: FetchUserCallback,
195
- callback_args: {user_id: 123}
187
+ callback_args: {user_id: user_id, requested_at: Time.now.iso8601}
196
188
  )
197
189
  ```
198
190
 
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:
191
+ The `response.callback_args` and `error.callback_args` methods return the arguments:
204
192
 
205
193
  ```ruby
206
- PatientHttp.inline!
194
+ response.callback_args[:user_id]
195
+ response.callback_args["user_id"]
207
196
  ```
208
197
 
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.
198
+ The `callback_args` value follows these rules:
210
199
 
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
- ```
200
+ - 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`.
201
+ - Hash keys are converted to strings, including the keys of nested hashes and of hashes in arrays.
202
+ - You can read the arguments with symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`.
203
+ - Reading a key that isn't set raises a `KeyError`. To get a default value instead, use `callback_args.fetch(:user_id, nil)`.
216
204
 
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:
205
+ ### Use request templates
206
+
207
+ To share settings across requests to the same API, use `PatientHttp::RequestTemplate`:
218
208
 
219
209
  ```ruby
220
- PatientHttp.inline!(config: PatientHttp::Configuration.new(raise_error_responses: true))
221
- ```
210
+ template = PatientHttp::RequestTemplate.new(
211
+ base_url: "https://api.example.com",
212
+ headers: {"Authorization" => PatientHttp.secret(:api_token)},
213
+ timeout: 60
214
+ )
222
215
 
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:)`.
216
+ request = template.get("/users/123")
217
+ PatientHttp.execute(request: request, callback: FetchUserCallback)
218
+ ```
224
219
 
225
- ### RequestHelper Mixin
220
+ 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.
226
221
 
227
- Use `PatientHttp::RequestHelper` when you want a simple API for creating and dispatching async HTTP requests directly from your class.
222
+ ### Use the RequestHelper module
228
223
 
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`.
224
+ 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
225
 
234
226
  ```ruby
235
227
  class ApiClient
@@ -237,435 +229,436 @@ class ApiClient
237
229
 
238
230
  request_template(
239
231
  base_url: "https://api.example.com",
240
- headers: {"Authorization" => "Bearer #{ENV["API_KEY"]}"},
232
+ headers: {"Authorization" => PatientHttp.secret(:api_token)},
241
233
  timeout: 60
242
234
  )
243
235
 
244
236
  def fetch_user(user_id)
245
- async_get(
246
- "/users/#{user_id}",
247
- callback: FetchUserCallback,
248
- callback_args: {user_id: user_id}
249
- )
237
+ async_get("/users/#{user_id}", callback: FetchUserCallback, callback_args: {user_id: user_id})
250
238
  end
251
239
 
252
240
  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
- )
241
+ async_patch("/users/#{user_id}", json: data, callback: UpdateUserCallback, callback_args: {user_id: user_id})
259
242
  end
260
243
  end
261
244
  ```
262
245
 
263
- ## Callback Arguments
246
+ 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
247
 
265
- Pass custom data through the request/response cycle using `callback_args`:
248
+ ## Configuration
266
249
 
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
- ```
250
+ 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.
275
251
 
276
- Callback arguments are available on both `Response` and `Error` objects:
252
+ Every call yields the same configuration object, so options accumulate. Several initializers can each set options without overwriting one another.
277
253
 
278
254
  ```ruby
279
- response.callback_args[:user_id] # Symbol access
280
- response.callback_args["user_id"] # String access
281
- ```
282
-
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.
255
+ PatientHttp.configure do |config|
256
+ # Maximum concurrent HTTP requests (default: 256).
257
+ config.max_connections = 256
284
258
 
285
- ## Response and Error Objects
259
+ # Maximum connections to each host (default: nil, no limit).
260
+ config.max_connections_per_host = 32
286
261
 
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.
262
+ # Default timeout for HTTP requests in seconds (default: 60).
263
+ config.request_timeout = 60
288
264
 
289
- Both response and error objects provide `as_json` and `to_json` methods for serialization:
265
+ # Timeout for graceful shutdown in seconds (default: 30).
266
+ config.shutdown_timeout = 30
290
267
 
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
268
+ # Maximum response body size in bytes (default: 1MB). Larger responses raise
269
+ # ResponseTooLargeError.
270
+ config.max_response_size = 1024 * 1024
296
271
 
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
- ```
272
+ # Default User-Agent header for all requests (default: "PatientHttp").
273
+ config.user_agent = "MyApp/1.0"
302
274
 
303
- When deserializing, use the `load` class methods to reconstruct the objects:
275
+ # Whether to raise HttpError for non-2xx responses by default (default: false).
276
+ config.raise_error_responses = false
304
277
 
305
- ```ruby
306
- response = PatientHttp::Response.load(json_data)
307
- error = PatientHttp::HttpError.load(json_data)
308
- ```
278
+ # Maximum number of redirects to follow (default: 5; 0 turns off redirects).
279
+ config.max_redirects = 5
309
280
 
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.
281
+ # Whether to follow redirects that change the HTTP method, such as POST to
282
+ # GET on a 302 (default: true). If false, the request gets the redirect
283
+ # response.
284
+ config.follow_method_changing_redirects = true
311
285
 
312
- Response headers are case insensitive. Headers that appear multiple times in the response (such as `set-cookie`) are flattened into a single joined string value.
286
+ # Names of headers to remove from all redirected requests (default: []).
287
+ # Names are case insensitive. Authorization and Cookie are always removed on
288
+ # cross-origin redirects.
289
+ config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
313
290
 
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.
291
+ # Maximum number of host clients to pool (default: 100).
292
+ config.connection_pool_size = 100
315
293
 
316
- ### Payload Stores
294
+ # Timeout in seconds to open a connection, including the TCP connect and the
295
+ # TLS handshake (default: nil, no limit). It doesn't limit the wait for a
296
+ # response; request_timeout does that.
297
+ config.connection_timeout = 10
317
298
 
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.
299
+ # TCP keepalive for pooled connections (default: nil, the kernel sends no
300
+ # probes). A number sets the idle seconds before the first probe. A Hash also
301
+ # sets the interval and the probe count, for example
302
+ # {idle: 30, interval: 10, count: 3}. The Hash must contain :idle. The
303
+ # :interval default is 10 seconds, and the :count default is 3 probes.
304
+ config.tcp_keepalive = 30
319
305
 
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.
306
+ # Seconds that sent data can stay unacknowledged before the kernel closes the
307
+ # connection (default: nil, the kernel default applies). Sets
308
+ # TCP_USER_TIMEOUT, which is available only on Linux.
309
+ config.tcp_user_timeout = 30
321
310
 
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")
311
+ # HTTP or HTTPS proxy URL (default: nil). Supports authentication, for
312
+ # example "http://user:pass@proxy.example.com:8080".
313
+ config.proxy_url = "http://proxy.example.com:8080"
325
314
 
326
- # Use the ExternalStorage class to set and fetch stored payloads in your callbacks.
327
- storage = PatientHttp::ExternalStorage.new(config)
315
+ # Number of retries for failed requests (default: 3).
316
+ config.retries = 3
328
317
 
329
- large_response_data = storage.store(large_response.as_json)
330
- # Returns a reference like: {"$ref" => {"store" => "my_store", "key" => "abc123"}}
318
+ # HTTP protocol, :http1 or :http2 (default: nil, negotiated with the server,
319
+ # with HTTP/2 preferred for HTTPS). The :http1 value also limits the TLS ALPN
320
+ # advertisement to http/1.1, which can work around proxies that intercept SSL
321
+ # and don't handle HTTP/2 correctly.
322
+ config.protocol = nil
331
323
 
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.
324
+ # Number of threads that decode responses and deliver results (default: 2).
325
+ config.completion_threads = 2
334
326
 
335
- storage.storage_ref?(large_response_data) # => true
336
- storage.storage_ref?(small_response_data) # => false
327
+ # Number of times to retry result delivery before the failure is reported
328
+ # (default: 2).
329
+ config.completion_retries = 2
337
330
 
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
331
+ # Size in bytes above which payloads are stored externally when a payload
332
+ # store is configured (default: 64KB).
333
+ config.payload_store_threshold = 64 * 1024
340
334
 
341
- storage.delete(large_response_data) # Deletes the stored payload
335
+ # Logger (default: a Logger that writes errors to standard error).
336
+ config.logger = Rails.logger
337
+ end
342
338
  ```
343
339
 
344
- #### File Store
340
+ `PatientHttp.configuration` returns the same object outside a `configure` block.
345
341
 
346
- For development and testing:
342
+ 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).
347
343
 
348
- ```ruby
349
- config.register_payload_store(:files, adapter: :file, directory: "/tmp/payloads")
350
- ```
344
+ ### Named processors
351
345
 
352
- #### Redis Store
346
+ Named processors are available only when an integration gem is loaded. The integration gems add the `config.processor` method.
353
347
 
354
- For production with shared state across processes (requires the `redis` gem; the client must respond to `set`, `get`, `del`, and `exists`):
348
+ 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:
355
349
 
356
350
  ```ruby
357
- redis = Redis.new(url: ENV["REDIS_URL"])
358
- config.register_payload_store(:redis, adapter: :redis, redis: redis, ttl: 86400)
351
+ PatientHttp.configure do |config|
352
+ config.processor(:llm, max_connections: 200, request_timeout: 120)
353
+ config.processor(:webhooks, max_connections: 64, request_timeout: 10)
354
+ end
359
355
  ```
360
356
 
361
- Options: `redis:` (required), `ttl:` (seconds, optional), `key_prefix:` (default: `"patient_http:payloads:"`)
362
-
363
- #### S3 Store
357
+ 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.
364
358
 
365
- For durable storage across instances (requires `aws-sdk-s3` gem):
359
+ To send a request to a processor, set the `processor:` option on the request method, on a `Request`, or on a `RequestTemplate`:
366
360
 
367
361
  ```ruby
368
- s3 = Aws::S3::Resource.new
369
- bucket = s3.bucket("my-payloads-bucket")
370
- config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
362
+ PatientHttp.post(url, callback: MyCallback, processor: :llm)
371
363
  ```
372
364
 
373
- Options: `bucket:` (required), `key_prefix:` (default: `"patient_http/payloads/"`)
365
+ For how the processor name is kept through retries and crash recovery, see the documentation for your integration gem.
374
366
 
375
- #### ActiveRecord Store
367
+ ### Tuning tips
376
368
 
377
- For database-backed storage with transactional guarantees:
369
+ - `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.
370
+ - `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.
371
+ - `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.
372
+ - `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.
373
+ - `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.
374
+ - `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.
375
+ - `connection_pool_size`: Sets the maximum number of hosts whose connections are kept open. Increase it if your application calls many different hosts.
376
+ - `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. For large responses, consider a [payload store](#payload-stores).
377
+ - `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.
378
+ - `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.
379
+ - `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.
378
380
 
379
- ```ruby
380
- config.register_payload_store(:database, adapter: :active_record)
381
- ```
381
+ ### Response compression
382
382
 
383
- This requires a database migration. Copy the migration from the gem:
383
+ 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:
384
384
 
385
- ```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
385
+ - `identity` turns off compression.
386
+ - Any other encoding is delivered still encoded, with its `content-encoding` header, so that you can decode it yourself.
394
387
 
395
- add_index :patient_http_payloads, :key, unique: true
396
- add_index :patient_http_payloads, :created_at
397
- end
398
- end
399
- ```
388
+ ## Sensitive and large payloads
400
389
 
401
- Options: `model:` (optional, defaults to built-in `PatientHttp::PayloadStore::ActiveRecordStore::Payload`)
390
+ 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.
402
391
 
403
- #### Custom Stores
392
+ These features cover both cases. They work together:
404
393
 
405
- Implement your own by subclassing `PatientHttp::PayloadStore::Base`:
394
+ | Feature | When to use it | How to register it |
395
+ | --- | --- | --- |
396
+ | [Secrets](#secrets) | A sensitive header or query parameter, such as an API token. | `config.register_secret` |
397
+ | [Preprocessors](#request-preprocessors) | A signature calculated from the final request, such as AWS SigV4. | `config.register_preprocessor` |
398
+ | [Encryption](#encryption) | The request or response body is sensitive. | `config.encryption_key` |
399
+ | [Payload stores](#payload-stores) | Payloads are too large for the queue. | `config.register_payload_store` |
406
400
 
407
- ```ruby
408
- class MyStore < PatientHttp::PayloadStore::Base
409
- register :my_store, self
401
+ 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.
410
402
 
411
- def store(key, data)
412
- # Store the hash and return the key
413
- end
403
+ ### Secrets
414
404
 
415
- def fetch(key)
416
- # Return the hash or nil if not found
417
- end
405
+ 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.
418
406
 
419
- def delete(key)
420
- # Delete the data (idempotent)
421
- end
422
- end
407
+ Register a secret with a value, or with a block that runs each time the secret is resolved:
423
408
 
424
- config.register_payload_store(:custom, adapter: :my_store, **options)
409
+ ```ruby
410
+ PatientHttp.configure do |config|
411
+ config.register_secret(:authorization, "Bearer #{ENV["API_TOKEN"]}")
412
+ config.register_secret(:api_key) { ENV["MY_API_KEY"] }
413
+ end
425
414
  ```
426
415
 
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.
428
-
429
- ## Encryption
430
-
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.
416
+ 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:
432
417
 
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.
434
-
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.
418
+ ```ruby
419
+ PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
420
+ ```
436
421
 
437
- ### Using an encryption key
422
+ 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)`.
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
+ Use a secret reference as a header or query parameter value:
440
425
 
441
426
  ```ruby
442
- config = PatientHttp::Configuration.new
443
- config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
427
+ PatientHttp.get(
428
+ "https://api.example.com/data",
429
+ callback: MyCallback,
430
+ headers: {"Authorization" => PatientHttp.secret(:authorization)},
431
+ params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
432
+ )
444
433
  ```
445
434
 
446
- To support key rotation, pass an array — the first key encrypts new data, and all keys attempt decryption:
435
+ 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`.
447
436
 
448
- ```ruby
449
- config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
450
- ```
437
+ ### Request preprocessors
451
438
 
452
- ### Using custom callables
439
+ 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.
453
440
 
454
- For custom encryption libraries, provide callables that accept and return raw bytes (String):
441
+ 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.
455
442
 
456
443
  ```ruby
457
- config.encryption { |bytes| MyEncryption.encrypt(bytes) }
458
- config.decryption { |bytes| MyEncryption.decrypt(bytes) }
444
+ PatientHttp.configure do |config|
445
+ config.register_preprocessor(:aws_sigv4) do |request|
446
+ signer = Aws::Sigv4::Signer.new(
447
+ service: "execute-api",
448
+ region: "us-east-1",
449
+ credentials_provider: Aws::CredentialProviderChain.new.resolve
450
+ )
451
+ signature = signer.sign_request(
452
+ http_method: request.http_method.to_s.upcase,
453
+ url: request.url,
454
+ headers: request.headers.to_h,
455
+ body: request.body.to_s
456
+ )
457
+ signature.headers.each { |name, value| request.headers[name] = value }
458
+ end
459
+ end
460
+
461
+ PatientHttp.post("https://api.example.com/data", callback: MyCallback, json: {value: 1}, preprocessors: :aws_sigv4)
459
462
  ```
460
463
 
461
- Or pass any object that responds to `#call`:
464
+ 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:
462
465
 
463
- ```ruby
464
- config.encryption(->(bytes) { MyEncryption.encrypt(bytes) })
465
- config.decryption(->(bytes) { MyEncryption.decrypt(bytes) })
466
- ```
466
+ - `http_method`, `url`, and `body`: Read-only. The URL includes the resolved secret query parameters.
467
+ - `headers`: The request headers. You can change them. Names are case insensitive.
468
+ - `add_param(name, value)`: Adds a query parameter to the URL, for signing schemes that use query parameters.
467
469
 
468
- ### Wiring encryption into a custom TaskHandler
470
+ 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`.
469
471
 
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:
472
+ 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.
471
473
 
472
- ```ruby
473
- class MyTaskHandler < PatientHttp::TaskHandler
474
- def initialize(job_id, configuration:)
475
- @job_id = job_id
476
- @configuration = configuration
477
- end
474
+ ### Encryption
478
475
 
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
476
+ 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.
484
477
 
485
- def on_error(error, callback)
486
- encrypted = @configuration.encryptor.encrypt(error.as_json)
487
- MyJobSystem.enqueue(callback, :on_error, encrypted)
488
- end
478
+ The simplest option is `encryption_key`. It uses [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) with AES-256-GCM:
489
479
 
490
- def retry
491
- MyJobSystem.enqueue_job(@job_id)
492
- end
480
+ ```ruby
481
+ PatientHttp.configure do |config|
482
+ config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
493
483
  end
494
-
495
- # Keep a configuration reference and use config.encryptor where needed
496
- handler = MyTaskHandler.new("job-123", configuration: config)
497
484
  ```
498
485
 
499
- In your callback, decrypt before processing:
486
+ To rotate keys, pass an array. The first key encrypts data, and all keys are tried for decryption:
500
487
 
501
488
  ```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
- # ...
510
- end
489
+ PatientHttp.configure do |config|
490
+ config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
511
491
  end
512
492
  ```
513
493
 
514
- ### How it works
494
+ To use another encryption library, provide callables that take and return raw bytes as a String:
515
495
 
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.
496
+ ```ruby
497
+ PatientHttp.configure do |config|
498
+ config.encryption { |bytes| MyEncryption.encrypt(bytes) }
499
+ config.decryption { |bytes| MyEncryption.decrypt(bytes) }
500
+ end
501
+ ```
517
502
 
518
- ## Secrets
503
+ You can also pass any object that responds to `call`.
519
504
 
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.
505
+ 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.
521
506
 
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.
507
+ If you write your own `TaskHandler`, see [Build a custom integration](#build-a-custom-integration) for how to add encryption.
523
508
 
524
- ### Defining secrets
509
+ ### Payload stores
525
510
 
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):
511
+ 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.
527
512
 
528
513
  ```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
514
+ PatientHttp.configure do |config|
515
+ config.register_payload_store(:redis, adapter: :redis, redis: Redis.new(url: ENV["REDIS_URL"]), ttl: 86_400)
516
+ config.payload_store_threshold = 64 * 1024
517
+ end
532
518
  ```
533
519
 
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.
520
+ These adapters are available:
535
521
 
536
- #### Module-level registration
522
+ | Adapter | Options | Notes |
523
+ | --- | --- | --- |
524
+ | `:file` | `directory:` | For development and tests only. Hosts don't share the files. |
525
+ | `:redis` | `redis:` (required), `ttl:`, `key_prefix:` (default `"patient_http:payloads:"`) | Requires the `redis` gem. The client must respond to `set`, `get`, `del`, and `exists`. |
526
+ | `:s3` | `bucket:` (required), `key_prefix:` (default `"patient_http/payloads/"`) | Requires the `aws-sdk-s3` gem. |
527
+ | `:active_record` | `model:` | Requires a database table. See the following section. |
537
528
 
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:
529
+ 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:
539
530
 
540
531
  ```ruby
541
- PatientHttp.register_secret(:authorization, "Bearer #{ENV['API_TOKEN']}")
542
- PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
532
+ require "patient_http/rails/engine"
543
533
  ```
544
534
 
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:
546
-
547
- ```ruby
548
- PatientHttp.default_configuration = config
535
+ ```bash
536
+ bin/rails patient_http:install:migrations
537
+ bin/rails db:migrate
549
538
  ```
550
539
 
551
- Use `PatientHttp.secret_registered?(name)` to check whether a secret is available, either at the module level or on the default configuration.
552
-
553
- ### Referencing secrets when building a request
554
-
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:
540
+ You can also add the migration yourself:
556
541
 
557
542
  ```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
- )
564
- ```
543
+ class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
544
+ def change
545
+ create_table :patient_http_payloads, id: false do |t|
546
+ t.string :key, null: false, limit: 36
547
+ t.text :data, null: false
548
+ t.timestamps
549
+ end
565
550
 
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.
551
+ add_index :patient_http_payloads, :key, unique: true
552
+ add_index :patient_http_payloads, :created_at
553
+ end
554
+ end
555
+ ```
567
556
 
568
- ## Request Preprocessors
557
+ To write your own adapter, subclass `PatientHttp::PayloadStore::Base`, and implement `store_json`, `fetch`, and `delete`. The adapter must be thread-safe.
569
558
 
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.
559
+ ```ruby
560
+ class MyStore < PatientHttp::PayloadStore::Base
561
+ register :my_store, self
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
+ def store_json(key, json)
564
+ # Store the JSON string and return the key.
565
+ end
573
566
 
574
- ### Defining preprocessors
567
+ def fetch(key)
568
+ # Return the parsed hash, or nil if the key isn't found.
569
+ end
575
570
 
576
- Register a named preprocessor as a block or callable taking a single argument:
571
+ def delete(key)
572
+ # Delete the data. Don't raise an error if the key doesn't exist.
573
+ end
574
+ end
577
575
 
578
- ```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 }
576
+ PatientHttp.configure do |config|
577
+ config.register_payload_store(:custom, adapter: :my_store, **options)
593
578
  end
594
579
  ```
595
580
 
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:
597
-
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
601
-
602
- ### Attaching preprocessors to a request
581
+ 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.
603
582
 
604
- Reference registered preprocessors by name when building a request:
583
+ To store and fetch payloads yourself, use `PatientHttp::ExternalStorage`:
605
584
 
606
585
  ```ruby
607
- PatientHttp.post(
608
- "https://api.example.com/data",
609
- callback: MyCallback,
610
- json: {value: 1},
611
- preprocessors: :aws_sigv4
612
- )
613
- ```
614
-
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.
616
-
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.
586
+ storage = PatientHttp::ExternalStorage.new(PatientHttp.configuration)
618
587
 
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.
588
+ data = storage.store(response.as_json, max_size: 1024) # Returns the original hash if it's 1KB or smaller.
589
+ storage.storage_ref?(data) # Returns true if the hash was stored.
590
+ storage.fetch(data) # Returns the original hash.
591
+ storage.delete(data) # Deletes the stored payload.
592
+ ```
620
593
 
621
594
  ## Redirects
622
595
 
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.
596
+ 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
597
 
625
598
  The HTTP method of the redirected request follows RFC 9110:
626
599
 
627
600
  | Status | Method |
628
601
  | --- | --- |
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. |
602
+ | 301, 302 | `POST` changes to `GET`, and the body is removed. Other methods, including `HEAD`, `PUT`, `DELETE`, and `QUERY`, keep their method and body. |
603
+ | 303 | `GET` and `HEAD` keep their method. All other methods change to `GET`, and the body is removed. |
604
+ | 300, 307, 308 | The method and body don't change. |
605
+
606
+ 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
607
 
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`.
608
+ ### Prevent method changes
634
609
 
635
- ### Preventing method changes
610
+ 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
611
 
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.
612
+ You can set the option in the configuration or on a request. If both are set, the request value applies.
638
613
 
639
614
  ```ruby
640
- config = PatientHttp::Configuration.new(follow_method_changing_redirects: false)
615
+ PatientHttp.configure do |config|
616
+ config.follow_method_changing_redirects = false
617
+ end
641
618
 
642
- # Or per request
643
- request = PatientHttp::Request.new(:post, "https://api.example.com/submit", body: payload, follow_method_changing_redirects: false)
619
+ # Or for one request.
620
+ PatientHttp.post(url, callback: MyCallback, body: payload, follow_method_changing_redirects: false)
644
621
  ```
645
622
 
646
- ### Stripping headers on redirects
623
+ ### Remove headers on redirects
647
624
 
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.
625
+ 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
626
 
650
627
  ```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")
628
+ PatientHttp.configure do |config|
629
+ config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
630
+ end
655
631
 
656
- # The same options are accepted by PatientHttp.request, the async_* helpers, and RequestTemplate
632
+ # Or for one request. These headers are removed in addition to the configured headers.
657
633
  PatientHttp.get("https://api.example.com/data", callback: FetchCallback, redirect_strip_headers: "X-Signature")
658
634
  ```
659
635
 
660
- Per-request header names survive serialization into the job queue, so they apply no matter which process follows the redirect.
636
+ 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
637
 
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.
638
+ 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.
639
+
640
+ 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`.
641
+
642
+ ## Response and error objects
643
+
644
+ `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:
645
+
646
+ ```ruby
647
+ response = PatientHttp::Response.load(json_data)
648
+ error = PatientHttp::Error.load(json_data)
649
+ ```
650
+
651
+ 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`.
652
+
653
+ 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.
654
+
655
+ 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
656
 
664
657
  ## Troubleshooting
665
658
 
666
659
  ### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
667
660
 
668
- On some Ruby versions you may see a warning like this in your logs:
661
+ On some Ruby versions, a warning like this can appear in your logs:
669
662
 
670
663
  ```
671
664
  warn: Async::Task: Async::Pool::Controller Gardener [...]
@@ -674,114 +667,122 @@ warn: Async::Task: Async::Pool::Controller Gardener [...]
674
667
  | → .../async-pool-x.y.z/lib/async/pool/controller.rb:132 in `synchronize'
675
668
  ```
676
669
 
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.
670
+ 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
671
 
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+.
672
+ 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
673
 
681
- ## Configuration
674
+ ## Build a custom integration
682
675
 
683
- ```ruby
684
- config = PatientHttp::Configuration.new(
685
- # Maximum concurrent HTTP requests (default: 256)
686
- max_connections: 256,
676
+ 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
677
 
688
- # Default timeout for HTTP requests in seconds (default: 60)
689
- request_timeout: 60,
678
+ An integration has these parts:
690
679
 
691
- # Timeout for graceful shutdown in seconds (default: 30)
692
- shutdown_timeout: 30,
680
+ - A `TaskHandler` that delivers results to your job system.
681
+ - A `Processor` that runs requests.
682
+ - A registered request handler, so that application code can use the `PatientHttp` module methods.
693
683
 
694
- # Maximum response body size in bytes (default: 1MB)
695
- max_response_size: 1024 * 1024,
684
+ ### Create a task handler
696
685
 
697
- # Default User-Agent header (default: "PatientHttp")
698
- user_agent: "MyApp/1.0",
686
+ The `TaskHandler` connects the processor to your job system:
699
687
 
700
- # Treat non-2xx responses as errors by default (default: false)
701
- raise_error_responses: false,
688
+ ```ruby
689
+ class MyTaskHandler < PatientHttp::TaskHandler
690
+ def initialize(job_id)
691
+ @job_id = job_id
692
+ end
702
693
 
703
- # Maximum redirects to follow (default: 5, 0 disables)
704
- max_redirects: 5,
694
+ def on_complete(response, callback)
695
+ MyJobSystem.enqueue(callback, :on_complete, response.as_json)
696
+ end
705
697
 
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,
698
+ def on_error(error, callback)
699
+ MyJobSystem.enqueue(callback, :on_error, error.as_json)
700
+ end
701
+
702
+ def retry
703
+ # Re-enqueue the original job when the processor shuts down before the
704
+ # request finishes.
705
+ MyJobSystem.enqueue_job(@job_id)
706
+ end
707
+ end
708
+ ```
709
709
 
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"],
710
+ > [!IMPORTANT]
711
+ > 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.
712
+ >
713
+ > 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.
714
+ >
715
+ > 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
716
 
715
- # Maximum number of hosts to maintain persistent connections for (default: 100)
716
- connection_pool_size: 100,
717
+ 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
718
 
718
- # Connection timeout in seconds (default: nil, uses request_timeout)
719
- connection_timeout: 10,
719
+ ```ruby
720
+ def on_complete(response, callback)
721
+ encrypted = @configuration.encryptor.encrypt(response.as_json)
722
+ MyJobSystem.enqueue(callback, :on_complete, encrypted)
723
+ end
724
+ ```
720
725
 
721
- # HTTP/HTTPS proxy URL (default: nil)
722
- proxy_url: "http://proxy.example.com:8080",
726
+ Decrypt the data before you create the object again:
723
727
 
724
- # Retries for failed requests (default: 3)
725
- retries: 3,
728
+ ```ruby
729
+ response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
730
+ ```
726
731
 
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,
732
+ ### Run a processor
732
733
 
733
- # Logger instance (default: Logger to STDERR at ERROR level)
734
- logger: Logger.new($stdout)
735
- )
734
+ ```ruby
735
+ config = PatientHttp::Configuration.new(max_connections: 256, request_timeout: 60)
736
+ processor = PatientHttp::Processor.new(config)
737
+ processor.start
736
738
 
737
- # Register named secrets to reference sensitive headers/params indirectly (see Secrets)
738
- config.register_secret(:api_token, ENV["MY_API_TOKEN"])
739
+ task = PatientHttp::RequestTask.new(
740
+ request: PatientHttp::Request.new(:get, "https://api.example.com/users/123"),
741
+ task_handler: MyTaskHandler.new("job-123"),
742
+ callback: "FetchUserCallback",
743
+ callback_args: {user_id: 123}
744
+ )
745
+ processor.enqueue(task)
739
746
  ```
740
747
 
741
- ### Tuning Tips
748
+ If the processor isn't running, `enqueue` raises a `PatientHttp::NotRunningError`. If the processor is at `max_connections`, it raises a `PatientHttp::MaxCapacityError`.
742
749
 
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.
750
+ A process can run several named processors, each with its own capacity and threads:
752
751
 
753
- ## Processor Lifecycle
752
+ ```ruby
753
+ PatientHttp::Processor.new(config, name: :llm)
754
+ ```
755
+
756
+ 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
757
 
755
- The processor transitions through these states:
758
+ ### Processor lifecycle
756
759
 
757
760
  ```
758
761
  stopped -> starting -> running -> draining -> stopping -> stopped
759
762
  ```
760
763
 
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
764
+ - `stopped`: The processor doesn't run requests.
765
+ - `starting`: The processor starts the reactor thread.
766
+ - `running`: The processor accepts and runs requests.
767
+ - `draining`: The processor rejects new requests and finishes in-flight requests.
768
+ - `stopping`: The processor shuts down and re-enqueues requests that didn't finish.
766
769
 
767
770
  ```ruby
768
- processor = PatientHttp::Processor.new(config)
769
-
770
- processor.start # Start processing
771
+ processor.start # Start the processor.
771
772
  processor.running? # => true
772
773
 
773
- processor.drain # Stop accepting new requests
774
+ processor.drain # Stop accepting new requests.
774
775
  processor.draining? # => true
775
776
 
776
- processor.stop(timeout: 25) # Graceful shutdown
777
+ processor.stop(timeout: 25) # Shut down gracefully.
777
778
  processor.stopped? # => true
778
779
  ```
779
780
 
780
- When the processor stops with in-flight requests, it calls `TaskHandler#retry` on each incomplete task so they can be re-enqueued.
781
+ 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
782
 
782
- ### Observing the Processor
783
+ ### Observe the processor
783
784
 
784
- Register observers to monitor processor events:
785
+ To receive processor events, subclass `PatientHttp::ProcessorObserver`, and override the methods for the events that you need:
785
786
 
786
787
  ```ruby
787
788
  class MetricsObserver < PatientHttp::ProcessorObserver
@@ -805,27 +806,48 @@ end
805
806
  processor.observe(MetricsObserver.new)
806
807
  ```
807
808
 
808
- Observers can also track the full task pipeline:
809
+ Observers can also track each task through the processor:
809
810
 
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.
811
+ - `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.
812
+ - `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.
813
+ - `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.
814
+ - `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
815
 
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.
816
+ 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
817
 
816
- ## Testing
818
+ For the thread that runs each observer method, see the `ProcessorObserver` documentation. Observers must be thread-safe.
817
819
 
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.
820
+ ### Register a request handler
819
821
 
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.
822
+ To let application code use the `PatientHttp` module methods, register a request handler. The handler builds each task:
821
823
 
822
824
  ```ruby
823
- task = PatientHttp::RequestTask.new(
824
- request: request,
825
- task_handler: handler,
826
- callback: "MyCallback"
827
- )
825
+ PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
826
+ task = PatientHttp::RequestTask.new(
827
+ request: request,
828
+ task_handler: MyTaskHandler.new(MyJobSystem.current_job_id),
829
+ callback: callback,
830
+ callback_args: callback_args,
831
+ raise_error_responses: raise_error_responses
832
+ )
833
+ processor.enqueue(task)
834
+ task.id
835
+ end
836
+ ```
837
+
838
+ 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?`.
839
+
840
+ 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`:
841
+
842
+ ```ruby
843
+ PatientHttp.register_configuration_provider(MyIntegration)
844
+ ```
845
+
846
+ ### Test an integration
847
+
848
+ 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:
828
849
 
850
+ ```ruby
829
851
  executor = PatientHttp::SynchronousExecutor.new(
830
852
  task,
831
853
  config: config,
@@ -836,25 +858,23 @@ executor = PatientHttp::SynchronousExecutor.new(
836
858
  executor.call
837
859
  ```
838
860
 
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.
861
+ ## Installation
846
862
 
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.
863
+ Most applications need only the integration gem for their job system, which depends on this gem:
848
864
 
849
- ## Installation
865
+ ```ruby
866
+ gem "patient_http-sidekiq"
867
+ # or
868
+ gem "patient_http-solid_queue"
869
+ ```
850
870
 
851
- Add this line to your application's Gemfile:
871
+ To use this gem on its own, add it to your Gemfile:
852
872
 
853
873
  ```ruby
854
874
  gem "patient_http"
855
875
  ```
856
876
 
857
- Then execute:
877
+ Then install it:
858
878
 
859
879
  ```bash
860
880
  bundle install
@@ -864,11 +884,17 @@ bundle install
864
884
 
865
885
  Open a pull request on [GitHub](https://github.com/bdurand/patient_http).
866
886
 
867
- Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
887
+ Follow the [standardrb](https://github.com/testdouble/standard) style, and run `standardrb --fix` before you submit a pull request.
888
+
889
+ Run the tests:
890
+
891
+ ```bash
892
+ bundle exec rspec
893
+ ```
868
894
 
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.
895
+ 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.
870
896
 
871
- ## Further Reading
897
+ ## Further reading
872
898
 
873
899
  - [Architecture](ARCHITECTURE.md)
874
900