patient_http-solid_queue 1.2.1 → 1.3.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.
data/README.md CHANGED
@@ -6,13 +6,13 @@
6
6
 
7
7
  *Built for APIs that like to think.*
8
8
 
9
- This gem provides a mechanism to offload HTTP requests to a dedicated async I/O processor running in your Solid Queue worker process using the [patient_http gem](https://github.com/bdurand/patient_http). Worker threads are freed immediately while HTTP requests are in flight so that they can do other work instead of waiting for HTTP responses.
9
+ This gem runs HTTP requests from Solid Queue on a dedicated async I/O processor in your Solid Queue worker process, using the [patient_http gem](https://github.com/bdurand/patient_http). Worker threads don't wait for HTTP responses, so they're free to run other jobs while requests are in flight.
10
10
 
11
11
  ## Motivation
12
12
 
13
- Solid Queue is designed with the assumption that jobs are short-lived and complete quickly. Long-running HTTP requests block worker threads from processing other jobs, leading to increased latency and reduced throughput. This is particularly problematic when calling LLM or AI APIs, where requests can take many seconds to complete.
13
+ Solid Queue works best when jobs finish quickly. A long HTTP request blocks a worker thread, so other jobs wait, latency rises, and throughput drops. LLM and other AI APIs make this worse, because a request can take many seconds to finish.
14
14
 
15
- **The Problem:**
15
+ Without this gem, each slow request holds a worker thread for its full duration:
16
16
 
17
17
  ```
18
18
  ┌────────────────────────────────────────────────────────────────────────┐
@@ -26,7 +26,7 @@ Solid Queue is designed with the assumption that jobs are short-lived and comple
26
26
  └────────────────────────────────────────────────────────────────────────┘
27
27
  ```
28
28
 
29
- **The Solution:**
29
+ With this gem, a worker thread only hands off the request, and the async processor waits for the response:
30
30
 
31
31
  ```
32
32
  ┌────────────────────────────────────────────────────────────────────────┐
@@ -42,11 +42,31 @@ Solid Queue is designed with the assumption that jobs are short-lived and comple
42
42
  └────────────────────────────────────────────────────────────────────────┘
43
43
  ```
44
44
 
45
- The async processor runs in a dedicated thread within your Solid Queue worker process, using Ruby's Fiber-based concurrency to handle hundreds of concurrent HTTP requests without blocking. When an HTTP request completes, a callback service is invoked for processing.
45
+ The async processor runs in a dedicated thread in your Solid Queue worker process. It uses Ruby's fiber-based concurrency to run hundreds of HTTP requests at the same time without blocking. When a request finishes, the gem calls your callback service.
46
46
 
47
- ## Quick Start
47
+ ## Quick start
48
48
 
49
- ### 1. Create a Callback Service
49
+ ### 1. Install the gem
50
+
51
+ Add the gem to your Gemfile:
52
+
53
+ ```ruby
54
+ gem "patient_http-solid_queue"
55
+ ```
56
+
57
+ Then install it, run the install generator, and run the migration:
58
+
59
+ ```bash
60
+ bundle install
61
+ bin/rails generate patient_http:solid_queue:install
62
+ bin/rails db:migrate
63
+ ```
64
+
65
+ The generator creates the migration for the crash-recovery tables and a commented initializer to start from. For details, including multi-database setups, see [Installation](#installation).
66
+
67
+ No other setup is required. When the gem loads, it registers the request handler and connects the processor to Solid Queue's startup and shutdown. You don't need to call a setup method. Every option has a working default. To change the defaults, see [Configuration](#configuration).
68
+
69
+ ### 2. Create a callback service
50
70
 
51
71
  Define a callback service class with `on_complete` and `on_error` methods:
52
72
 
@@ -69,9 +89,9 @@ class FetchDataCallback
69
89
  end
70
90
  ```
71
91
 
72
- ### 2. Make HTTP Requests
92
+ ### 3. Make HTTP requests
73
93
 
74
- Make HTTP requests from anywhere in your code using `PatientHttp`:
94
+ Make HTTP requests from anywhere in your code with the `PatientHttp` module:
75
95
 
76
96
  ```ruby
77
97
  PatientHttp.get(
@@ -82,32 +102,80 @@ PatientHttp.get(
82
102
  )
83
103
  ```
84
104
 
85
- ### 3. That's It!
105
+ The gem enqueues the request as an Active Job, which runs the request on a [PatientHttp](https://github.com/bdurand/patient_http) processor. When the request finishes, another Active Job calls your callback's `on_complete` method. If the request fails, the job calls `on_error` instead.
86
106
 
87
- The request will be enqueued as an Active Job and passed to a [PatientHttp](https://github.com/bdurand/patient_http) processor to execute asynchronously. When the HTTP request completes, your callback's `on_complete` method is executed in another Active Job.
107
+ The `response.callback_args` and `error.callback_args` methods return the arguments that you passed with the `callback_args` option.
88
108
 
89
- If an error occurs during the request, the `on_error` method is called instead.
109
+ For other HTTP methods, use `PatientHttp.post`, `PatientHttp.put`, `PatientHttp.patch`, `PatientHttp.delete`, `PatientHttp.head`, and `PatientHttp.query`. For the full API reference, see the [patient_http documentation](https://github.com/bdurand/patient_http).
90
110
 
91
- You can also call `PatientHttp.post`, `PatientHttp.put`, `PatientHttp.patch`, and `PatientHttp.delete` for other HTTP methods. See the [patient_http docs](https://github.com/bdurand/patient_http) for the full API reference.
111
+ > [!IMPORTANT]
112
+ > Don't raise an error in `on_error` to retry the request. Active Job retries the callback job, not the request. To retry the request, make a new request from `on_error`. Make sure that the retries stop if the error persists, or they can loop forever.
113
+ >
114
+ > The `on_error` callback runs only when the request raises an exception, such as a timeout or a connection failure. By default, HTTP error status codes (4xx and 5xx) don't call `on_error`. The gem treats these responses as completed requests and passes them to `on_complete`. To treat HTTP errors as exceptions, see [Handle HTTP error responses](#handle-http-error-responses).
92
115
 
93
- The `response.callback_args` and `error.callback_args` provide access to the arguments you passed via the `callback_args` option.
116
+ ## Usage
94
117
 
95
- > [!IMPORTANT]
96
- > Do not re-raise errors in the `on_error` callback as a means to retry the request. That will just retry the error callback job. If you want to retry the original request, you can enqueue a new request from within `on_error`. Be careful with this approach, though, as it can lead to infinite retry loops if the error condition is not resolved.
118
+ ### Make requests
97
119
 
98
- Callback jobs are not retried by default. If you want failed callbacks to be retried before being discarded, configure `retry_on` in an initializer:
120
+ Use the `PatientHttp` module methods to make requests. There's a method for each HTTP method:
99
121
 
100
122
  ```ruby
101
- PatientHttp::SolidQueue::CallbackJob.retry_on StandardError, wait: :polynomially_longer, attempts: 5
123
+ # GET request
124
+ PatientHttp.get("https://api.example.com/users/123",
125
+ callback: MyCallback, callback_args: {user_id: 123})
126
+
127
+ # POST request with a JSON body
128
+ PatientHttp.post("https://api.example.com/users",
129
+ json: {name: "John", email: "john@example.com"},
130
+ callback: MyCallback)
131
+
132
+ # PUT request
133
+ PatientHttp.put("https://api.example.com/users/123",
134
+ json: {name: "Updated Name"},
135
+ callback: MyCallback)
136
+
137
+ # PATCH request
138
+ PatientHttp.patch("https://api.example.com/users/123",
139
+ json: {status: "active"},
140
+ callback: MyCallback)
141
+
142
+ # DELETE request
143
+ PatientHttp.delete("https://api.example.com/users/123",
144
+ callback: MyCallback)
102
145
  ```
103
146
 
104
- Note that Active Job runs `after_discard` hooks for any unhandled exception, not just when configured retries are exhausted. Without `retry_on`, the first callback failure will trigger the `on_retries_exhausted` handler and delete any externally stored payload, even though the failed job can still be retried manually from Mission Control (such retries will fail if the payload was stored externally).
105
- >
106
- > Also note that the error callback is only called when an exception occurs during the HTTP request (timeout, connection failure, etc). HTTP error status codes (4xx, 5xx) do not trigger the error callback by default. Instead, they are treated as completed requests and passed to the `on_complete` callback. See the "Handling HTTP Error Responses" section below for how to treat HTTP errors as exceptions.
147
+ The methods take these options:
148
+
149
+ | Option | Description |
150
+ | --- | --- |
151
+ | `callback:` | Required. The callback service class, or its name. |
152
+ | `callback_args:` | A Hash of arguments that the callback reads from the response or error. See [Callback arguments](#callback-arguments). |
153
+ | `headers:` | The request headers. |
154
+ | `body:` | The request body. GET, HEAD, and DELETE requests can't have a body. |
155
+ | `json:` | An object to send as a JSON body. Can't be combined with `body:`. |
156
+ | `params:` | Query parameters to add to the URL. |
157
+ | `timeout:` | The request timeout in seconds. |
158
+ | `raise_error_responses:` | Whether to treat non-2xx responses as errors. See [Handle HTTP error responses](#handle-http-error-responses). |
159
+ | `processor:` | The name of the processor that runs the request. See [Named processors](#named-processors). |
160
+
161
+ For all options, see the [patient_http documentation](https://github.com/bdurand/patient_http#make-requests).
162
+
163
+ For more control, build a `PatientHttp::Request` object and pass it to `PatientHttp.execute`:
164
+
165
+ ```ruby
166
+ request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
167
+ headers: {"Authorization" => "Bearer token"},
168
+ params: {include: "profile"},
169
+ timeout: 30
170
+ )
171
+ PatientHttp.execute(request: request, callback: MyCallback, callback_args: {user_id: 123})
172
+ ```
107
173
 
108
- ### Handling HTTP Error Responses
174
+ For the full `Request` and `Response` API reference, see the [patient_http documentation](https://github.com/bdurand/patient_http).
109
175
 
110
- By default, HTTP error status codes (4xx, 5xx) are treated as successful responses and passed to the `on_complete` callback. You can check the status using `response.success?`, `response.client_error?`, or `response.server_error?`:
176
+ ### Handle HTTP error responses
177
+
178
+ By default, the gem treats HTTP error status codes (4xx and 5xx) as completed requests and passes them to `on_complete`. To check the status, use `response.success?`, `response.client_error?`, or `response.server_error?`:
111
179
 
112
180
  ```ruby
113
181
  class ApiCallback
@@ -132,22 +200,22 @@ PatientHttp.get(
132
200
  )
133
201
  ```
134
202
 
135
- If you prefer to treat HTTP errors as exceptions, you can use the `raise_error_responses` option. When enabled, non-2xx responses will call the `on_error` callback with an `HttpError` instead:
203
+ To treat HTTP errors as exceptions, set the `raise_error_responses` option. With this option, a non-2xx response calls `on_error` with a `PatientHttp::HttpError` instead:
136
204
 
137
205
  ```ruby
138
206
  class ApiCallback
139
207
  def on_complete(response)
140
- # Only called for 2xx responses
208
+ # Called only for 2xx responses.
141
209
  process_data(response.json)
142
210
  end
143
211
 
144
212
  def on_error(error)
145
- # Called for exceptions AND HTTP errors when using raise_error_responses
213
+ # Called for exceptions, and for HTTP errors when raise_error_responses is set.
146
214
  if error.is_a?(PatientHttp::HttpError)
147
- # Access the response via error.response
215
+ # The response is available from error.response.
148
216
  Rails.logger.error("HTTP #{error.status} from #{error.url}: #{error.response.body}")
149
217
  else
150
- # Regular request errors (timeout, connection, etc)
218
+ # Request errors, such as timeouts and connection failures.
151
219
  Rails.logger.error("Request failed: #{error.message}")
152
220
  end
153
221
  end
@@ -160,7 +228,7 @@ PatientHttp.get(
160
228
  )
161
229
  ```
162
230
 
163
- The `HttpError` provides convenient access to the response:
231
+ An `HttpError` gives you access to the request and the response:
164
232
 
165
233
  ```ruby
166
234
  def on_error(error)
@@ -170,97 +238,44 @@ def on_error(error)
170
238
  puts error.http_method # HTTP method
171
239
  puts error.response.body # Response body
172
240
  puts error.response.headers # Response headers
173
- puts error.response.json # Parse JSON response (if applicable)
241
+ puts error.response.json # Response body parsed as JSON
174
242
  end
175
243
  end
176
244
  ```
177
245
 
178
- ## Usage Patterns
179
-
180
- ### Making Requests
246
+ ### Named processors
181
247
 
182
- The primary interface for making requests is through the `PatientHttp` module, which provides convenience methods for all HTTP verbs:
248
+ 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:
183
249
 
184
250
  ```ruby
185
- # GET request
186
- PatientHttp.get("https://api.example.com/users/123",
187
- callback: MyCallback, callback_args: {user_id: 123})
188
-
189
- # POST request with JSON body
190
- PatientHttp.post("https://api.example.com/users",
191
- json: {name: "John", email: "john@example.com"},
192
- callback: MyCallback)
193
-
194
- # PUT request
195
- PatientHttp.put("https://api.example.com/users/123",
196
- json: {name: "Updated Name"},
197
- callback: MyCallback)
198
-
199
- # PATCH request
200
- PatientHttp.patch("https://api.example.com/users/123",
201
- json: {status: "active"},
202
- callback: MyCallback)
203
-
204
- # DELETE request
205
- PatientHttp.delete("https://api.example.com/users/123",
206
- callback: MyCallback)
207
- ```
208
-
209
- Available request options:
210
-
211
- - `callback:` - (required) Callback service class or class name
212
- - `callback_args:` - Hash of arguments passed to callback via response/error
213
- - `headers:` - Request headers
214
- - `body:` - Request body (for POST/PUT/PATCH)
215
- - `json:` - Object to serialize as JSON body (cannot use with body)
216
- - `params:` - Query parameters to append to URL
217
- - `timeout:` - Request timeout in seconds
218
- - `raise_error_responses:` - Treat non-2xx responses as errors
219
-
220
- You can also build a `PatientHttp::Request` object and pass it to `PatientHttp.execute` for more control:
221
-
222
- ```ruby
223
- request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
224
- headers: {"Authorization" => "Bearer token"},
225
- params: {include: "profile"},
226
- timeout: 30
227
- )
228
- PatientHttp.execute(request: request, callback: MyCallback, callback_args: {user_id: 123})
229
- ```
230
-
231
- See the [patient_http docs](https://github.com/bdurand/patient_http) for the full `Request` and `Response` API reference.
232
-
233
- ### Named Processors
234
-
235
- By default all requests share one processor and one `max_connections` cap. When one process serves workload classes with very different profiles (for example, large slow API calls and small fast webhook deliveries), a burst of one class can consume all of the capacity the other class needs. Named processor profiles isolate them:
236
-
237
- ```ruby
238
- PatientHttp::SolidQueue.configure do |config|
251
+ PatientHttp.configure do |config|
239
252
  config.processor(:llm, max_connections: 200, request_timeout: 120)
240
253
  config.processor(:webhooks, max_connections: 64, request_timeout: 10)
241
254
  end
242
255
  ```
243
256
 
244
- Each profile runs as an independent processor in the process, with its own capacity, timeouts, and threads. Profile options override the top-level configuration; anything not overridden (secrets, preprocessors, payload stores, encryption, logger) is shared. The `:default` processor always exists; declare `config.processor(:default, ...)` to override its options.
257
+ 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. To override its options, declare `config.processor(:default, ...)`.
245
258
 
246
- Route a request to a processor in any of these ways:
259
+ To send a request to a processor, use any of these methods:
247
260
 
248
261
  ```ruby
249
- # Explicit option on execute
250
- PatientHttp::SolidQueue.execute(request, callback: MyCallback, processor: :llm)
262
+ # An option on the request method.
263
+ PatientHttp.get(url, callback: MyCallback, processor: :llm)
251
264
 
252
- # On the request itself (survives serialization, retries, and crash recovery)
265
+ # A request object. The processor is kept through serialization, retries, and crash recovery.
253
266
  request = PatientHttp::Request.new(:get, url, processor: :llm)
254
267
 
255
- # Through a request template
268
+ # A request template.
256
269
  template = PatientHttp::RequestTemplate.new(base_url: url, processor: :llm)
257
270
  ```
258
271
 
259
- The processor name is serialized into the job arguments, so Active Job retries and crash recovery keep their routing. A job that names a processor that is not configured in the executing process raises `PatientHttp::UnknownProcessorError` and is retried with backoff; this makes new profile names safe to roll out gradually. Jobs enqueued by older gem versions run on the `:default` processor.
272
+ A request that names a processor that isn't declared in the process making the request raises `PatientHttp::UnknownProcessorError`, so a misspelled name fails where the request is made. Declare processors in an initializer that every process loads, such as the web server and the Solid Queue workers, not only in code that runs in worker processes.
260
273
 
261
- ### Using Request Templates
274
+ The processor name is saved in the job arguments, so Active Job retries and crash recovery send the request to the same processor. If a job names a processor that isn't configured in the process that runs it, the job raises `PatientHttp::UnknownProcessorError`, and Active Job retries it with backoff. As a result, you can roll out a new profile name gradually. Jobs enqueued by earlier versions of the gem run on the `:default` processor.
262
275
 
263
- For repeated requests to the same API, use `PatientHttp::RequestTemplate` to share configuration:
276
+ ### Use request templates
277
+
278
+ To share settings across requests to the same API, use `PatientHttp::RequestTemplate`:
264
279
 
265
280
  ```ruby
266
281
  class ApiService
@@ -292,11 +307,11 @@ class ApiService
292
307
  end
293
308
  ```
294
309
 
295
- ### Using the RequestHelper Module
310
+ If the template doesn't set a `timeout`, the configured `request_timeout` applies.
296
311
 
297
- For classes that make many async HTTP requests, you can include `PatientHttp::RequestHelper` to get convenient instance methods like `async_get`, `async_post`, `async_put`, `async_patch`, and `async_delete`. You can also define a request template at the class level using the `request_template` class method to set shared options like `base_url`, `headers`, and `timeout`.
312
+ ### Use the RequestHelper module
298
313
 
299
- When using this gem, the request handler is automatically registered when you call `PatientHttp::SolidQueue.configure` or when the processor starts — no manual setup is required. The handler stays registered when the processor stops, so a request submitted while the worker is shutting down is enqueued as a job and executed by the next process instead of being lost.
314
+ 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` instance methods. To set shared options such as `base_url`, `headers`, and `timeout`, use the `request_template` class method:
300
315
 
301
316
  ```ruby
302
317
  class NotificationService
@@ -323,18 +338,18 @@ class NotificationService
323
338
  end
324
339
  ```
325
340
 
326
- The `async_*` methods accept the same options as `PatientHttp.get`, `PatientHttp.post`, etc. Paths are resolved relative to the `base_url` defined in the request template.
341
+ The `async_*` methods take the same options as `PatientHttp.get`, `PatientHttp.post`, and the other module methods. Paths are relative to the template's `base_url`.
327
342
 
328
- See the [patient_http gem](https://github.com/bdurand/patient_http) for the full `RequestHelper` documentation.
343
+ For the full `RequestHelper` documentation, see the [patient_http documentation](https://github.com/bdurand/patient_http#use-the-requesthelper-module).
329
344
 
330
- ### Callback Arguments
345
+ ### Callback arguments
331
346
 
332
- Pass custom data to your callbacks using the `callback_args` option:
347
+ To pass data to your callbacks, use the `callback_args` option:
333
348
 
334
349
  ```ruby
335
350
  class FetchDataCallback
336
351
  def on_complete(response)
337
- # Access callback_args using symbol or string keys
352
+ # Read callback_args with symbol or string keys.
338
353
  user_id = response.callback_args[:user_id]
339
354
  request_timestamp = response.callback_args[:request_timestamp]
340
355
 
@@ -354,7 +369,7 @@ class FetchDataCallback
354
369
  end
355
370
  end
356
371
 
357
- # Pass data via callback_args option
372
+ # Pass data with the callback_args option.
358
373
  PatientHttp.get(
359
374
  "https://api.example.com/users/#{user_id}",
360
375
  callback: FetchDataCallback,
@@ -365,137 +380,185 @@ PatientHttp.get(
365
380
  )
366
381
  ```
367
382
 
368
- **Important details about callback_args:**
383
+ The `callback_args` value follows these rules:
384
+
385
+ - 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`.
386
+ - Hash keys are converted to strings, including the keys of nested hashes and of hashes in arrays.
387
+ - You can read the arguments with symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`.
388
+ - Reading a key that isn't set raises a `KeyError`. To get a default value instead, use `callback_args.fetch(:user_id, nil)`.
389
+
390
+ ### Protect sensitive data
369
391
 
370
- - Must be a Hash (or respond to `to_h`) containing only JSON-native types: `nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, or `Hash`
371
- - Hash keys will be converted to strings for serialization
372
- - Nested hashes and hashes in arrays also have their keys converted to strings
373
- - You can access callback_args using either symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`
392
+ The gem stores requests and responses in your queue database so that it can run the callback job. If they contain sensitive data, that data is stored in plain text.
374
393
 
375
- ### Sensitive Data Handling
394
+ To protect the data, configure encryption. The gem then encrypts all request and response data before it stores the data in the queue, and decrypts the data when it reads it.
376
395
 
377
- Requests and responses from asynchronous HTTP requests may be stored in your queue backend (and optionally external storage) in order to execute completion callbacks. This can raise security concerns if they contain sensitive data since the data will be stored in plain text.
396
+ #### Use an encryption key
378
397
 
379
- Encryption is configured on the parent `patient_http` gem. You can set an `encryption_key` to automatically encrypt and decrypt request and response data using `ActiveSupport::MessageEncryptor`:
398
+ The simplest option is `encryption_key=`. It uses [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) with AES-256-GCM:
380
399
 
381
400
  ```ruby
382
- PatientHttp::SolidQueue.configure do |config|
383
- config.encryption_key = Rails.application.credentials.patient_http_secret
401
+ PatientHttp.configure do |config|
402
+ config.encryption_key = Rails.application.credentials.patient_http_encryption_key
384
403
  end
385
404
  ```
386
405
 
387
- See the [patient_http gem](https://github.com/bdurand/patient_http) for full documentation on encryption options, including key rotation and custom encryption callables.
406
+ To rotate keys, pass an array. The first key encrypts data, and all keys are tried for decryption:
407
+
408
+ ```ruby
409
+ PatientHttp.configure do |config|
410
+ config.encryption_key = [
411
+ Rails.application.credentials.patient_http_encryption_key,
412
+ Rails.application.credentials.patient_http_old_key
413
+ ]
414
+ end
415
+ ```
416
+
417
+ #### Use custom callables
418
+
419
+ To use another encryption library, provide callables that take and return raw bytes as a String:
420
+
421
+ ```ruby
422
+ PatientHttp.configure do |config|
423
+ config.encryption { |bytes| MyEncryption.encrypt(bytes) }
424
+ config.decryption { |bytes| MyEncryption.decrypt(bytes) }
425
+ end
426
+ ```
427
+
428
+ You can also pass any object that responds to `call`:
429
+
430
+ ```ruby
431
+ PatientHttp.configure do |config|
432
+ config.encryption(->(bytes) { MyEncryption.encrypt(bytes) })
433
+ config.decryption(->(bytes) { MyEncryption.decrypt(bytes) })
434
+ end
435
+ ```
436
+
437
+ To keep API tokens out of the queue entirely, use secrets instead. For secrets, request preprocessors, and payload stores for large payloads, see the [patient_http documentation](https://github.com/bdurand/patient_http#sensitive-and-large-payloads).
388
438
 
389
439
  ## Configuration
390
440
 
391
- The gem can be configured globally in an initializer:
441
+ All configuration is optional. To set options, call `PatientHttp.configure` in an initializer. The method yields this gem's configuration. `PatientHttp::SolidQueue.configure` does the same thing, but `PatientHttp.configure` keeps the initializer free of references to the job system.
442
+
443
+ Every call yields the same configuration object, so options accumulate. Several initializers can each set options without overwriting one another.
392
444
 
393
445
  ```ruby
394
- PatientHttp::SolidQueue.configure do |config|
395
- # Maximum concurrent HTTP requests (default: 256)
446
+ PatientHttp.configure do |config|
447
+ # Maximum concurrent HTTP requests (default: 256).
396
448
  config.max_connections = 256
397
449
 
398
- # Default timeout for HTTP requests in seconds (default: 60)
450
+ # Default timeout for HTTP requests in seconds (default: 60).
399
451
  config.request_timeout = 60
400
452
 
401
- # Maximum number of host clients to pool (default: 100)
453
+ # Maximum number of host clients to pool (default: 100).
402
454
  config.connection_pool_size = 100
403
455
 
404
- # Connection timeout in seconds (default: nil, uses request_timeout)
456
+ # Timeout in seconds to open a connection, including the TCP connect and the
457
+ # TLS handshake (default: nil, no limit). It doesn't limit the wait for a
458
+ # response; request_timeout does that.
405
459
  config.connection_timeout = 10
406
460
 
407
- # Number of retries for failed requests (default: 3)
408
- config.retries = 3
461
+ # TCP keepalive for pooled connections (default: nil, the kernel sends no
462
+ # probes). A number sets the idle seconds before the first probe. A Hash also
463
+ # sets the interval and the probe count, for example
464
+ # {idle: 30, interval: 10, count: 3}. The Hash must contain :idle. The
465
+ # :interval default is 10 seconds, and the :count default is 3 probes.
466
+ config.tcp_keepalive = 30
409
467
 
410
- # Handler called when a callback job exhausts all Sidekiq retries
411
- config.on_retries_exhausted { |error| MyAlertService.notify(error) }
468
+ # Seconds that sent data can stay unacknowledged before the kernel closes the
469
+ # connection (default: nil, the kernel default applies). Sets
470
+ # TCP_USER_TIMEOUT, which is available only on Linux.
471
+ config.tcp_user_timeout = 30
472
+
473
+ # Number of retries for failed requests (default: 3).
474
+ config.retries = 3
412
475
 
413
- # HTTP/HTTPS proxy URL (default: nil)
414
- # Supports authentication: "http://user:pass@proxy.example.com:8080"
476
+ # HTTP or HTTPS proxy URL (default: nil). Supports authentication, for
477
+ # example "http://user:pass@proxy.example.com:8080".
415
478
  config.proxy_url = "http://proxy.example.com:8080"
416
479
 
417
- # Default User-Agent header for all requests (default: "PatientHttp")
480
+ # Default User-Agent header for all requests (default: "PatientHttp").
418
481
  config.user_agent = "MyApp/1.0"
419
482
 
420
- # Timeout for graceful shutdown in seconds
421
- # (default: SolidQueue.shutdown_timeout - 2)
422
- # This should be less than your worker shutdown timeout
483
+ # Timeout for graceful shutdown in seconds (default: the Solid Queue shutdown
484
+ # timeout minus 2 seconds). Must be less than Solid Queue's shutdown timeout.
423
485
  config.shutdown_timeout = 23
424
486
 
425
- # Maximum response body size in bytes (default: 1MB)
426
- # Responses larger than this will trigger ResponseTooLargeError
487
+ # Maximum response body size in bytes (default: 1MB). Larger responses raise
488
+ # ResponseTooLargeError.
427
489
  config.max_response_size = 1024 * 1024
428
490
 
429
- # Maximum number of redirects to follow (default: 5, 0 disables)
491
+ # Maximum number of redirects to follow (default: 5; 0 turns off redirects).
430
492
  config.max_redirects = 5
431
493
 
432
- # Whether to raise HttpError for non-2xx responses by default (default: false)
494
+ # Whether to raise HttpError for non-2xx responses by default (default: false).
433
495
  config.raise_error_responses = false
434
496
 
435
- # Heartbeat interval for crash recovery in seconds (default: 60)
497
+ # Heartbeat interval for crash recovery in seconds (default: 60).
436
498
  config.heartbeat_interval = 60
437
499
 
438
- # Orphan detection threshold in seconds (default: 300)
439
- # Requests older than this without a heartbeat will be re-enqueued
500
+ # Seconds without a heartbeat after which a request is re-enqueued
501
+ # (default: 300).
440
502
  config.orphan_threshold = 300
441
503
 
442
- # Size threshold in bytes for external payload storage (default: 64KB)
443
- # Payloads larger than this will be stored externally when a payload
444
- # store is configured.
504
+ # Size in bytes above which payloads are stored externally when a payload
505
+ # store is configured (default: 64KB).
445
506
  config.payload_store_threshold = 64 * 1024
446
507
 
447
- # Queue name for RequestJob and CallbackJob (default: nil, Active Job default)
448
- config.queue_name = "async_http"
508
+ # Queue name for RequestJob and CallbackJob (default: nil, the Active Job
509
+ # default queue).
510
+ config.queue_name = "patient_http"
449
511
 
450
- # Number of threads that decode responses and deliver results (default: 2)
512
+ # Number of threads that decode responses and deliver results (default: 2).
451
513
  config.completion_threads = 2
452
514
 
453
- # Maximum connections per host (default: nil, unlimited)
515
+ # Maximum connections to each host (default: nil, no limit).
454
516
  config.max_connections_per_host = 32
455
517
 
456
- # Named processor profiles for workload isolation (see Named Processors)
518
+ # Named processor profiles for workload isolation. See Named processors.
457
519
  config.processor(:llm, max_connections: 200, request_timeout: 120)
458
520
  config.processor(:webhooks, max_connections: 64, request_timeout: 10)
459
521
 
460
- # Custom logger (defaults to SolidQueue.logger)
522
+ # Handler that runs when a callback job is discarded. See Handle exhausted
523
+ # retries.
524
+ config.on_retries_exhausted { |error| MyAlertService.notify(error) }
525
+
526
+ # Logger (default: SolidQueue.logger).
461
527
  config.logger = Rails.logger
462
528
 
463
- # Encryption key for sensitive data (see Sensitive Data Handling)
464
- # Accepts a string or an array of strings for key rotation.
465
- # Encryption is provided by the parent patient_http gem.
466
- config.encryption_key = Rails.application.credentials.patient_http_secret
529
+ # Encryption for sensitive data. See Protect sensitive data.
530
+ config.encryption_key = Rails.application.credentials.patient_http_encryption_key
467
531
  end
468
532
  ```
469
533
 
470
- See the [Configuration](lib/patient_http/solid_queue/configuration.rb) class for all available options.
534
+ For all options, see the [Configuration](lib/patient_http/solid_queue/configuration.rb) class. For the HTTP options that this gem inherits, see the [patient_http documentation](https://github.com/bdurand/patient_http#configuration).
471
535
 
472
- ### Tuning Tips
536
+ ### Tuning tips
473
537
 
474
- - `max_connections`: Adjust this based on your system's resources. Each connection uses memory and file descriptors. A tuned system with sufficient resources can handle thousands of concurrent connections.
475
- - `request_timeout`: Set this based on the expected response times of the APIs you are calling. AI APIs might sometimes take minutes to respond as they generate content.
476
- - `connection_pool_size`: Controls how many connections to different hosts are kept alive. Increase for applications calling many different API endpoints.
477
- - `connection_timeout`: Set this if you need to fail fast on connection establishment. Useful for detecting network issues quickly.
478
- - `retries`: Number of times to retry a failed request before calling the error callback.
479
- - `max_response_size`: Set this to limit the maximum size of HTTP responses. This helps prevent excessive memory usage from unexpectedly large responses. Responses need to be serialized as Active Job arguments and very large responses may cause performance issues. If a response body is text content, it will be compressed to save space. However, binary content needs to be Base64 encoded which increases size by ~33%.
480
- - `payload_store_threshold`: Lower this if your queue backend struggles with large payloads; higher values avoid extra external storage reads/writes.
481
- - `max_connections_per_host`: Bounds sockets per host. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections; raise the limit if needed.
482
- - `completion_threads`: Number of threads that decode responses and deliver results (default 2). Increase when result callbacks do heavier work and completions back up behind them. Size the Active Record connection pool to cover these threads plus the task monitor thread in addition to the worker threads.
483
- - `shutdown_timeout`: Must be below the process supervisor's termination window so the drain finishes before a hard kill. The default derives it from Solid Queue's own shutdown timeout; check any additional supervisor stop timeout as well.
484
- - `heartbeat_interval` and `orphan_threshold`: For high-churn workloads, keep `heartbeat_interval` as large as your recovery SLO allows (while still less than `orphan_threshold`) to reduce write/update pressure on monitoring tables. If Solid Queue uses PostgreSQL and request volume is high, tune autovacuum for the queue database tables because `inflight_requests` is intentionally insert/update/delete heavy.
538
+ - `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.
539
+ - `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.
540
+ - `connection_pool_size`: Sets the maximum number of hosts whose connections are kept open. Increase it if your application calls many different hosts.
541
+ - `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.
542
+ - `retries`: Sets the number of times to retry a failed request before the gem calls the error callback.
543
+ - `max_response_size`: Limits the size of HTTP responses to prevent high memory use from unexpectedly large responses. Responses are serialized in Active Job arguments, and very large responses can slow the queue database down. Text response bodies are compressed to save space. Binary bodies are Base64 encoded, which increases their size by about 33%.
544
+ - `payload_store_threshold`: Lower this if large payloads slow your queue down. Higher values avoid extra reads and writes to the payload store.
545
+ - `max_connections_per_host`: Limits the sockets open to each host. Make sure that the process file descriptor limit covers `max_connections`, plus idle pooled connections, plus the application's own connections. Raise the limit if needed.
546
+ - `shutdown_timeout`: Must be less than the process supervisor's stop timeout, so that in-flight requests finish before a hard kill. The default is based on Solid Queue's shutdown timeout. If a container orchestrator or init system also stops the process, check its stop timeout as well.
547
+ - `completion_threads`: Increase this when result delivery does heavy work, such as serialization or encryption, and finished requests wait for a thread. Size the Active Record connection pool to cover these threads, the task monitor thread, and the worker threads.
548
+ - `heartbeat_interval` and `orphan_threshold`: For high-volume workloads, set `heartbeat_interval` as high as your recovery objective allows, while you keep it less than `orphan_threshold`. Fewer heartbeats mean fewer writes to the crash-recovery tables. If Solid Queue uses PostgreSQL and the request volume is high, tune autovacuum for the queue database tables. The `inflight_requests` table has many inserts, updates, and deletes by design.
485
549
 
486
550
  > [!IMPORTANT]
551
+ > When the processor reaches `max_connections`, a new request raises an error in its Active Job. The job retries with polynomial backoff until the processor has capacity.
487
552
  >
488
- > One difference between using this gem and making synchronous HTTP requests from a Solid Queue job is that if `max_connections` is reached due to slow asynchronous requests, new requests will trigger an error on the Active Job. The job declares `retry_on` for this error with a polynomial backoff, so it will automatically be retried until the processor has capacity again.
489
- >
490
- > In contrast, slow synchronous HTTP requests will fill up the worker pool and block new jobs from being dequeued until a worker thread becomes free.
553
+ > Synchronous HTTP requests in Solid Queue jobs behave differently. Slow synchronous requests fill the worker pool, and no new jobs start until a worker thread is free.
491
554
  >
492
- > In general, the former behavior is preferable because it allows Solid Queue to continue processing other jobs and prevents getting into a state with 1000's of jobs stuck in the queue.
555
+ > The asynchronous behavior is usually better, because Solid Queue keeps running other jobs, and thousands of jobs don't pile up in the queue.
493
556
 
494
- ## Metrics and Monitoring
557
+ ## Metrics and monitoring
495
558
 
496
- ### Callbacks for Custom Monitoring
559
+ ### Monitoring callbacks
497
560
 
498
- You can register callbacks to integrate with your monitoring system using the `after_completion` and `after_error` hooks:
561
+ To send metrics to your monitoring system, register `after_completion` and `after_error` callbacks:
499
562
 
500
563
  ```ruby
501
564
  PatientHttp::SolidQueue.after_completion do |response|
@@ -504,86 +567,101 @@ PatientHttp::SolidQueue.after_completion do |response|
504
567
  end
505
568
 
506
569
  PatientHttp::SolidQueue.after_error do |error|
507
- error_type = error.is_a?(PatientHttp::Error) ? error.error_type : "exception"
508
- StatsD.increment("patient_http.error.#{error_type}")
509
- Rails.logger.error("Async HTTP error: #{error.class.name} - #{error.message}")
570
+ StatsD.increment("patient_http.error.#{error.error_type}")
571
+ Sentry.capture_message("Async HTTP error: #{error.message}")
572
+ end
573
+ ```
574
+
575
+ You can register more than one callback. Callbacks run in the order that you register them.
576
+
577
+ ### Handle exhausted retries
578
+
579
+ Callback jobs aren't retried by default. To retry a failed callback job before Active Job discards it, configure `retry_on` in an initializer:
580
+
581
+ ```ruby
582
+ PatientHttp::SolidQueue::CallbackJob.retry_on StandardError, wait: :polynomially_longer, attempts: 5
583
+ ```
584
+
585
+ When Active Job discards a callback job, the gem can call an `on_retries_exhausted` handler. Use the handler to send an alert or to record that a callback failed permanently. The handler receives the same error object as `on_error`:
586
+
587
+ ```ruby
588
+ PatientHttp.configure do |config|
589
+ config.on_retries_exhausted do |error|
590
+ Sentry.capture_message("Callback permanently failed: #{error.message}")
591
+ DeadLetterRecord.create!(
592
+ error_message: error.message,
593
+ callback_args: error.callback_args
594
+ )
595
+ end
596
+ end
597
+ ```
598
+
599
+ You can also assign any object that responds to `call`:
600
+
601
+ ```ruby
602
+ PatientHttp.configure do |config|
603
+ config.on_retries_exhausted = ->(error) { MyAlertService.notify(error) }
510
604
  end
511
605
  ```
512
606
 
513
- You can register multiple callbacks; they will be called in the order registered.
607
+ > [!NOTE]
608
+ > The gem calls the `on_retries_exhausted` handler only for callback jobs that deliver an error to `on_error`. If the handler raises an exception, the gem logs a warning, and the discarded job cleanup continues as usual.
609
+ >
610
+ > Active Job runs `after_discard` hooks for any unhandled exception, not only when the configured retries run out. Without `retry_on`, the first callback failure calls the `on_retries_exhausted` handler and deletes any externally stored payload. You can still retry the failed job by hand from Mission Control, but the retry fails if the payload was stored externally.
514
611
 
515
- ## Shutdown Behavior
612
+ ## Shutdown behavior
516
613
 
517
- The async HTTP processor automatically hooks in with Solid Queue's lifecycle events.
614
+ The async HTTP processor follows Solid Queue's lifecycle events:
518
615
 
519
- 1. **Startup:** Processor starts automatically when Solid Queue starts a worker
520
- 2. **Shutdown:** Processor waits up to `shutdown_timeout` seconds for in-flight requests to complete
616
+ 1. **Startup**: The processor starts when Solid Queue starts a worker.
617
+ 2. **Shutdown**: The processor waits up to `shutdown_timeout` seconds for in-flight requests to finish.
521
618
 
522
- ### Incomplete Request Handling
619
+ ### Incomplete requests
523
620
 
524
- If requests are still in-flight when shutdown times out:
621
+ If requests are still in flight when the shutdown timeout ends, the gem interrupts them and re-enqueues their Active Jobs. The jobs run again when Solid Queue restarts or on another worker, so no work is lost during deployments or restarts.
525
622
 
526
- - In-flight requests are interrupted
527
- - The **original Active Job** is automatically re-enqueued
528
- - Re-enqueued jobs will be processed again when workers are available
623
+ ### Crash recovery
529
624
 
530
- This ensures no work is lost during deployments or restarts.
625
+ The gem recovers requests from processes that crash:
531
626
 
532
- ### Crash Recovery
627
+ 1. **Heartbeats**: Every `heartbeat_interval` seconds, each process updates the heartbeat times of its in-flight requests in the database.
628
+ 2. **Orphan detection**: One process at a time checks for requests that haven't had a heartbeat in `orphan_threshold` seconds.
629
+ 3. **Re-enqueue**: The gem re-enqueues the Active Jobs of the orphaned requests.
533
630
 
534
- The gem includes crash recovery to handle process failures:
631
+ As a result, if a Solid Queue worker process crashes, another process retries its in-flight requests.
535
632
 
536
- 1. **Heartbeat Tracking:** Every `heartbeat_interval` seconds, the processor updates heartbeat timestamps for all in-flight requests in the database
537
- 2. **Orphan Detection:** One processor periodically checks for requests that haven't received a heartbeat update in `orphan_threshold` seconds
538
- 3. **Automatic Re-enqueue:** Orphaned requests have their original Active Jobs re-enqueued
633
+ Crash recovery gives at-least-once delivery. If a process crashes at the wrong moment, such as between a re-enqueue and the removal of the registry entry, a request can run more than once, and its callback can run more than once. Make your callbacks idempotent. If the gem can't write a request's registry entry, the processor rejects the request, the job raises `PatientHttp::SolidQueue::RegistrationError`, and Active Job retries it.
539
634
 
540
- This ensures that if a worker process crashes, its in-flight requests will be retried by another process.
635
+ If the gem can't hand a result to a callback job, it keeps the request's registry entry, so crash recovery re-enqueues the request.
541
636
 
542
637
  ## Testing
543
638
 
544
- The gem supports testing with Active Job test adapters. When in test mode (`PatientHttp.testing?`), async HTTP requests are executed immediately within the worker thread, blocking until completion. This allows you to write tests that verify the full request/response cycle without needing the async processor to be running.
639
+ The gem supports the Active Job test adapters. When `RAILS_ENV`, `RACK_ENV`, or `APP_ENV` is `test`, requests run immediately in the worker thread and block until they finish. As a result, tests can check the full request and response cycle without a running processor.
545
640
 
546
641
  ## Installation
547
642
 
548
- Add this line to your application's Gemfile:
643
+ Add the gem to your Gemfile:
549
644
 
550
645
  ```ruby
551
646
  gem "patient_http-solid_queue"
552
647
  ```
553
648
 
554
- Then execute:
649
+ Then install it:
555
650
 
556
651
  ```bash
557
652
  bundle install
558
653
  ```
559
654
 
560
- Install and run the gem migrations:
655
+ Run the install generator, and then run the migration:
561
656
 
562
657
  ```bash
563
- bin/rails patient_http_solid_queue:install:migrations
658
+ bin/rails generate patient_http:solid_queue:install
564
659
  bin/rails db:migrate
565
660
  ```
566
661
 
567
- The database tables are used for crash recovery and monitoring of in-flight requests and need to be added to the same database that Solid Queue uses.
568
-
569
- By default, this install task copies migrations to the `queue` database migration path (typically `db/queue_migrate`).
570
- If your Solid Queue database name is different, override it with `DATABASE=your_database_name`.
662
+ The generator creates a migration for the crash-recovery and in-flight request tables. It also creates a commented initializer, which you can edit or delete.
571
663
 
572
- For a typical multi-database setup, ensure your `queue` database config defines its own migration path:
573
-
574
- ```yaml
575
- development:
576
- primary:
577
- adapter: sqlite3
578
- database: storage/development.sqlite3
579
- queue:
580
- adapter: sqlite3
581
- database: storage/development_queue.sqlite3
582
- migrations_paths:
583
- - db/queue_migrate
584
- ```
585
-
586
- PostgreSQL example:
664
+ The tables must be in the database that Solid Queue uses. The generator finds that database from `config.solid_queue.connects_to`, or from the database names in `config/database.yml`, and writes the migration to its migrations path, so a multi-database application needs no extra arguments. The generator prints the migrate command for the database that it chose. For a typical setup like the following, the command is `bin/rails db:migrate:queue`:
587
665
 
588
666
  ```yaml
589
667
  development:
@@ -597,51 +675,42 @@ development:
597
675
  - db/queue_migrate
598
676
  ```
599
677
 
600
- Then run:
678
+ If the generator can't find the database, name it:
601
679
 
602
680
  ```bash
603
- bin/rails patient_http_solid_queue:install:migrations
604
- bin/rails db:queue:migrate
681
+ bin/rails generate patient_http:solid_queue:install --database=solid_queue
605
682
  ```
606
683
 
607
- If your Solid Queue database is not named `queue`, pass its name explicitly when installing migrations:
608
-
609
- ```bash
610
- bin/rails patient_http_solid_queue:install:migrations DATABASE=solid_queue
611
- bin/rails db:migrate:solid_queue
612
- ```
684
+ To generate only the migration, pass `--skip-initializer`.
613
685
 
614
686
  ## Contributing
615
687
 
616
688
  Open a pull request on [GitHub](https://github.com/bdurand/patient_http-solid_queue).
617
689
 
618
- Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
690
+ Follow the [standardrb](https://github.com/testdouble/standard) style, and run `standardrb --fix` before you submit a pull request.
619
691
 
620
- Run the test suite with:
692
+ Run the tests:
621
693
 
622
694
  ```bash
623
695
  bundle exec rake
624
696
  ```
625
697
 
626
- There is also a bundled test app in the `test_app` directory that can be used for manual testing and experimentation.
627
-
628
- To run the test app, first install the dependencies:
698
+ The `test_app` directory has a test app for manual testing. To run it, install its dependencies:
629
699
 
630
700
  ```bash
631
701
  bundle exec rake test_app:bundle
632
702
  ```
633
703
 
634
- The server will run on http://localhost:9292 and can be started with:
704
+ Then start the server, which runs at http://localhost:9292:
635
705
 
636
706
  ```bash
637
707
  bundle exec rake test_app
638
708
  ```
639
709
 
640
- ## Further Reading
710
+ ## Further reading
641
711
 
642
712
  - [Architecture](ARCHITECTURE.md)
643
713
 
644
-
645
714
  ## License
646
715
 
647
716
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).