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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +10 -8
- data/CHANGELOG.md +24 -0
- data/README.md +303 -234
- data/VERSION +1 -1
- data/lib/generators/patient_http/solid_queue/install_generator.rb +248 -0
- data/lib/generators/patient_http/solid_queue/templates/initializer.rb +102 -0
- data/lib/patient_http/solid_queue/callback_job.rb +14 -7
- data/lib/patient_http/solid_queue/configuration.rb +178 -76
- data/lib/patient_http/solid_queue/context.rb +10 -9
- data/lib/patient_http/solid_queue/engine.rb +3 -1
- data/lib/patient_http/solid_queue/gc_lock.rb +4 -4
- data/lib/patient_http/solid_queue/inflight_request.rb +4 -4
- data/lib/patient_http/solid_queue/lifecycle_hooks.rb +11 -1
- data/lib/patient_http/solid_queue/process_registration.rb +4 -4
- data/lib/patient_http/solid_queue/processor_observer.rb +43 -12
- data/lib/patient_http/solid_queue/record.rb +2 -1
- data/lib/patient_http/solid_queue/request_executor.rb +37 -20
- data/lib/patient_http/solid_queue/request_job.rb +21 -12
- data/lib/patient_http/solid_queue/task_handler.rb +44 -12
- data/lib/patient_http/solid_queue/task_monitor.rb +91 -73
- data/lib/patient_http/solid_queue/task_monitor_thread.rb +15 -13
- data/lib/patient_http/solid_queue.rb +274 -122
- data/patient_http-solid_queue.gemspec +2 -2
- metadata +8 -6
- /data/db/migrate/{20260216000000_create_solid_queue_async_http_tables.rb → 20260216000000_create_patient_http_solid_queue_tables.rb} +0 -0
data/README.md
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
*Built for APIs that like to think.*
|
|
8
8
|
|
|
9
|
-
This gem
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
47
|
+
## Quick start
|
|
48
48
|
|
|
49
|
-
### 1.
|
|
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
|
-
###
|
|
92
|
+
### 3. Make HTTP requests
|
|
73
93
|
|
|
74
|
-
Make HTTP requests from anywhere in your code
|
|
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
|
-
|
|
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
|
|
107
|
+
The `response.callback_args` and `error.callback_args` methods return the arguments that you passed with the `callback_args` option.
|
|
88
108
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
+
## Usage
|
|
94
117
|
|
|
95
|
-
|
|
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
|
-
|
|
120
|
+
Use the `PatientHttp` module methods to make requests. There's a method for each HTTP method:
|
|
99
121
|
|
|
100
122
|
```ruby
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
174
|
+
For the full `Request` and `Response` API reference, see the [patient_http documentation](https://github.com/bdurand/patient_http).
|
|
109
175
|
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
213
|
+
# Called for exceptions, and for HTTP errors when raise_error_responses is set.
|
|
146
214
|
if error.is_a?(PatientHttp::HttpError)
|
|
147
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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 #
|
|
241
|
+
puts error.response.json # Response body parsed as JSON
|
|
174
242
|
end
|
|
175
243
|
end
|
|
176
244
|
```
|
|
177
245
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
### Making Requests
|
|
246
|
+
### Named processors
|
|
181
247
|
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
259
|
+
To send a request to a processor, use any of these methods:
|
|
247
260
|
|
|
248
261
|
```ruby
|
|
249
|
-
#
|
|
250
|
-
PatientHttp
|
|
262
|
+
# An option on the request method.
|
|
263
|
+
PatientHttp.get(url, callback: MyCallback, processor: :llm)
|
|
251
264
|
|
|
252
|
-
#
|
|
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
|
-
#
|
|
268
|
+
# A request template.
|
|
256
269
|
template = PatientHttp::RequestTemplate.new(base_url: url, processor: :llm)
|
|
257
270
|
```
|
|
258
271
|
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
310
|
+
If the template doesn't set a `timeout`, the configured `request_timeout` applies.
|
|
296
311
|
|
|
297
|
-
|
|
312
|
+
### Use the RequestHelper module
|
|
298
313
|
|
|
299
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
345
|
+
### Callback arguments
|
|
331
346
|
|
|
332
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
+
#### Use an encryption key
|
|
378
397
|
|
|
379
|
-
|
|
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
|
|
383
|
-
config.encryption_key = Rails.application.credentials.
|
|
401
|
+
PatientHttp.configure do |config|
|
|
402
|
+
config.encryption_key = Rails.application.credentials.patient_http_encryption_key
|
|
384
403
|
end
|
|
385
404
|
```
|
|
386
405
|
|
|
387
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
408
|
-
|
|
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
|
-
#
|
|
411
|
-
|
|
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
|
|
414
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
439
|
-
#
|
|
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
|
|
443
|
-
#
|
|
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
|
|
448
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
464
|
-
|
|
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
|
-
|
|
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
|
|
536
|
+
### Tuning tips
|
|
473
537
|
|
|
474
|
-
- `max_connections`:
|
|
475
|
-
- `request_timeout`: Set this based on the
|
|
476
|
-
- `connection_pool_size`:
|
|
477
|
-
- `connection_timeout`:
|
|
478
|
-
- `retries`:
|
|
479
|
-
- `max_response_size`:
|
|
480
|
-
- `payload_store_threshold`: Lower this if
|
|
481
|
-
- `max_connections_per_host`:
|
|
482
|
-
- `
|
|
483
|
-
- `
|
|
484
|
-
- `heartbeat_interval` and `orphan_threshold`: For high-
|
|
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
|
-
>
|
|
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
|
-
>
|
|
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
|
|
557
|
+
## Metrics and monitoring
|
|
495
558
|
|
|
496
|
-
###
|
|
559
|
+
### Monitoring callbacks
|
|
497
560
|
|
|
498
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
|
612
|
+
## Shutdown behavior
|
|
516
613
|
|
|
517
|
-
The async HTTP processor
|
|
614
|
+
The async HTTP processor follows Solid Queue's lifecycle events:
|
|
518
615
|
|
|
519
|
-
1. **Startup
|
|
520
|
-
2. **Shutdown
|
|
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
|
|
619
|
+
### Incomplete requests
|
|
523
620
|
|
|
524
|
-
If requests are still in
|
|
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
|
-
|
|
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
|
-
|
|
625
|
+
The gem recovers requests from processes that crash:
|
|
531
626
|
|
|
532
|
-
|
|
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
|
-
|
|
631
|
+
As a result, if a Solid Queue worker process crashes, another process retries its in-flight requests.
|
|
535
632
|
|
|
536
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
649
|
+
Then install it:
|
|
555
650
|
|
|
556
651
|
```bash
|
|
557
652
|
bundle install
|
|
558
653
|
```
|
|
559
654
|
|
|
560
|
-
|
|
655
|
+
Run the install generator, and then run the migration:
|
|
561
656
|
|
|
562
657
|
```bash
|
|
563
|
-
bin/rails
|
|
658
|
+
bin/rails generate patient_http:solid_queue:install
|
|
564
659
|
bin/rails db:migrate
|
|
565
660
|
```
|
|
566
661
|
|
|
567
|
-
The
|
|
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
|
-
|
|
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
|
-
|
|
678
|
+
If the generator can't find the database, name it:
|
|
601
679
|
|
|
602
680
|
```bash
|
|
603
|
-
bin/rails
|
|
604
|
-
bin/rails db:queue:migrate
|
|
681
|
+
bin/rails generate patient_http:solid_queue:install --database=solid_queue
|
|
605
682
|
```
|
|
606
683
|
|
|
607
|
-
|
|
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
|
-
|
|
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
|
|
692
|
+
Run the tests:
|
|
621
693
|
|
|
622
694
|
```bash
|
|
623
695
|
bundle exec rake
|
|
624
696
|
```
|
|
625
697
|
|
|
626
|
-
|
|
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
|
-
|
|
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
|
|
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).
|