patient_http 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +322 -0
  3. data/CHANGELOG.md +30 -0
  4. data/MIT-LICENSE +20 -0
  5. data/README.md +653 -0
  6. data/VERSION +1 -0
  7. data/db/migrate/20250101000000_create_patient_http_payloads.rb +15 -0
  8. data/lib/patient_http/callback_args.rb +176 -0
  9. data/lib/patient_http/callback_validator.rb +52 -0
  10. data/lib/patient_http/class_helper.rb +26 -0
  11. data/lib/patient_http/client.rb +80 -0
  12. data/lib/patient_http/client_pool.rb +178 -0
  13. data/lib/patient_http/configuration.rb +365 -0
  14. data/lib/patient_http/encryptor.rb +69 -0
  15. data/lib/patient_http/error.rb +76 -0
  16. data/lib/patient_http/external_storage.rb +134 -0
  17. data/lib/patient_http/http_error.rb +106 -0
  18. data/lib/patient_http/http_headers.rb +99 -0
  19. data/lib/patient_http/lifecycle_manager.rb +174 -0
  20. data/lib/patient_http/payload.rb +160 -0
  21. data/lib/patient_http/payload_store/active_record_store.rb +102 -0
  22. data/lib/patient_http/payload_store/base.rb +150 -0
  23. data/lib/patient_http/payload_store/file_store.rb +92 -0
  24. data/lib/patient_http/payload_store/redis_store.rb +98 -0
  25. data/lib/patient_http/payload_store/s3_store.rb +94 -0
  26. data/lib/patient_http/payload_store.rb +11 -0
  27. data/lib/patient_http/processor.rb +538 -0
  28. data/lib/patient_http/processor_observer.rb +48 -0
  29. data/lib/patient_http/rails/engine.rb +21 -0
  30. data/lib/patient_http/redirect_error.rb +136 -0
  31. data/lib/patient_http/redirect_helper.rb +90 -0
  32. data/lib/patient_http/request.rb +158 -0
  33. data/lib/patient_http/request_error.rb +150 -0
  34. data/lib/patient_http/request_helper.rb +230 -0
  35. data/lib/patient_http/request_task.rb +308 -0
  36. data/lib/patient_http/request_template.rb +114 -0
  37. data/lib/patient_http/response.rb +183 -0
  38. data/lib/patient_http/response_reader.rb +135 -0
  39. data/lib/patient_http/synchronous_executor.rb +241 -0
  40. data/lib/patient_http/task_handler.rb +55 -0
  41. data/lib/patient_http/time_helper.rb +32 -0
  42. data/lib/patient_http.rb +313 -0
  43. data/patient_http.gemspec +48 -0
  44. metadata +161 -0
data/README.md ADDED
@@ -0,0 +1,653 @@
1
+ # PatientHttp
2
+
3
+ [![Continuous Integration](https://github.com/bdurand/patient_http/actions/workflows/continuous_integration.yml/badge.svg)](https://github.com/bdurand/patient_http/actions/workflows/continuous_integration.yml)
4
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/testdouble/standard)
5
+ [![Gem Version](https://badge.fury.io/rb/patient_http.svg)](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