patient_http 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +322 -0
- data/CHANGELOG.md +30 -0
- data/MIT-LICENSE +20 -0
- data/README.md +653 -0
- data/VERSION +1 -0
- data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
- data/lib/patient_http/callback_args.rb +176 -0
- data/lib/patient_http/callback_validator.rb +52 -0
- data/lib/patient_http/class_helper.rb +26 -0
- data/lib/patient_http/client.rb +80 -0
- data/lib/patient_http/client_pool.rb +178 -0
- data/lib/patient_http/configuration.rb +365 -0
- data/lib/patient_http/encryptor.rb +69 -0
- data/lib/patient_http/error.rb +76 -0
- data/lib/patient_http/external_storage.rb +134 -0
- data/lib/patient_http/http_error.rb +106 -0
- data/lib/patient_http/http_headers.rb +99 -0
- data/lib/patient_http/lifecycle_manager.rb +174 -0
- data/lib/patient_http/payload.rb +160 -0
- data/lib/patient_http/payload_store/active_record_store.rb +102 -0
- data/lib/patient_http/payload_store/base.rb +150 -0
- data/lib/patient_http/payload_store/file_store.rb +92 -0
- data/lib/patient_http/payload_store/redis_store.rb +98 -0
- data/lib/patient_http/payload_store/s3_store.rb +94 -0
- data/lib/patient_http/payload_store.rb +11 -0
- data/lib/patient_http/processor.rb +538 -0
- data/lib/patient_http/processor_observer.rb +48 -0
- data/lib/patient_http/rails/engine.rb +21 -0
- data/lib/patient_http/redirect_error.rb +136 -0
- data/lib/patient_http/redirect_helper.rb +90 -0
- data/lib/patient_http/request.rb +158 -0
- data/lib/patient_http/request_error.rb +150 -0
- data/lib/patient_http/request_helper.rb +230 -0
- data/lib/patient_http/request_task.rb +308 -0
- data/lib/patient_http/request_template.rb +114 -0
- data/lib/patient_http/response.rb +183 -0
- data/lib/patient_http/response_reader.rb +135 -0
- data/lib/patient_http/synchronous_executor.rb +241 -0
- data/lib/patient_http/task_handler.rb +55 -0
- data/lib/patient_http/time_helper.rb +32 -0
- data/lib/patient_http.rb +313 -0
- data/patient_http.gemspec +48 -0
- metadata +161 -0
data/README.md
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# PatientHttp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/bdurand/patient_http/actions/workflows/continuous_integration.yml)
|
|
4
|
+
[](https://github.com/testdouble/standard)
|
|
5
|
+
[](https://badge.fury.io/rb/patient_http)
|
|
6
|
+
|
|
7
|
+
*Built for APIs that like to think.*
|
|
8
|
+
|
|
9
|
+
Generic async HTTP connection pool for Ruby applications using Fiber-based concurrency.
|
|
10
|
+
|
|
11
|
+
## Motivation
|
|
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.
|
|
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.
|
|
16
|
+
|
|
17
|
+
This design keeps application threads free to do other work while HTTP requests are in flight.
|
|
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.
|
|
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.
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
### 1. Implement a TaskHandler
|
|
26
|
+
|
|
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
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class MyTaskHandler < PatientHttp::TaskHandler
|
|
31
|
+
def initialize(job_id)
|
|
32
|
+
@job_id = job_id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_complete(response, callback)
|
|
36
|
+
# Enqueue a message for your application to process the response.
|
|
37
|
+
# Keep this lightweight -- don't do heavy processing here since
|
|
38
|
+
# it runs on the processor thread.
|
|
39
|
+
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def on_error(error, callback)
|
|
43
|
+
MyJobSystem.enqueue(callback, :on_error, error.as_json)
|
|
44
|
+
end
|
|
45
|
+
|
|
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)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> **Important:** TaskHandler callbacks run on the processor's reactor thread. They should be lightweight and fast -- typically just enqueuing a message for another system to pick up. Doing heavy processing in a callback will block the reactor and delay other in-flight requests.
|
|
55
|
+
|
|
56
|
+
### 2. Create and Enqueue Requests
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Configure the processor
|
|
60
|
+
config = PatientHttp::Configuration.new(
|
|
61
|
+
max_connections: 256,
|
|
62
|
+
request_timeout: 60
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Start the processor
|
|
66
|
+
processor = PatientHttp::Processor.new(config)
|
|
67
|
+
processor.start
|
|
68
|
+
|
|
69
|
+
# Build a request
|
|
70
|
+
request = PatientHttp::Request.new(
|
|
71
|
+
:get,
|
|
72
|
+
"https://api.example.com/users/123",
|
|
73
|
+
headers: {"Authorization" => "Bearer token"}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Create a task with your handler
|
|
77
|
+
task = PatientHttp::RequestTask.new(
|
|
78
|
+
request: request,
|
|
79
|
+
task_handler: MyTaskHandler.new("job-123"),
|
|
80
|
+
callback: "FetchDataCallback",
|
|
81
|
+
callback_args: {user_id: 123}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Enqueue it
|
|
85
|
+
processor.enqueue(task)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3. Process Callbacks
|
|
89
|
+
|
|
90
|
+
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).
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class FetchDataCallback
|
|
94
|
+
def on_complete(response)
|
|
95
|
+
user_id = response.callback_args[:user_id]
|
|
96
|
+
data = response.json
|
|
97
|
+
User.find(user_id).update!(external_data: data)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def on_error(error)
|
|
101
|
+
user_id = error.callback_args[:user_id]
|
|
102
|
+
Rails.logger.error("Failed for user #{user_id}: #{error.message}")
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Handling HTTP Error Responses
|
|
108
|
+
|
|
109
|
+
By default, HTTP error status codes (4xx, 5xx) are treated as completed requests. You can check the status using helper methods on the response:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
def on_complete(response)
|
|
113
|
+
if response.success? # 2xx
|
|
114
|
+
process_data(response.json)
|
|
115
|
+
elsif response.client_error? # 4xx
|
|
116
|
+
handle_client_error(response)
|
|
117
|
+
elsif response.server_error? # 5xx
|
|
118
|
+
handle_server_error(response)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
To treat non-2xx responses as errors instead, set `raise_error_responses: true` on the `RequestTask`:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
task = PatientHttp::RequestTask.new(
|
|
127
|
+
request: request,
|
|
128
|
+
task_handler: handler,
|
|
129
|
+
callback: "ApiCallback",
|
|
130
|
+
raise_error_responses: true
|
|
131
|
+
)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
When enabled, non-2xx responses call `TaskHandler#on_error` with an `HttpError` that provides access to the response:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
def on_error(error)
|
|
138
|
+
if error.is_a?(PatientHttp::HttpError)
|
|
139
|
+
puts error.status # HTTP status code
|
|
140
|
+
puts error.url # Request URL
|
|
141
|
+
puts error.response.body # Response body
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Request Templates
|
|
147
|
+
|
|
148
|
+
For repeated requests to the same API, use `RequestTemplate` to share configuration:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
template = PatientHttp::RequestTemplate.new(
|
|
152
|
+
base_url: "https://api.example.com",
|
|
153
|
+
headers: {"Authorization" => "Bearer #{ENV['API_KEY']}"},
|
|
154
|
+
timeout: 60
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Build requests from the template
|
|
158
|
+
get_request = template.get("/users/123")
|
|
159
|
+
post_request = template.post("/users", json: {name: "John"})
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Templates support all HTTP methods (`get`, `post`, `put`, `patch`, `delete`) and handle URL joining, header merging, and query parameter encoding.
|
|
163
|
+
|
|
164
|
+
## Standard Interface
|
|
165
|
+
|
|
166
|
+
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.
|
|
167
|
+
|
|
168
|
+
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.
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
# The handler receives keyword arguments for the request, callback, and any additional callback arguments.
|
|
172
|
+
PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
|
|
173
|
+
# Example integration point. Adapt this to your app.
|
|
174
|
+
# Build a RequestTask and enqueue it to your processor.
|
|
175
|
+
task = PatientHttp::RequestTask.new(
|
|
176
|
+
request: request,
|
|
177
|
+
task_handler: MyTaskHandler.new,
|
|
178
|
+
callback: callback,
|
|
179
|
+
callback_args: callback_args,
|
|
180
|
+
raise_error_responses: raise_error_responses
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
processor.enqueue(task)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Now you can make requests directly through the PatientHttp interface with the .request,
|
|
187
|
+
# .get, .post, .patch, .put, and .delete class methods:
|
|
188
|
+
PatientHttp.get(
|
|
189
|
+
"https://api.example.com/users/123",
|
|
190
|
+
callback: FetchUserCallback,
|
|
191
|
+
callback_args: {user_id: 123}
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
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.
|
|
196
|
+
|
|
197
|
+
### RequestHelper Mixin
|
|
198
|
+
|
|
199
|
+
Use `PatientHttp::RequestHelper` when you want a simple API for creating and dispatching async HTTP requests directly from your class.
|
|
200
|
+
|
|
201
|
+
1. Register a request handler with `PatientHttp.register_handler` that defines how requests are dispatched to your job queue or background processing system.
|
|
202
|
+
2. Include `PatientHttp::RequestHelper` in your class.
|
|
203
|
+
3. Optionally define a `request_template` for shared `base_url`, headers, and timeout.
|
|
204
|
+
4. Call `async_get`, `async_post`, `async_put`, `async_patch`, `async_delete`, or `async_request`.
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
class ApiClient
|
|
208
|
+
include PatientHttp::RequestHelper
|
|
209
|
+
|
|
210
|
+
request_template(
|
|
211
|
+
base_url: "https://api.example.com",
|
|
212
|
+
headers: {"Authorization" => "Bearer #{ENV["API_KEY"]}"},
|
|
213
|
+
timeout: 60
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def fetch_user(user_id)
|
|
217
|
+
async_get(
|
|
218
|
+
"/users/#{user_id}",
|
|
219
|
+
callback: FetchUserCallback,
|
|
220
|
+
callback_args: {user_id: user_id}
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def update_user(user_id, data)
|
|
225
|
+
async_patch(
|
|
226
|
+
"/users/#{user_id}",
|
|
227
|
+
json: data,
|
|
228
|
+
callback: UpdateUserCallback,
|
|
229
|
+
callback_args: {user_id: user_id}
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Callback Arguments
|
|
236
|
+
|
|
237
|
+
Pass custom data through the request/response cycle using `callback_args`:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
task = PatientHttp::RequestTask.new(
|
|
241
|
+
request: request,
|
|
242
|
+
task_handler: handler,
|
|
243
|
+
callback: "FetchDataCallback",
|
|
244
|
+
callback_args: {user_id: 123, request_timestamp: Time.now.iso8601}
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Callback arguments are available on both `Response` and `Error` objects:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
response.callback_args[:user_id] # Symbol access
|
|
252
|
+
response.callback_args["user_id"] # String access
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Callback args must contain only JSON-native types (`nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, `Hash`). Hash keys are converted to strings for serialization.
|
|
256
|
+
|
|
257
|
+
## Response and Error Objects
|
|
258
|
+
|
|
259
|
+
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.
|
|
260
|
+
|
|
261
|
+
Both response and error objects provide `as_json` and `to_json` methods for serialization:
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
def on_complete(response, callback)
|
|
265
|
+
# Serialize the response for background processing
|
|
266
|
+
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def on_error(error, callback)
|
|
270
|
+
# Serialize the error for background processing
|
|
271
|
+
MyJobSystem.enqueue(callback, :on_error, error.as_json)
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
When deserializing, use the `load` class methods to reconstruct the objects:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
response = PatientHttp::Response.load(json_data)
|
|
279
|
+
error = PatientHttp::HttpError.load(json_data)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
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.
|
|
283
|
+
|
|
284
|
+
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.
|
|
285
|
+
|
|
286
|
+
### Payload Stores
|
|
287
|
+
|
|
288
|
+
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.
|
|
289
|
+
|
|
290
|
+
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.
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
# Register a payload store (see below for options; the file adapter should only be used for development/testing)
|
|
294
|
+
config.register_payload_store(:my_store, adapter: :file, directory: "/tmp/payloads")
|
|
295
|
+
|
|
296
|
+
# Use the ExternalStorage class to set and fetch stored payloads in your callbacks.
|
|
297
|
+
storage = PatientHttp::ExternalStorage.new(config)
|
|
298
|
+
|
|
299
|
+
large_response_data = storage.store(large_response.as_json)
|
|
300
|
+
# Returns a reference like: {"$ref" => {"store" => "my_store", "key" => "abc123"}}
|
|
301
|
+
|
|
302
|
+
small_response_data = storage.store(small_response.as_json, max_size: 1024)
|
|
303
|
+
# Will not store the payload and returns the original data hash if the JSON payload is under 1KB.
|
|
304
|
+
|
|
305
|
+
storage.storage_ref?(large_response_data) # => true
|
|
306
|
+
storage.storage_ref?(small_response_data) # => false
|
|
307
|
+
|
|
308
|
+
storage.fetch(large_response_data) # Fetches the original data from the store
|
|
309
|
+
storage.fetch(small_response_data) # Raises an error since this is not a reference
|
|
310
|
+
|
|
311
|
+
storage.delete(large_response_data) # Deletes the stored payload
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
#### File Store
|
|
315
|
+
|
|
316
|
+
For development and testing:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
config.register_payload_store(:files, adapter: :file, directory: "/tmp/payloads")
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
#### Redis Store
|
|
323
|
+
|
|
324
|
+
For production with shared state across processes:
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
redis = RedisClient.new(url: ENV["REDIS_URL"])
|
|
328
|
+
config.register_payload_store(:redis, adapter: :redis, redis: redis, ttl: 86400)
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Options: `redis:` (required), `ttl:` (seconds, optional), `key_prefix:` (default: `"patient_http:payloads:"`)
|
|
332
|
+
|
|
333
|
+
#### S3 Store
|
|
334
|
+
|
|
335
|
+
For durable storage across instances (requires `aws-sdk-s3` gem):
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
s3 = Aws::S3::Resource.new
|
|
339
|
+
bucket = s3.bucket("my-payloads-bucket")
|
|
340
|
+
config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Options: `bucket:` (required), `key_prefix:` (default: `"patient_http/payloads/"`)
|
|
344
|
+
|
|
345
|
+
#### ActiveRecord Store
|
|
346
|
+
|
|
347
|
+
For database-backed storage with transactional guarantees:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
config.register_payload_store(:database, adapter: :active_record)
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
This requires a database migration. Copy the migration from the gem:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
# db/migrate/XXXXXX_create_patient_http_payloads.rb
|
|
357
|
+
class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
|
|
358
|
+
def change
|
|
359
|
+
create_table :patient_http_payloads, id: false do |t|
|
|
360
|
+
t.string :key, null: false, limit: 36
|
|
361
|
+
t.text :data, null: false
|
|
362
|
+
t.timestamps
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
add_index :patient_http_payloads, :key, unique: true
|
|
366
|
+
add_index :patient_http_payloads, :created_at
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
Options: `model:` (optional, defaults to built-in `PatientHttp::PayloadStore::ActiveRecordStore::Payload`)
|
|
372
|
+
|
|
373
|
+
#### Custom Stores
|
|
374
|
+
|
|
375
|
+
Implement your own by subclassing `PatientHttp::PayloadStore::Base`:
|
|
376
|
+
|
|
377
|
+
```ruby
|
|
378
|
+
class MyStore < PatientHttp::PayloadStore::Base
|
|
379
|
+
register :my_store, self
|
|
380
|
+
|
|
381
|
+
def store(key, data)
|
|
382
|
+
# Store the hash and return the key
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def fetch(key)
|
|
386
|
+
# Return the hash or nil if not found
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def delete(key)
|
|
390
|
+
# Delete the data (idempotent)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
config.register_payload_store(:custom, adapter: :my_store, **options)
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
Multiple stores can be registered for migration purposes. The last registered store is used for new writes; all registered stores remain available for reads.
|
|
398
|
+
|
|
399
|
+
## Encryption
|
|
400
|
+
|
|
401
|
+
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.
|
|
402
|
+
|
|
403
|
+
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.
|
|
404
|
+
|
|
405
|
+
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.
|
|
406
|
+
|
|
407
|
+
### Using an encryption key
|
|
408
|
+
|
|
409
|
+
The simplest option is `encryption_key=`, which sets up [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) automatically using AES-256-GCM:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
config = PatientHttp::Configuration.new
|
|
413
|
+
config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
To support key rotation, pass an array — the first key encrypts new data, and all keys attempt decryption:
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Using custom callables
|
|
423
|
+
|
|
424
|
+
For custom encryption libraries, provide callables that accept and return raw bytes (String):
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
config.encryption { |bytes| MyEncryption.encrypt(bytes) }
|
|
428
|
+
config.decryption { |bytes| MyEncryption.decrypt(bytes) }
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Or pass any object that responds to `#call`:
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
config.encryption(->(bytes) { MyEncryption.encrypt(bytes) })
|
|
435
|
+
config.decryption(->(bytes) { MyEncryption.decrypt(bytes) })
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Wiring encryption into a custom TaskHandler
|
|
439
|
+
|
|
440
|
+
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:
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
class MyTaskHandler < PatientHttp::TaskHandler
|
|
444
|
+
def initialize(job_id, configuration:)
|
|
445
|
+
@job_id = job_id
|
|
446
|
+
@configuration = configuration
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def on_complete(response, callback)
|
|
450
|
+
# Encrypt the serialized response before enqueuing
|
|
451
|
+
encrypted = @configuration.encryptor.encrypt(response.as_json)
|
|
452
|
+
MyJobSystem.enqueue(callback, :on_complete, encrypted)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def on_error(error, callback)
|
|
456
|
+
encrypted = @configuration.encryptor.encrypt(error.as_json)
|
|
457
|
+
MyJobSystem.enqueue(callback, :on_error, encrypted)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def retry
|
|
461
|
+
MyJobSystem.enqueue_job(@job_id)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Keep a configuration reference and use config.encryptor where needed
|
|
466
|
+
handler = MyTaskHandler.new("job-123", configuration: config)
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
In your callback, decrypt before processing:
|
|
470
|
+
|
|
471
|
+
```ruby
|
|
472
|
+
class FetchDataCallback
|
|
473
|
+
def initialize(configuration:)
|
|
474
|
+
@configuration = configuration
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def on_complete(data)
|
|
478
|
+
response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
|
|
479
|
+
# ...
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### How it works
|
|
485
|
+
|
|
486
|
+
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.
|
|
487
|
+
|
|
488
|
+
## Configuration
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
491
|
+
config = PatientHttp::Configuration.new(
|
|
492
|
+
# Maximum concurrent HTTP requests (default: 256)
|
|
493
|
+
max_connections: 256,
|
|
494
|
+
|
|
495
|
+
# Default timeout for HTTP requests in seconds (default: 60)
|
|
496
|
+
request_timeout: 60,
|
|
497
|
+
|
|
498
|
+
# Timeout for graceful shutdown in seconds (default: 30)
|
|
499
|
+
shutdown_timeout: 30,
|
|
500
|
+
|
|
501
|
+
# Maximum response body size in bytes (default: 1MB)
|
|
502
|
+
max_response_size: 1024 * 1024,
|
|
503
|
+
|
|
504
|
+
# Default User-Agent header (default: "PatientHttp")
|
|
505
|
+
user_agent: "MyApp/1.0",
|
|
506
|
+
|
|
507
|
+
# Treat non-2xx responses as errors by default (default: false)
|
|
508
|
+
raise_error_responses: false,
|
|
509
|
+
|
|
510
|
+
# Maximum redirects to follow (default: 5, 0 disables)
|
|
511
|
+
max_redirects: 5,
|
|
512
|
+
|
|
513
|
+
# Maximum number of hosts to maintain persistent connections for (default: 100)
|
|
514
|
+
connection_pool_size: 100,
|
|
515
|
+
|
|
516
|
+
# Connection timeout in seconds (default: nil, uses request_timeout)
|
|
517
|
+
connection_timeout: 10,
|
|
518
|
+
|
|
519
|
+
# HTTP/HTTPS proxy URL (default: nil)
|
|
520
|
+
proxy_url: "http://proxy.example.com:8080",
|
|
521
|
+
|
|
522
|
+
# Retries for failed requests (default: 3)
|
|
523
|
+
retries: 3,
|
|
524
|
+
|
|
525
|
+
# Logger instance (default: Logger to STDERR at ERROR level)
|
|
526
|
+
logger: Logger.new($stdout)
|
|
527
|
+
)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Tuning Tips
|
|
531
|
+
|
|
532
|
+
- **max_connections**: Each connection uses memory and file descriptors. A tuned system can handle thousands.
|
|
533
|
+
- **request_timeout**: Set based on expected API response times. AI/LLM APIs may need minutes.
|
|
534
|
+
- **connection_pool_size**: Increase for applications calling many different API hosts.
|
|
535
|
+
- **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage.
|
|
536
|
+
|
|
537
|
+
## Processor Lifecycle
|
|
538
|
+
|
|
539
|
+
The processor transitions through these states:
|
|
540
|
+
|
|
541
|
+
```
|
|
542
|
+
stopped -> starting -> running -> draining -> stopping -> stopped
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
- **stopped**: Not processing requests
|
|
546
|
+
- **starting**: Initializing the reactor thread
|
|
547
|
+
- **running**: Accepting and processing requests
|
|
548
|
+
- **draining**: Rejecting new requests, completing in-flight ones
|
|
549
|
+
- **stopping**: Shutting down, re-enqueuing incomplete requests
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
processor = PatientHttp::Processor.new(config)
|
|
553
|
+
|
|
554
|
+
processor.start # Start processing
|
|
555
|
+
processor.running? # => true
|
|
556
|
+
|
|
557
|
+
processor.drain # Stop accepting new requests
|
|
558
|
+
processor.draining? # => true
|
|
559
|
+
|
|
560
|
+
processor.stop(timeout: 25) # Graceful shutdown
|
|
561
|
+
processor.stopped? # => true
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
When the processor stops with in-flight requests, it calls `TaskHandler#retry` on each incomplete task so they can be re-enqueued.
|
|
565
|
+
|
|
566
|
+
### Observing the Processor
|
|
567
|
+
|
|
568
|
+
Register observers to monitor processor events:
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
class MetricsObserver < PatientHttp::ProcessorObserver
|
|
572
|
+
def request_start(request_task)
|
|
573
|
+
StatsD.increment("http_pool.request.start")
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def request_end(request_task)
|
|
577
|
+
StatsD.timing("http_pool.request.duration", request_task.duration * 1000)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def request_error(error)
|
|
581
|
+
StatsD.increment("http_pool.request.error")
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def capacity_exceeded
|
|
585
|
+
StatsD.increment("http_pool.capacity_exceeded")
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
processor.observe(MetricsObserver.new)
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
## Testing
|
|
593
|
+
|
|
594
|
+
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.
|
|
595
|
+
|
|
596
|
+
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.
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
task = PatientHttp::RequestTask.new(
|
|
600
|
+
request: request,
|
|
601
|
+
task_handler: handler,
|
|
602
|
+
callback: "MyCallback"
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
executor = PatientHttp::SynchronousExecutor.new(
|
|
606
|
+
task,
|
|
607
|
+
config: config,
|
|
608
|
+
on_complete: ->(response) { StatsD.increment("complete") },
|
|
609
|
+
on_error: ->(error) { StatsD.increment("error") }
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
executor.call
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
## Integration
|
|
616
|
+
|
|
617
|
+
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.
|
|
618
|
+
|
|
619
|
+
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.
|
|
620
|
+
|
|
621
|
+
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.
|
|
622
|
+
|
|
623
|
+
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.
|
|
624
|
+
|
|
625
|
+
## Installation
|
|
626
|
+
|
|
627
|
+
Add this line to your application's Gemfile:
|
|
628
|
+
|
|
629
|
+
```ruby
|
|
630
|
+
gem "patient_http"
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Then execute:
|
|
634
|
+
|
|
635
|
+
```bash
|
|
636
|
+
bundle install
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
## Contributing
|
|
640
|
+
|
|
641
|
+
Open a pull request on [GitHub](https://github.com/bdurand/patient_http).
|
|
642
|
+
|
|
643
|
+
Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
|
|
644
|
+
|
|
645
|
+
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.
|
|
646
|
+
|
|
647
|
+
## Further Reading
|
|
648
|
+
|
|
649
|
+
- [Architecture](ARCHITECTURE.md)
|
|
650
|
+
|
|
651
|
+
## License
|
|
652
|
+
|
|
653
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :patient_http_payloads, id: false do |t|
|
|
6
|
+
t.string :key, null: false, limit: 36
|
|
7
|
+
t.text :data, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :patient_http_payloads, :key, unique: true
|
|
13
|
+
add_index :patient_http_payloads, :created_at
|
|
14
|
+
end
|
|
15
|
+
end
|