patient_http 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +8 -7
- data/CHANGELOG.md +39 -0
- data/README.md +540 -514
- data/VERSION +1 -1
- data/lib/patient_http/callback_args.rb +53 -48
- data/lib/patient_http/callback_validator.rb +11 -7
- data/lib/patient_http/class_helper.rb +6 -7
- data/lib/patient_http/client.rb +28 -22
- data/lib/patient_http/client_pool.rb +134 -39
- data/lib/patient_http/completion_executor.rb +20 -20
- data/lib/patient_http/configuration.rb +367 -119
- data/lib/patient_http/connection_endpoint.rb +150 -0
- data/lib/patient_http/encryptor.rb +28 -18
- data/lib/patient_http/error.rb +24 -18
- data/lib/patient_http/external_storage.rb +42 -38
- data/lib/patient_http/http_error.rb +30 -26
- data/lib/patient_http/http_headers.rb +57 -26
- data/lib/patient_http/immediate_retries.rb +98 -0
- data/lib/patient_http/inline_task_handler.rb +15 -10
- data/lib/patient_http/lifecycle_manager.rb +39 -40
- data/lib/patient_http/outgoing_request.rb +25 -23
- data/lib/patient_http/payload.rb +28 -26
- data/lib/patient_http/payload_store/active_record_store.rb +31 -34
- data/lib/patient_http/payload_store/base.rb +42 -46
- data/lib/patient_http/payload_store/file_store.rb +22 -26
- data/lib/patient_http/payload_store/redis_store.rb +28 -34
- data/lib/patient_http/payload_store/s3_store.rb +25 -28
- data/lib/patient_http/payload_store.rb +2 -0
- data/lib/patient_http/processor.rb +111 -79
- data/lib/patient_http/processor_observer.rb +65 -59
- data/lib/patient_http/rails/engine.rb +13 -8
- data/lib/patient_http/redirect_error.rb +50 -41
- data/lib/patient_http/redirect_helper.rb +38 -38
- data/lib/patient_http/request.rb +70 -46
- data/lib/patient_http/request_error.rb +47 -42
- data/lib/patient_http/request_helper.rb +142 -119
- data/lib/patient_http/request_preparer.rb +13 -10
- data/lib/patient_http/request_task.rb +113 -84
- data/lib/patient_http/request_template.rb +87 -64
- data/lib/patient_http/response.rb +58 -52
- data/lib/patient_http/response_reader.rb +66 -65
- data/lib/patient_http/secret_manager.rb +34 -30
- data/lib/patient_http/secret_reference.rb +33 -26
- data/lib/patient_http/synchronous_executor.rb +67 -95
- data/lib/patient_http/task_handler.rb +23 -19
- data/lib/patient_http/time_helper.rb +8 -8
- data/lib/patient_http.rb +311 -186
- data/patient_http.gemspec +3 -2
- metadata +20 -4
data/README.md
CHANGED
|
@@ -6,230 +6,222 @@
|
|
|
6
6
|
|
|
7
7
|
*Built for APIs that like to think.*
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This gem runs HTTP requests on a dedicated async I/O processor and passes each result to a callback service. Application threads don't wait for HTTP responses, so they're free to do other work while requests are in flight.
|
|
10
10
|
|
|
11
11
|
## Motivation
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
In a threaded application, a thread that makes an HTTP request waits for the response. A slow API response holds the thread for the full duration, so the thread can't do other work. When many threads wait on HTTP requests at the same time, throughput drops.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
This gem runs HTTP requests in a dedicated processor thread that uses Ruby's fiber scheduler for non-blocking I/O. An application thread hands off a request to the processor and returns immediately. The processor runs hundreds of HTTP requests at the same time. When a response arrives, the processor passes it to your callback service through a job system.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
## Quick start
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
The processor needs a process to run in and a job system to deliver the results. Install the integration gem for your job system, which handles both:
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
- [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) for Sidekiq
|
|
22
|
+
- [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) for Solid Queue
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
For large language model (LLM) requests, use [patient_llm](https://github.com/bdurand/patient_llm), which builds on this gem. LLM requests can take much longer than typical HTTP requests, which was the original reason for this gem.
|
|
24
25
|
|
|
25
|
-
### 1.
|
|
26
|
+
### 1. Install the gem
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
Add the integration gem for your job system to your Gemfile. The integration gem depends on this gem, so you don't need to add `patient_http` too.
|
|
28
29
|
|
|
29
30
|
```ruby
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@job_id = job_id
|
|
33
|
-
end
|
|
31
|
+
gem "patient_http-sidekiq"
|
|
32
|
+
```
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
# Enqueue a message for your application to process the response.
|
|
37
|
-
# Keep this lightweight and thread-safe -- it runs on a completion
|
|
38
|
-
# worker thread, concurrently with other completions.
|
|
39
|
-
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
40
|
-
end
|
|
34
|
+
No other setup is required. When the integration gem loads, it registers the request handler and connects the processor to the startup and shutdown of your job system. You don't need to write an initializer or call a setup method.
|
|
41
35
|
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
### 2. Create a callback service
|
|
37
|
+
|
|
38
|
+
Define a callback service class with `on_complete` and `on_error` instance methods. The callback service runs in a background job, so it can do slow work.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class FetchUserCallback
|
|
42
|
+
def on_complete(response)
|
|
43
|
+
user_id = response.callback_args[:user_id]
|
|
44
|
+
User.find(user_id).update!(external_data: response.json)
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
MyJobSystem.enqueue_job(@job_id)
|
|
47
|
+
def on_error(error)
|
|
48
|
+
user_id = error.callback_args[:user_id]
|
|
49
|
+
Rails.logger.error("Failed to fetch user #{user_id}: #{error.message}")
|
|
50
50
|
end
|
|
51
51
|
end
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
>
|
|
56
|
-
> Callbacks must be thread-safe. Results are delivered concurrently on `completion_threads` workers (default 2), so two callbacks can run at the same time and in an order unrelated to the order the requests completed. Set `completion_threads: 1` to serialize delivery.
|
|
57
|
-
>
|
|
58
|
-
> Callbacks must also be idempotent. A callback that raises is retried `completion_retries` times (default 2), so one that raises after enqueuing its message enqueues it again. Set `completion_retries: 0` if that is not acceptable.
|
|
59
|
-
|
|
60
|
-
### 2. Create and Enqueue Requests
|
|
54
|
+
### 3. Make HTTP requests
|
|
61
55
|
|
|
62
56
|
```ruby
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
57
|
+
PatientHttp.get(
|
|
58
|
+
"https://api.example.com/users/123",
|
|
59
|
+
callback: FetchUserCallback,
|
|
60
|
+
callback_args: {user_id: 123}
|
|
67
61
|
)
|
|
62
|
+
```
|
|
68
63
|
|
|
69
|
-
|
|
70
|
-
processor = PatientHttp::Processor.new(config)
|
|
71
|
-
processor.start
|
|
64
|
+
The call returns immediately. The processor runs the request, and when the request finishes, a background job calls your callback's `on_complete` method. If the request fails, the job calls `on_error` instead.
|
|
72
65
|
|
|
73
|
-
|
|
74
|
-
request = PatientHttp::Request.new(
|
|
75
|
-
:get,
|
|
76
|
-
"https://api.example.com/users/123",
|
|
77
|
-
headers: {"Authorization" => "Bearer token"}
|
|
78
|
-
)
|
|
66
|
+
Every option has a working default. To change the defaults, see [Configuration](#configuration).
|
|
79
67
|
|
|
80
|
-
|
|
81
|
-
task = PatientHttp::RequestTask.new(
|
|
82
|
-
request: request,
|
|
83
|
-
task_handler: MyTaskHandler.new("job-123"),
|
|
84
|
-
callback: "FetchDataCallback",
|
|
85
|
-
callback_args: {user_id: 123}
|
|
86
|
-
)
|
|
68
|
+
### Run requests without a job system
|
|
87
69
|
|
|
88
|
-
|
|
89
|
-
|
|
70
|
+
In consoles, tests, and development environments without a job system, run requests inline:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
PatientHttp.inline!
|
|
90
74
|
```
|
|
91
75
|
|
|
92
|
-
|
|
76
|
+
After this call, each request made with the `PatientHttp` module methods runs immediately on the calling thread. The request goes through the full request lifecycle, including timeouts, redirects, and error handling, and the callback runs before the request method returns.
|
|
93
77
|
|
|
94
|
-
|
|
78
|
+
```ruby
|
|
79
|
+
PatientHttp.inline!
|
|
80
|
+
PatientHttp.get("https://api.example.com/users/123", callback: FetchUserCallback)
|
|
81
|
+
# FetchUserCallback#on_complete has already run.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Requests that callbacks make also run inline. To check whether the inline handler is registered, call `PatientHttp.inline?`. To run one request inline without registering a handler, call `PatientHttp.execute_inline(request:, callback:)`.
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
### Make requests
|
|
89
|
+
|
|
90
|
+
The `PatientHttp` module has a method for each HTTP method: `get`, `head`, `post`, `put`, `patch`, `delete`, and `query`. The methods take the same options. `PatientHttp.request` takes the HTTP method as its first argument.
|
|
95
91
|
|
|
96
92
|
```ruby
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
93
|
+
PatientHttp.post(
|
|
94
|
+
"https://api.example.com/users",
|
|
95
|
+
json: {name: "John", email: "john@example.com"},
|
|
96
|
+
callback: CreateUserCallback,
|
|
97
|
+
callback_args: {source: "signup"}
|
|
98
|
+
)
|
|
99
|
+
```
|
|
103
100
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
101
|
+
The methods take these options:
|
|
102
|
+
|
|
103
|
+
| Option | Description |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `callback:` | Required. The callback service class, or its name. |
|
|
106
|
+
| `callback_args:` | A Hash of arguments that the callback reads from the response or error. See [Callback arguments](#callback-arguments). |
|
|
107
|
+
| `headers:` | The request headers. |
|
|
108
|
+
| `body:` | The request body. GET, HEAD, and DELETE requests can't have a body. |
|
|
109
|
+
| `json:` | An object to send as a JSON body. Can't be combined with `body:`. |
|
|
110
|
+
| `params:` | Query parameters to add to the URL. |
|
|
111
|
+
| `timeout:` | The request timeout in seconds. |
|
|
112
|
+
| `raise_error_responses:` | Whether to treat non-2xx responses as errors. See [Handle HTTP error responses](#handle-http-error-responses). |
|
|
113
|
+
| `max_redirects:` | The maximum number of redirects to follow for this request. `0` turns off redirects. |
|
|
114
|
+
| `follow_method_changing_redirects:` | Whether to follow redirects that change the HTTP method. See [Redirects](#redirects). |
|
|
115
|
+
| `redirect_strip_headers:` | The names of headers to remove from redirected requests. |
|
|
116
|
+
| `preprocessors:` | The names of registered [preprocessors](#request-preprocessors) to run on the request. |
|
|
117
|
+
| `processor:` | The name of the processor that runs the request. See [Named processors](#named-processors). |
|
|
118
|
+
|
|
119
|
+
For more control, build a `PatientHttp::Request` object and pass it to `PatientHttp.execute`:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
|
|
123
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
124
|
+
params: {include: "profile"},
|
|
125
|
+
timeout: 30
|
|
126
|
+
)
|
|
127
|
+
PatientHttp.execute(request: request, callback: FetchUserCallback, callback_args: {user_id: 123})
|
|
109
128
|
```
|
|
110
129
|
|
|
111
|
-
|
|
130
|
+
Application code that uses these methods never names the job system. To move to another job system, change the integration gem in your Gemfile.
|
|
112
131
|
|
|
113
|
-
|
|
132
|
+
### Handle HTTP error responses
|
|
133
|
+
|
|
134
|
+
By default, HTTP error status codes (4xx and 5xx) are treated as completed requests and passed to `on_complete`. To check the status, use `response.success?`, `response.client_error?`, or `response.server_error?`:
|
|
114
135
|
|
|
115
136
|
```ruby
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
137
|
+
class ApiCallback
|
|
138
|
+
def on_complete(response)
|
|
139
|
+
if response.success?
|
|
140
|
+
process_data(response.json)
|
|
141
|
+
elsif response.client_error?
|
|
142
|
+
handle_client_error(response.status, response.body)
|
|
143
|
+
elsif response.server_error?
|
|
144
|
+
handle_server_error(response.status, response.body)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def on_error(error)
|
|
149
|
+
Rails.logger.error("Request failed: #{error.message}")
|
|
123
150
|
end
|
|
124
151
|
end
|
|
125
152
|
```
|
|
126
153
|
|
|
127
|
-
To treat
|
|
154
|
+
The `on_error` callback runs when the request raises an exception, such as a timeout or a connection failure. To treat HTTP errors as exceptions too, set the `raise_error_responses` option. With this option, a non-2xx response calls `on_error` with a `PatientHttp::HttpError`:
|
|
128
155
|
|
|
129
156
|
```ruby
|
|
130
|
-
|
|
131
|
-
request: request,
|
|
132
|
-
task_handler: handler,
|
|
133
|
-
callback: "ApiCallback",
|
|
134
|
-
raise_error_responses: true
|
|
135
|
-
)
|
|
157
|
+
PatientHttp.get("https://api.example.com/data", callback: ApiCallback, raise_error_responses: true)
|
|
136
158
|
```
|
|
137
159
|
|
|
138
|
-
|
|
160
|
+
An `HttpError` gives you access to the request and the response:
|
|
139
161
|
|
|
140
162
|
```ruby
|
|
141
163
|
def on_error(error)
|
|
142
164
|
if error.is_a?(PatientHttp::HttpError)
|
|
143
|
-
puts error.status
|
|
144
|
-
puts error.url
|
|
145
|
-
puts error.
|
|
165
|
+
puts error.status # HTTP status code
|
|
166
|
+
puts error.url # Request URL
|
|
167
|
+
puts error.http_method # HTTP method
|
|
168
|
+
puts error.response.body # Response body
|
|
169
|
+
puts error.response.headers # Response headers
|
|
170
|
+
puts error.response.json # Response body parsed as JSON
|
|
146
171
|
end
|
|
147
172
|
end
|
|
148
173
|
```
|
|
149
174
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
For repeated requests to the same API, use `RequestTemplate` to share configuration:
|
|
153
|
-
|
|
154
|
-
```ruby
|
|
155
|
-
template = PatientHttp::RequestTemplate.new(
|
|
156
|
-
base_url: "https://api.example.com",
|
|
157
|
-
headers: {"Authorization" => "Bearer #{ENV['API_KEY']}"},
|
|
158
|
-
timeout: 60
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
# Build requests from the template
|
|
162
|
-
get_request = template.get("/users/123")
|
|
163
|
-
post_request = template.post("/users", json: {name: "John"})
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
Templates support all HTTP methods (`get`, `head`, `post`, `put`, `patch`, `delete`, `query`) and handle URL joining, header merging, and query parameter encoding.
|
|
175
|
+
A 4xx response raises a `PatientHttp::ClientError`, and a 5xx response raises a `PatientHttp::ServerError`. Both are subclasses of `HttpError`.
|
|
167
176
|
|
|
168
|
-
|
|
177
|
+
To make `raise_error_responses` the default for every request, set it in the [configuration](#configuration).
|
|
169
178
|
|
|
170
|
-
|
|
179
|
+
### Callback arguments
|
|
171
180
|
|
|
172
|
-
|
|
181
|
+
To pass data to your callbacks, use the `callback_args` option:
|
|
173
182
|
|
|
174
183
|
```ruby
|
|
175
|
-
# The handler receives keyword arguments for the request, callback, and any additional callback arguments.
|
|
176
|
-
PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
|
|
177
|
-
# Example integration point. Adapt this to your app.
|
|
178
|
-
# Build a RequestTask and enqueue it to your processor.
|
|
179
|
-
task = PatientHttp::RequestTask.new(
|
|
180
|
-
request: request,
|
|
181
|
-
task_handler: MyTaskHandler.new,
|
|
182
|
-
callback: callback,
|
|
183
|
-
callback_args: callback_args,
|
|
184
|
-
raise_error_responses: raise_error_responses
|
|
185
|
-
)
|
|
186
|
-
|
|
187
|
-
processor.enqueue(task)
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Now you can make requests directly through the PatientHttp interface with the .request,
|
|
191
|
-
# .get, .head, .post, .patch, .put, .delete, and .query class methods:
|
|
192
184
|
PatientHttp.get(
|
|
193
|
-
"https://api.example.com/users
|
|
185
|
+
"https://api.example.com/users/#{user_id}",
|
|
194
186
|
callback: FetchUserCallback,
|
|
195
|
-
callback_args: {user_id:
|
|
187
|
+
callback_args: {user_id: user_id, requested_at: Time.now.iso8601}
|
|
196
188
|
)
|
|
197
189
|
```
|
|
198
190
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
### Inline Execution
|
|
202
|
-
|
|
203
|
-
For consoles, tests, and development environments where no job system is configured, you can register a handler that executes requests inline — synchronously, in-process — instead of dispatching them to a queue:
|
|
191
|
+
The `response.callback_args` and `error.callback_args` methods return the arguments:
|
|
204
192
|
|
|
205
193
|
```ruby
|
|
206
|
-
|
|
194
|
+
response.callback_args[:user_id]
|
|
195
|
+
response.callback_args["user_id"]
|
|
207
196
|
```
|
|
208
197
|
|
|
209
|
-
|
|
198
|
+
The `callback_args` value follows these rules:
|
|
210
199
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
```
|
|
200
|
+
- It must be a Hash, or respond to `to_h`, and contain only JSON-native types: `nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, and `Hash`.
|
|
201
|
+
- Hash keys are converted to strings, including the keys of nested hashes and of hashes in arrays.
|
|
202
|
+
- You can read the arguments with symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`.
|
|
203
|
+
- Reading a key that isn't set raises a `KeyError`. To get a default value instead, use `callback_args.fetch(:user_id, nil)`.
|
|
216
204
|
|
|
217
|
-
|
|
205
|
+
### Use request templates
|
|
206
|
+
|
|
207
|
+
To share settings across requests to the same API, use `PatientHttp::RequestTemplate`:
|
|
218
208
|
|
|
219
209
|
```ruby
|
|
220
|
-
|
|
221
|
-
|
|
210
|
+
template = PatientHttp::RequestTemplate.new(
|
|
211
|
+
base_url: "https://api.example.com",
|
|
212
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
213
|
+
timeout: 60
|
|
214
|
+
)
|
|
222
215
|
|
|
223
|
-
|
|
216
|
+
request = template.get("/users/123")
|
|
217
|
+
PatientHttp.execute(request: request, callback: FetchUserCallback)
|
|
218
|
+
```
|
|
224
219
|
|
|
225
|
-
|
|
220
|
+
A template has a method for each HTTP method. The template joins each path with the base URL, and merges the headers and query parameters of each request with its own. If the template doesn't set a `timeout`, the configured `request_timeout` applies.
|
|
226
221
|
|
|
227
|
-
Use
|
|
222
|
+
### Use the RequestHelper module
|
|
228
223
|
|
|
229
|
-
|
|
230
|
-
2. Include `PatientHttp::RequestHelper` in your class.
|
|
231
|
-
3. Optionally define a `request_template` for shared `base_url`, headers, and timeout.
|
|
232
|
-
4. Call `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`, `async_delete`, `async_query`, or `async_request`.
|
|
224
|
+
For a class that makes many requests, include `PatientHttp::RequestHelper`. The module adds the `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`, `async_delete`, `async_query`, and `async_request` methods. To set shared options such as `base_url`, `headers`, and `timeout`, use the `request_template` class method:
|
|
233
225
|
|
|
234
226
|
```ruby
|
|
235
227
|
class ApiClient
|
|
@@ -237,435 +229,436 @@ class ApiClient
|
|
|
237
229
|
|
|
238
230
|
request_template(
|
|
239
231
|
base_url: "https://api.example.com",
|
|
240
|
-
headers: {"Authorization" =>
|
|
232
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
241
233
|
timeout: 60
|
|
242
234
|
)
|
|
243
235
|
|
|
244
236
|
def fetch_user(user_id)
|
|
245
|
-
async_get(
|
|
246
|
-
"/users/#{user_id}",
|
|
247
|
-
callback: FetchUserCallback,
|
|
248
|
-
callback_args: {user_id: user_id}
|
|
249
|
-
)
|
|
237
|
+
async_get("/users/#{user_id}", callback: FetchUserCallback, callback_args: {user_id: user_id})
|
|
250
238
|
end
|
|
251
239
|
|
|
252
240
|
def update_user(user_id, data)
|
|
253
|
-
async_patch(
|
|
254
|
-
"/users/#{user_id}",
|
|
255
|
-
json: data,
|
|
256
|
-
callback: UpdateUserCallback,
|
|
257
|
-
callback_args: {user_id: user_id}
|
|
258
|
-
)
|
|
241
|
+
async_patch("/users/#{user_id}", json: data, callback: UpdateUserCallback, callback_args: {user_id: user_id})
|
|
259
242
|
end
|
|
260
243
|
end
|
|
261
244
|
```
|
|
262
245
|
|
|
263
|
-
|
|
246
|
+
The `async_*` methods take the same options as the `PatientHttp` module methods. Paths are relative to the template's `base_url`. A subclass uses the template of its superclass unless it declares its own.
|
|
264
247
|
|
|
265
|
-
|
|
248
|
+
## Configuration
|
|
266
249
|
|
|
267
|
-
|
|
268
|
-
task = PatientHttp::RequestTask.new(
|
|
269
|
-
request: request,
|
|
270
|
-
task_handler: handler,
|
|
271
|
-
callback: "FetchDataCallback",
|
|
272
|
-
callback_args: {user_id: 123, request_timestamp: Time.now.iso8601}
|
|
273
|
-
)
|
|
274
|
-
```
|
|
250
|
+
All configuration is optional. To set options, call `PatientHttp.configure` in an initializer. If an integration gem is loaded, the method yields that gem's configuration, which adds the options for the job system to the options below.
|
|
275
251
|
|
|
276
|
-
|
|
252
|
+
Every call yields the same configuration object, so options accumulate. Several initializers can each set options without overwriting one another.
|
|
277
253
|
|
|
278
254
|
```ruby
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
Callback args must contain only JSON-native types (`nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, `Hash`). Hash keys are converted to strings for serialization.
|
|
255
|
+
PatientHttp.configure do |config|
|
|
256
|
+
# Maximum concurrent HTTP requests (default: 256).
|
|
257
|
+
config.max_connections = 256
|
|
284
258
|
|
|
285
|
-
|
|
259
|
+
# Maximum connections to each host (default: nil, no limit).
|
|
260
|
+
config.max_connections_per_host = 32
|
|
286
261
|
|
|
287
|
-
|
|
262
|
+
# Default timeout for HTTP requests in seconds (default: 60).
|
|
263
|
+
config.request_timeout = 60
|
|
288
264
|
|
|
289
|
-
|
|
265
|
+
# Timeout for graceful shutdown in seconds (default: 30).
|
|
266
|
+
config.shutdown_timeout = 30
|
|
290
267
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
295
|
-
end
|
|
268
|
+
# Maximum response body size in bytes (default: 1MB). Larger responses raise
|
|
269
|
+
# ResponseTooLargeError.
|
|
270
|
+
config.max_response_size = 1024 * 1024
|
|
296
271
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
MyJobSystem.enqueue(callback, :on_error, error.as_json)
|
|
300
|
-
end
|
|
301
|
-
```
|
|
272
|
+
# Default User-Agent header for all requests (default: "PatientHttp").
|
|
273
|
+
config.user_agent = "MyApp/1.0"
|
|
302
274
|
|
|
303
|
-
|
|
275
|
+
# Whether to raise HttpError for non-2xx responses by default (default: false).
|
|
276
|
+
config.raise_error_responses = false
|
|
304
277
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
error = PatientHttp::HttpError.load(json_data)
|
|
308
|
-
```
|
|
278
|
+
# Maximum number of redirects to follow (default: 5; 0 turns off redirects).
|
|
279
|
+
config.max_redirects = 5
|
|
309
280
|
|
|
310
|
-
|
|
281
|
+
# Whether to follow redirects that change the HTTP method, such as POST to
|
|
282
|
+
# GET on a 302 (default: true). If false, the request gets the redirect
|
|
283
|
+
# response.
|
|
284
|
+
config.follow_method_changing_redirects = true
|
|
311
285
|
|
|
312
|
-
|
|
286
|
+
# Names of headers to remove from all redirected requests (default: []).
|
|
287
|
+
# Names are case insensitive. Authorization and Cookie are always removed on
|
|
288
|
+
# cross-origin redirects.
|
|
289
|
+
config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
|
|
313
290
|
|
|
314
|
-
|
|
291
|
+
# Maximum number of host clients to pool (default: 100).
|
|
292
|
+
config.connection_pool_size = 100
|
|
315
293
|
|
|
316
|
-
|
|
294
|
+
# Timeout in seconds to open a connection, including the TCP connect and the
|
|
295
|
+
# TLS handshake (default: nil, no limit). It doesn't limit the wait for a
|
|
296
|
+
# response; request_timeout does that.
|
|
297
|
+
config.connection_timeout = 10
|
|
317
298
|
|
|
318
|
-
|
|
299
|
+
# TCP keepalive for pooled connections (default: nil, the kernel sends no
|
|
300
|
+
# probes). A number sets the idle seconds before the first probe. A Hash also
|
|
301
|
+
# sets the interval and the probe count, for example
|
|
302
|
+
# {idle: 30, interval: 10, count: 3}. The Hash must contain :idle. The
|
|
303
|
+
# :interval default is 10 seconds, and the :count default is 3 probes.
|
|
304
|
+
config.tcp_keepalive = 30
|
|
319
305
|
|
|
320
|
-
|
|
306
|
+
# Seconds that sent data can stay unacknowledged before the kernel closes the
|
|
307
|
+
# connection (default: nil, the kernel default applies). Sets
|
|
308
|
+
# TCP_USER_TIMEOUT, which is available only on Linux.
|
|
309
|
+
config.tcp_user_timeout = 30
|
|
321
310
|
|
|
322
|
-
|
|
323
|
-
#
|
|
324
|
-
config.
|
|
311
|
+
# HTTP or HTTPS proxy URL (default: nil). Supports authentication, for
|
|
312
|
+
# example "http://user:pass@proxy.example.com:8080".
|
|
313
|
+
config.proxy_url = "http://proxy.example.com:8080"
|
|
325
314
|
|
|
326
|
-
#
|
|
327
|
-
|
|
315
|
+
# Number of retries for failed requests (default: 3).
|
|
316
|
+
config.retries = 3
|
|
328
317
|
|
|
329
|
-
|
|
330
|
-
#
|
|
318
|
+
# HTTP protocol, :http1 or :http2 (default: nil, negotiated with the server,
|
|
319
|
+
# with HTTP/2 preferred for HTTPS). The :http1 value also limits the TLS ALPN
|
|
320
|
+
# advertisement to http/1.1, which can work around proxies that intercept SSL
|
|
321
|
+
# and don't handle HTTP/2 correctly.
|
|
322
|
+
config.protocol = nil
|
|
331
323
|
|
|
332
|
-
|
|
333
|
-
|
|
324
|
+
# Number of threads that decode responses and deliver results (default: 2).
|
|
325
|
+
config.completion_threads = 2
|
|
334
326
|
|
|
335
|
-
|
|
336
|
-
|
|
327
|
+
# Number of times to retry result delivery before the failure is reported
|
|
328
|
+
# (default: 2).
|
|
329
|
+
config.completion_retries = 2
|
|
337
330
|
|
|
338
|
-
|
|
339
|
-
|
|
331
|
+
# Size in bytes above which payloads are stored externally when a payload
|
|
332
|
+
# store is configured (default: 64KB).
|
|
333
|
+
config.payload_store_threshold = 64 * 1024
|
|
340
334
|
|
|
341
|
-
|
|
335
|
+
# Logger (default: a Logger that writes errors to standard error).
|
|
336
|
+
config.logger = Rails.logger
|
|
337
|
+
end
|
|
342
338
|
```
|
|
343
339
|
|
|
344
|
-
|
|
340
|
+
`PatientHttp.configuration` returns the same object outside a `configure` block.
|
|
345
341
|
|
|
346
|
-
For
|
|
342
|
+
For the options that each job system adds, see the documentation for [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq#configuration) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue#configuration).
|
|
347
343
|
|
|
348
|
-
|
|
349
|
-
config.register_payload_store(:files, adapter: :file, directory: "/tmp/payloads")
|
|
350
|
-
```
|
|
344
|
+
### Named processors
|
|
351
345
|
|
|
352
|
-
|
|
346
|
+
Named processors are available only when an integration gem is loaded. The integration gems add the `config.processor` method.
|
|
353
347
|
|
|
354
|
-
|
|
348
|
+
By default, all requests share one processor and one `max_connections` limit. If one process runs workloads with very different profiles, such as slow LLM API calls and fast webhook deliveries, a burst of one workload can use all the capacity that the other needs. Named processor profiles keep the workloads separate:
|
|
355
349
|
|
|
356
350
|
```ruby
|
|
357
|
-
|
|
358
|
-
config.
|
|
351
|
+
PatientHttp.configure do |config|
|
|
352
|
+
config.processor(:llm, max_connections: 200, request_timeout: 120)
|
|
353
|
+
config.processor(:webhooks, max_connections: 64, request_timeout: 10)
|
|
354
|
+
end
|
|
359
355
|
```
|
|
360
356
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
#### S3 Store
|
|
357
|
+
Each profile runs as an independent processor in the process, with its own capacity, timeouts, and threads. Profile options override the top-level configuration. The profiles share every option that they don't override, such as secrets, preprocessors, payload stores, encryption, and the logger. The `:default` processor always exists.
|
|
364
358
|
|
|
365
|
-
|
|
359
|
+
To send a request to a processor, set the `processor:` option on the request method, on a `Request`, or on a `RequestTemplate`:
|
|
366
360
|
|
|
367
361
|
```ruby
|
|
368
|
-
|
|
369
|
-
bucket = s3.bucket("my-payloads-bucket")
|
|
370
|
-
config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
|
|
362
|
+
PatientHttp.post(url, callback: MyCallback, processor: :llm)
|
|
371
363
|
```
|
|
372
364
|
|
|
373
|
-
|
|
365
|
+
For how the processor name is kept through retries and crash recovery, see the documentation for your integration gem.
|
|
374
366
|
|
|
375
|
-
|
|
367
|
+
### Tuning tips
|
|
376
368
|
|
|
377
|
-
|
|
369
|
+
- `max_connections`: Set this based on your system's resources. Each connection uses memory and a file descriptor. A tuned system with enough resources can handle thousands of concurrent connections.
|
|
370
|
+
- `max_connections_per_host`: Limits the sockets open to each host. The default is no limit. For high concurrency, set a value such as 32, so that one host can't use every file descriptor. Make sure that the process file descriptor limit covers `max_connections`, plus idle pooled connections, plus the application's own connections.
|
|
371
|
+
- `request_timeout`: Set this based on the response times of the APIs that you call. AI APIs can take minutes to respond while they generate content.
|
|
372
|
+
- `connection_timeout`: Limits only the TCP connect and the TLS handshake. Set it to fail fast when a host doesn't answer. It doesn't limit the wait for a response, because `request_timeout` controls the full exchange.
|
|
373
|
+
- `tcp_keepalive`: The kernel sends probes on an idle pooled connection. The probes keep NAT and firewall mappings open, and let the kernel find a dead peer before the pool sends a request on the connection. Set this option when connections stay idle in the pool between requests.
|
|
374
|
+
- `tcp_user_timeout`: The kernel closes a connection when the peer doesn't acknowledge sent data. This option is available only on Linux. A request to a peer that stopped without notice fails after this timeout, without waiting for `request_timeout`. Acknowledged data isn't affected, so a slow response continues.
|
|
375
|
+
- `connection_pool_size`: Sets the maximum number of hosts whose connections are kept open. Increase it if your application calls many different hosts.
|
|
376
|
+
- `max_response_size`: Limits the size of HTTP responses to prevent high memory use from unexpectedly large responses. For a compressed response, the limit applies to the decompressed body. For large responses, consider a [payload store](#payload-stores).
|
|
377
|
+
- `completion_threads`: Increase this when result delivery does heavy work, such as serialization or encryption, and finished requests wait for a thread. If the value is greater than 1, results are delivered concurrently, so `TaskHandler` callbacks and completion-time observers must be thread-safe. Set it to 1 to deliver results one at a time.
|
|
378
|
+
- `completion_retries`: The number of times to retry result delivery before the failure is reported to observers through `completion_failed`. A retry calls `on_complete` or `on_error` again. As a result, a handler that raises an error *after* it enqueues its message delivers that message twice. Make handlers idempotent, or set `completion_retries` to 0 to report the first failure without a retry.
|
|
379
|
+
- `shutdown_timeout`: Must be less than the process supervisor's stop timeout, so that in-flight requests and their results finish before a hard kill.
|
|
378
380
|
|
|
379
|
-
|
|
380
|
-
config.register_payload_store(:database, adapter: :active_record)
|
|
381
|
-
```
|
|
381
|
+
### Response compression
|
|
382
382
|
|
|
383
|
-
|
|
383
|
+
Requests ask for `gzip` compression by default. A completion worker thread decompresses the response body. To change this, set the `accept-encoding` header on a request:
|
|
384
384
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
|
|
388
|
-
def change
|
|
389
|
-
create_table :patient_http_payloads, id: false do |t|
|
|
390
|
-
t.string :key, null: false, limit: 36
|
|
391
|
-
t.text :data, null: false
|
|
392
|
-
t.timestamps
|
|
393
|
-
end
|
|
385
|
+
- `identity` turns off compression.
|
|
386
|
+
- Any other encoding is delivered still encoded, with its `content-encoding` header, so that you can decode it yourself.
|
|
394
387
|
|
|
395
|
-
|
|
396
|
-
add_index :patient_http_payloads, :created_at
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
```
|
|
388
|
+
## Sensitive and large payloads
|
|
400
389
|
|
|
401
|
-
|
|
390
|
+
Requests and responses are serialized into your job queue so that they can move between processes. As a result, you need to decide how to keep sensitive values out of the queue, and where to put payloads that are too large for it.
|
|
402
391
|
|
|
403
|
-
|
|
392
|
+
These features cover both cases. They work together:
|
|
404
393
|
|
|
405
|
-
|
|
394
|
+
| Feature | When to use it | How to register it |
|
|
395
|
+
| --- | --- | --- |
|
|
396
|
+
| [Secrets](#secrets) | A sensitive header or query parameter, such as an API token. | `config.register_secret` |
|
|
397
|
+
| [Preprocessors](#request-preprocessors) | A signature calculated from the final request, such as AWS SigV4. | `config.register_preprocessor` |
|
|
398
|
+
| [Encryption](#encryption) | The request or response body is sensitive. | `config.encryption_key` |
|
|
399
|
+
| [Payload stores](#payload-stores) | Payloads are too large for the queue. | `config.register_payload_store` |
|
|
406
400
|
|
|
407
|
-
|
|
408
|
-
class MyStore < PatientHttp::PayloadStore::Base
|
|
409
|
-
register :my_store, self
|
|
401
|
+
Use secrets and preprocessors first, because they keep sensitive values out of the queue. Use encryption when the payload itself is sensitive, and a payload store when the payload is too large.
|
|
410
402
|
|
|
411
|
-
|
|
412
|
-
# Store the hash and return the key
|
|
413
|
-
end
|
|
403
|
+
### Secrets
|
|
414
404
|
|
|
415
|
-
|
|
416
|
-
# Return the hash or nil if not found
|
|
417
|
-
end
|
|
405
|
+
If you put an API token directly on a request, the token is written to your job queue. A secret lets you refer to the value by name. The serialized request stores only a marker, `{"$secret" => "name"}`. The processor resolves the value from the configuration when it sends the request.
|
|
418
406
|
|
|
419
|
-
|
|
420
|
-
# Delete the data (idempotent)
|
|
421
|
-
end
|
|
422
|
-
end
|
|
407
|
+
Register a secret with a value, or with a block that runs each time the secret is resolved:
|
|
423
408
|
|
|
424
|
-
|
|
409
|
+
```ruby
|
|
410
|
+
PatientHttp.configure do |config|
|
|
411
|
+
config.register_secret(:authorization, "Bearer #{ENV["API_TOKEN"]}")
|
|
412
|
+
config.register_secret(:api_key) { ENV["MY_API_KEY"] }
|
|
413
|
+
end
|
|
425
414
|
```
|
|
426
415
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
## Encryption
|
|
430
|
-
|
|
431
|
-
When using PatientHttp with a job queue system, request and response data is serialized into the queue (Redis, database, etc.). If this data contains sensitive information, you should encrypt it.
|
|
416
|
+
You can also register a secret at the module level. Use this in a library, or in an initializer that loads before the rest of your configuration:
|
|
432
417
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
418
|
+
```ruby
|
|
419
|
+
PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
|
|
420
|
+
```
|
|
436
421
|
|
|
437
|
-
|
|
422
|
+
Module-level secrets are added to the configuration when it's created, so load order doesn't matter. To check whether a secret is registered, call `PatientHttp.secret_registered?(name)`.
|
|
438
423
|
|
|
439
|
-
|
|
424
|
+
Use a secret reference as a header or query parameter value:
|
|
440
425
|
|
|
441
426
|
```ruby
|
|
442
|
-
|
|
443
|
-
|
|
427
|
+
PatientHttp.get(
|
|
428
|
+
"https://api.example.com/data",
|
|
429
|
+
callback: MyCallback,
|
|
430
|
+
headers: {"Authorization" => PatientHttp.secret(:authorization)},
|
|
431
|
+
params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
|
|
432
|
+
)
|
|
444
433
|
```
|
|
445
434
|
|
|
446
|
-
|
|
435
|
+
The secret query parameter isn't added to the serialized URL. The other parameters, such as `page`, are added as usual. The processor resolves both secrets immediately before it sends the request. If a secret isn't registered, the request fails with a `PatientHttp::SecretManager::SecretNotFoundError`, which is passed to `on_error`.
|
|
447
436
|
|
|
448
|
-
|
|
449
|
-
config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
|
|
450
|
-
```
|
|
437
|
+
### Request preprocessors
|
|
451
438
|
|
|
452
|
-
|
|
439
|
+
A preprocessor changes a request immediately before it's sent, usually to sign it. Signing schemes such as AWS SigV4 calculate values from the final request and set several headers, so a static header value can't express them.
|
|
453
440
|
|
|
454
|
-
|
|
441
|
+
Like secrets, preprocessors are registered in the configuration and referenced by name. The signing code and its credentials stay in the processor and aren't written to the queue.
|
|
455
442
|
|
|
456
443
|
```ruby
|
|
457
|
-
|
|
458
|
-
config.
|
|
444
|
+
PatientHttp.configure do |config|
|
|
445
|
+
config.register_preprocessor(:aws_sigv4) do |request|
|
|
446
|
+
signer = Aws::Sigv4::Signer.new(
|
|
447
|
+
service: "execute-api",
|
|
448
|
+
region: "us-east-1",
|
|
449
|
+
credentials_provider: Aws::CredentialProviderChain.new.resolve
|
|
450
|
+
)
|
|
451
|
+
signature = signer.sign_request(
|
|
452
|
+
http_method: request.http_method.to_s.upcase,
|
|
453
|
+
url: request.url,
|
|
454
|
+
headers: request.headers.to_h,
|
|
455
|
+
body: request.body.to_s
|
|
456
|
+
)
|
|
457
|
+
signature.headers.each { |name, value| request.headers[name] = value }
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
PatientHttp.post("https://api.example.com/data", callback: MyCallback, json: {value: 1}, preprocessors: :aws_sigv4)
|
|
459
462
|
```
|
|
460
463
|
|
|
461
|
-
|
|
464
|
+
The preprocessor receives a `PatientHttp::OutgoingRequest`. At that time, secret references are resolved, and the `x-request-id` and default `User-Agent` headers are set. The object has these methods:
|
|
462
465
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
```
|
|
466
|
+
- `http_method`, `url`, and `body`: Read-only. The URL includes the resolved secret query parameters.
|
|
467
|
+
- `headers`: The request headers. You can change them. Names are case insensitive.
|
|
468
|
+
- `add_param(name, value)`: Adds a query parameter to the URL, for signing schemes that use query parameters.
|
|
467
469
|
|
|
468
|
-
|
|
470
|
+
To run several preprocessors, pass an array. They run in order, and each one sees the changes of the ones before it. `RequestTemplate` and `request_template` in `RequestHelper` take a `preprocessors:` option as a default. If a preprocessor isn't registered, the request fails with a `PatientHttp::RequestPreparer::PreprocessorNotFoundError`, which is passed to `on_error`.
|
|
469
471
|
|
|
470
|
-
|
|
472
|
+
When a request follows a same-origin redirect, the preprocessors run again for the redirect URL, so signatures stay valid. On a cross-origin redirect, the preprocessors are removed, as are the `Authorization` and `Cookie` headers. As a result, signed credentials aren't sent to another origin.
|
|
471
473
|
|
|
472
|
-
|
|
473
|
-
class MyTaskHandler < PatientHttp::TaskHandler
|
|
474
|
-
def initialize(job_id, configuration:)
|
|
475
|
-
@job_id = job_id
|
|
476
|
-
@configuration = configuration
|
|
477
|
-
end
|
|
474
|
+
### Encryption
|
|
478
475
|
|
|
479
|
-
|
|
480
|
-
# Encrypt the serialized response before enqueuing
|
|
481
|
-
encrypted = @configuration.encryptor.encrypt(response.as_json)
|
|
482
|
-
MyJobSystem.enqueue(callback, :on_complete, encrypted)
|
|
483
|
-
end
|
|
476
|
+
When the request or response body is sensitive, encrypt it. After you set a key, the integration gems encrypt data before they write it to the queue, and decrypt it when they read it.
|
|
484
477
|
|
|
485
|
-
|
|
486
|
-
encrypted = @configuration.encryptor.encrypt(error.as_json)
|
|
487
|
-
MyJobSystem.enqueue(callback, :on_error, encrypted)
|
|
488
|
-
end
|
|
478
|
+
The simplest option is `encryption_key`. It uses [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) with AES-256-GCM:
|
|
489
479
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
480
|
+
```ruby
|
|
481
|
+
PatientHttp.configure do |config|
|
|
482
|
+
config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
|
|
493
483
|
end
|
|
494
|
-
|
|
495
|
-
# Keep a configuration reference and use config.encryptor where needed
|
|
496
|
-
handler = MyTaskHandler.new("job-123", configuration: config)
|
|
497
484
|
```
|
|
498
485
|
|
|
499
|
-
|
|
486
|
+
To rotate keys, pass an array. The first key encrypts data, and all keys are tried for decryption:
|
|
500
487
|
|
|
501
488
|
```ruby
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
@configuration = configuration
|
|
505
|
-
end
|
|
506
|
-
|
|
507
|
-
def on_complete(data)
|
|
508
|
-
response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
|
|
509
|
-
# ...
|
|
510
|
-
end
|
|
489
|
+
PatientHttp.configure do |config|
|
|
490
|
+
config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
|
|
511
491
|
end
|
|
512
492
|
```
|
|
513
493
|
|
|
514
|
-
|
|
494
|
+
To use another encryption library, provide callables that take and return raw bytes as a String:
|
|
515
495
|
|
|
516
|
-
|
|
496
|
+
```ruby
|
|
497
|
+
PatientHttp.configure do |config|
|
|
498
|
+
config.encryption { |bytes| MyEncryption.encrypt(bytes) }
|
|
499
|
+
config.decryption { |bytes| MyEncryption.decrypt(bytes) }
|
|
500
|
+
end
|
|
501
|
+
```
|
|
517
502
|
|
|
518
|
-
|
|
503
|
+
You can also pass any object that responds to `call`.
|
|
519
504
|
|
|
520
|
-
|
|
505
|
+
Encrypted data is stored as `{"__encrypted__" => true, "value" => "<base64>"}`. The `Encryptor` serializes the original hash to JSON, passes the bytes to your callable, and encodes the result with Base64. A hash without the `"__encrypted__"` key is returned unchanged, so data that was written before you turned on encryption can still be read.
|
|
521
506
|
|
|
522
|
-
|
|
507
|
+
If you write your own `TaskHandler`, see [Build a custom integration](#build-a-custom-integration) for how to add encryption.
|
|
523
508
|
|
|
524
|
-
###
|
|
509
|
+
### Payload stores
|
|
525
510
|
|
|
526
|
-
|
|
511
|
+
When you register a payload store, any serialized payload larger than `payload_store_threshold` is written to the store instead of the job queue. The queue gets a small reference, which is resolved when the payload is needed. Queue messages stay small, and your application code doesn't change.
|
|
527
512
|
|
|
528
513
|
```ruby
|
|
529
|
-
|
|
530
|
-
config.
|
|
531
|
-
config.
|
|
514
|
+
PatientHttp.configure do |config|
|
|
515
|
+
config.register_payload_store(:redis, adapter: :redis, redis: Redis.new(url: ENV["REDIS_URL"]), ttl: 86_400)
|
|
516
|
+
config.payload_store_threshold = 64 * 1024
|
|
517
|
+
end
|
|
532
518
|
```
|
|
533
519
|
|
|
534
|
-
|
|
520
|
+
These adapters are available:
|
|
535
521
|
|
|
536
|
-
|
|
522
|
+
| Adapter | Options | Notes |
|
|
523
|
+
| --- | --- | --- |
|
|
524
|
+
| `:file` | `directory:` | For development and tests only. Hosts don't share the files. |
|
|
525
|
+
| `:redis` | `redis:` (required), `ttl:`, `key_prefix:` (default `"patient_http:payloads:"`) | Requires the `redis` gem. The client must respond to `set`, `get`, `del`, and `exists`. |
|
|
526
|
+
| `:s3` | `bucket:` (required), `key_prefix:` (default `"patient_http/payloads/"`) | Requires the `aws-sdk-s3` gem. |
|
|
527
|
+
| `:active_record` | `model:` | Requires a database table. See the following section. |
|
|
537
528
|
|
|
538
|
-
|
|
529
|
+
The Active Record adapter needs the `patient_http_payloads` table. In a Rails app, load the engine in an initializer, and then install and run the migration:
|
|
539
530
|
|
|
540
531
|
```ruby
|
|
541
|
-
|
|
542
|
-
PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
|
|
532
|
+
require "patient_http/rails/engine"
|
|
543
533
|
```
|
|
544
534
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
PatientHttp.default_configuration = config
|
|
535
|
+
```bash
|
|
536
|
+
bin/rails patient_http:install:migrations
|
|
537
|
+
bin/rails db:migrate
|
|
549
538
|
```
|
|
550
539
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
### Referencing secrets when building a request
|
|
554
|
-
|
|
555
|
-
Use `PatientHttp.secret(name)` anywhere you would put a sensitive header or query parameter value. No value is needed (or available) at build time:
|
|
540
|
+
You can also add the migration yourself:
|
|
556
541
|
|
|
557
542
|
```ruby
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
543
|
+
class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
|
|
544
|
+
def change
|
|
545
|
+
create_table :patient_http_payloads, id: false do |t|
|
|
546
|
+
t.string :key, null: false, limit: 36
|
|
547
|
+
t.text :data, null: false
|
|
548
|
+
t.timestamps
|
|
549
|
+
end
|
|
565
550
|
|
|
566
|
-
|
|
551
|
+
add_index :patient_http_payloads, :key, unique: true
|
|
552
|
+
add_index :patient_http_payloads, :created_at
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
```
|
|
567
556
|
|
|
568
|
-
|
|
557
|
+
To write your own adapter, subclass `PatientHttp::PayloadStore::Base`, and implement `store_json`, `fetch`, and `delete`. The adapter must be thread-safe.
|
|
569
558
|
|
|
570
|
-
|
|
559
|
+
```ruby
|
|
560
|
+
class MyStore < PatientHttp::PayloadStore::Base
|
|
561
|
+
register :my_store, self
|
|
571
562
|
|
|
572
|
-
|
|
563
|
+
def store_json(key, json)
|
|
564
|
+
# Store the JSON string and return the key.
|
|
565
|
+
end
|
|
573
566
|
|
|
574
|
-
|
|
567
|
+
def fetch(key)
|
|
568
|
+
# Return the parsed hash, or nil if the key isn't found.
|
|
569
|
+
end
|
|
575
570
|
|
|
576
|
-
|
|
571
|
+
def delete(key)
|
|
572
|
+
# Delete the data. Don't raise an error if the key doesn't exist.
|
|
573
|
+
end
|
|
574
|
+
end
|
|
577
575
|
|
|
578
|
-
|
|
579
|
-
config
|
|
580
|
-
config.register_preprocessor(:aws_sigv4) do |request|
|
|
581
|
-
signer = Aws::Sigv4::Signer.new(
|
|
582
|
-
service: "execute-api",
|
|
583
|
-
region: "us-east-1",
|
|
584
|
-
credentials_provider: Aws::CredentialProviderChain.new.resolve
|
|
585
|
-
)
|
|
586
|
-
signature = signer.sign_request(
|
|
587
|
-
http_method: request.http_method.to_s.upcase,
|
|
588
|
-
url: request.url,
|
|
589
|
-
headers: request.headers.to_h,
|
|
590
|
-
body: request.body.to_s
|
|
591
|
-
)
|
|
592
|
-
signature.headers.each { |name, value| request.headers[name] = value }
|
|
576
|
+
PatientHttp.configure do |config|
|
|
577
|
+
config.register_payload_store(:custom, adapter: :my_store, **options)
|
|
593
578
|
end
|
|
594
579
|
```
|
|
595
580
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
- `http_method`, `url`, and `body` (read-only; the URL includes any resolved secret query params)
|
|
599
|
-
- `headers` — mutable, case-insensitive headers
|
|
600
|
-
- `add_param(name, value)` — appends a query parameter to the URL, for signed-query-param schemes
|
|
601
|
-
|
|
602
|
-
### Attaching preprocessors to a request
|
|
581
|
+
To move to a new store, register both stores. The last store registered is used for new writes. All registered stores are available for reads.
|
|
603
582
|
|
|
604
|
-
|
|
583
|
+
To store and fetch payloads yourself, use `PatientHttp::ExternalStorage`:
|
|
605
584
|
|
|
606
585
|
```ruby
|
|
607
|
-
PatientHttp.
|
|
608
|
-
"https://api.example.com/data",
|
|
609
|
-
callback: MyCallback,
|
|
610
|
-
json: {value: 1},
|
|
611
|
-
preprocessors: :aws_sigv4
|
|
612
|
-
)
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
Multiple preprocessors can be given as an array; they run in order, each seeing the changes made by the ones before it. `RequestTemplate` and the `RequestHelper` mixin's `request_template` also accept `preprocessors:` as a template-wide default, and the mixin's `async_*` helpers accept `preprocessors:` per request.
|
|
616
|
-
|
|
617
|
-
If a request references a preprocessor name that is not registered, a `PatientHttp::RequestPreparer::PreprocessorNotFoundError` is raised, which surfaces through the normal request error path.
|
|
586
|
+
storage = PatientHttp::ExternalStorage.new(PatientHttp.configuration)
|
|
618
587
|
|
|
619
|
-
|
|
588
|
+
data = storage.store(response.as_json, max_size: 1024) # Returns the original hash if it's 1KB or smaller.
|
|
589
|
+
storage.storage_ref?(data) # Returns true if the hash was stored.
|
|
590
|
+
storage.fetch(data) # Returns the original hash.
|
|
591
|
+
storage.delete(data) # Deletes the stored payload.
|
|
592
|
+
```
|
|
620
593
|
|
|
621
594
|
## Redirects
|
|
622
595
|
|
|
623
|
-
|
|
596
|
+
A redirect response (300, 301, 302, 303, 307, or 308) with a `Location` header is followed automatically, up to `max_redirects` times. A 300 response is followed only when the `Location` header names the server's preferred choice. A redirect loop raises a `RecursiveRedirectError`, and too many redirects raise a `TooManyRedirectsError`. A redirect that isn't followed is passed to the callback as a normal response.
|
|
624
597
|
|
|
625
598
|
The HTTP method of the redirected request follows RFC 9110:
|
|
626
599
|
|
|
627
600
|
| Status | Method |
|
|
628
601
|
| --- | --- |
|
|
629
|
-
| 301, 302 | `POST`
|
|
630
|
-
| 303 | `GET` and `HEAD`
|
|
631
|
-
| 300, 307, 308 | The method and body
|
|
602
|
+
| 301, 302 | `POST` changes to `GET`, and the body is removed. Other methods, including `HEAD`, `PUT`, `DELETE`, and `QUERY`, keep their method and body. |
|
|
603
|
+
| 303 | `GET` and `HEAD` keep their method. All other methods change to `GET`, and the body is removed. |
|
|
604
|
+
| 300, 307, 308 | The method and body don't change. |
|
|
605
|
+
|
|
606
|
+
The QUERY specification states that the POST-to-GET exception for 301 and 302 doesn't apply to `QUERY`. As a result, a redirected `QUERY` is sent again as a `QUERY` with its body, and a 303 changes it to a `GET`.
|
|
632
607
|
|
|
633
|
-
|
|
608
|
+
### Prevent method changes
|
|
634
609
|
|
|
635
|
-
|
|
610
|
+
To stop following redirects that change the HTTP method, set `follow_method_changing_redirects: false`. For example, a `POST` that gets a 302 then completes with the 302 response, instead of a `GET` to the new location. Redirects that keep the method are still followed, such as a `PUT` that gets a 301, or any method that gets a 307.
|
|
636
611
|
|
|
637
|
-
|
|
612
|
+
You can set the option in the configuration or on a request. If both are set, the request value applies.
|
|
638
613
|
|
|
639
614
|
```ruby
|
|
640
|
-
|
|
615
|
+
PatientHttp.configure do |config|
|
|
616
|
+
config.follow_method_changing_redirects = false
|
|
617
|
+
end
|
|
641
618
|
|
|
642
|
-
# Or
|
|
643
|
-
|
|
619
|
+
# Or for one request.
|
|
620
|
+
PatientHttp.post(url, callback: MyCallback, body: payload, follow_method_changing_redirects: false)
|
|
644
621
|
```
|
|
645
622
|
|
|
646
|
-
###
|
|
623
|
+
### Remove headers on redirects
|
|
647
624
|
|
|
648
|
-
`Authorization` and `Cookie` headers are always removed on cross-origin redirects. To
|
|
625
|
+
The `Authorization` and `Cookie` headers are always removed on cross-origin redirects. To keep other sensitive headers from being sent to a redirect target, list them in `redirect_strip_headers`. Header names are case insensitive. The listed headers are removed from every redirected request, same-origin or cross-origin.
|
|
649
626
|
|
|
650
627
|
```ruby
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
request = PatientHttp::Request.new(:get, "https://api.example.com/data", headers: headers, redirect_strip_headers: "X-Signature")
|
|
628
|
+
PatientHttp.configure do |config|
|
|
629
|
+
config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
|
|
630
|
+
end
|
|
655
631
|
|
|
656
|
-
#
|
|
632
|
+
# Or for one request. These headers are removed in addition to the configured headers.
|
|
657
633
|
PatientHttp.get("https://api.example.com/data", callback: FetchCallback, redirect_strip_headers: "X-Signature")
|
|
658
634
|
```
|
|
659
635
|
|
|
660
|
-
|
|
636
|
+
The header names for a request are serialized with it into the job queue, so they apply in the process that follows the redirect.
|
|
661
637
|
|
|
662
|
-
|
|
638
|
+
Only the headers set on the request are removed. Preprocessors run again on each same-origin redirect and can add headers after they're removed. As a result, a header that a preprocessor sets is sent to the redirect target.
|
|
639
|
+
|
|
640
|
+
When a redirect changes the method and removes the body, the headers that describe the body are removed as well: `Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location`.
|
|
641
|
+
|
|
642
|
+
## Response and error objects
|
|
643
|
+
|
|
644
|
+
`PatientHttp::Response` and the error objects can be serialized to JSON, so they can move through job queues and between processes. Both have `as_json` and `to_json` methods. To create an object from JSON data, use the `load` class method:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
response = PatientHttp::Response.load(json_data)
|
|
648
|
+
error = PatientHttp::Error.load(json_data)
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
A `Response` has the HTTP status code, headers, body, and callback arguments. The error classes, `HttpError`, `RedirectError`, and `RequestError`, have the error message, details about the request, and callback arguments. All error classes are subclasses of `PatientHttp::Error`, and `error.error_type` returns a symbol that identifies the kind of error, such as `:timeout`, `:connection`, or `:http_error`.
|
|
652
|
+
|
|
653
|
+
Request and response header names are case insensitive. A request header with a `nil` or empty value is never sent. If you set a header to `nil` or `""`, the header is removed, and a header Hash such as `{"X-Header" => nil}` doesn't set the header. A response header that occurs more than one time, such as `set-cookie`, becomes one string with the values joined.
|
|
654
|
+
|
|
655
|
+
Response bodies are encoded for JSON serialization. Binary content is encoded with Base64. Large text content is compressed with gzip and then encoded with Base64 to reduce the payload size. The `body` and `json` methods of `Response` decode the body for you.
|
|
663
656
|
|
|
664
657
|
## Troubleshooting
|
|
665
658
|
|
|
666
659
|
### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
|
|
667
660
|
|
|
668
|
-
On some Ruby versions
|
|
661
|
+
On some Ruby versions, a warning like this can appear in your logs:
|
|
669
662
|
|
|
670
663
|
```
|
|
671
664
|
warn: Async::Task: Async::Pool::Controller Gardener [...]
|
|
@@ -674,114 +667,122 @@ warn: Async::Task: Async::Pool::Controller Gardener [...]
|
|
|
674
667
|
| → .../async-pool-x.y.z/lib/async/pool/controller.rb:132 in `synchronize'
|
|
675
668
|
```
|
|
676
669
|
|
|
677
|
-
|
|
670
|
+
The cause is [Ruby bug #20907](https://bugs.ruby-lang.org/issues/20907). For more information, see [socketry/async#424](https://github.com/socketry/async/issues/424). With the fiber scheduler, a fiber that's interrupted while it waits on a `ConditionVariable` doesn't get its mutex again before it exits, which raises a false `ThreadError`. The warning appears when a pooled HTTP client closes while its connection pool's background gardener task is idle. For example, this happens when the pool evicts a connection after a connection error, when the pool is full and evicts the least recently used client, or when the processor shuts down.
|
|
678
671
|
|
|
679
|
-
The warning is harmless
|
|
672
|
+
The warning is harmless. The connections still close correctly. To remove the warning, upgrade Ruby. The bug is fixed in Ruby 3.2.7 and later, 3.3.7 and later, and 3.4 and later.
|
|
680
673
|
|
|
681
|
-
##
|
|
674
|
+
## Build a custom integration
|
|
682
675
|
|
|
683
|
-
|
|
684
|
-
config = PatientHttp::Configuration.new(
|
|
685
|
-
# Maximum concurrent HTTP requests (default: 256)
|
|
686
|
-
max_connections: 256,
|
|
676
|
+
This section explains how to integrate the gem with a job system that doesn't have an integration gem. If you use Sidekiq or Solid Queue, the integration gems do all of this for you.
|
|
687
677
|
|
|
688
|
-
|
|
689
|
-
request_timeout: 60,
|
|
678
|
+
An integration has these parts:
|
|
690
679
|
|
|
691
|
-
|
|
692
|
-
|
|
680
|
+
- A `TaskHandler` that delivers results to your job system.
|
|
681
|
+
- A `Processor` that runs requests.
|
|
682
|
+
- A registered request handler, so that application code can use the `PatientHttp` module methods.
|
|
693
683
|
|
|
694
|
-
|
|
695
|
-
max_response_size: 1024 * 1024,
|
|
684
|
+
### Create a task handler
|
|
696
685
|
|
|
697
|
-
|
|
698
|
-
user_agent: "MyApp/1.0",
|
|
686
|
+
The `TaskHandler` connects the processor to your job system:
|
|
699
687
|
|
|
700
|
-
|
|
701
|
-
|
|
688
|
+
```ruby
|
|
689
|
+
class MyTaskHandler < PatientHttp::TaskHandler
|
|
690
|
+
def initialize(job_id)
|
|
691
|
+
@job_id = job_id
|
|
692
|
+
end
|
|
702
693
|
|
|
703
|
-
|
|
704
|
-
|
|
694
|
+
def on_complete(response, callback)
|
|
695
|
+
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
696
|
+
end
|
|
705
697
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
698
|
+
def on_error(error, callback)
|
|
699
|
+
MyJobSystem.enqueue(callback, :on_error, error.as_json)
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def retry
|
|
703
|
+
# Re-enqueue the original job when the processor shuts down before the
|
|
704
|
+
# request finishes.
|
|
705
|
+
MyJobSystem.enqueue_job(@job_id)
|
|
706
|
+
end
|
|
707
|
+
end
|
|
708
|
+
```
|
|
709
709
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
710
|
+
> [!IMPORTANT]
|
|
711
|
+
> Task handler callbacks run on the processor's completion worker threads (see `completion_threads`), not on the reactor thread, so they don't block the event loop. Keep them lightweight anyway. Usually, a callback only enqueues a message for another system. Heavy callbacks compete with the reactor for the GVL. A task also counts against capacity until its result is delivered, so slow callbacks reduce request capacity.
|
|
712
|
+
>
|
|
713
|
+
> Callbacks must be thread-safe. By default, two completion worker threads deliver results concurrently. As a result, two callbacks can run at the same time, in an order unrelated to the order in which the requests finished. To deliver results one at a time, set `completion_threads` to 1.
|
|
714
|
+
>
|
|
715
|
+
> Callbacks must also be idempotent. A callback that raises an error is retried `completion_retries` times (default 2). If a callback raises an error after it enqueues its message, the message is enqueued again. If that isn't acceptable, set `completion_retries` to 0.
|
|
714
716
|
|
|
715
|
-
|
|
716
|
-
connection_pool_size: 100,
|
|
717
|
+
The task handler is responsible for encryption. The base class doesn't encrypt or decrypt data. Use `Configuration#encryptor` each time you serialize data:
|
|
717
718
|
|
|
718
|
-
|
|
719
|
-
|
|
719
|
+
```ruby
|
|
720
|
+
def on_complete(response, callback)
|
|
721
|
+
encrypted = @configuration.encryptor.encrypt(response.as_json)
|
|
722
|
+
MyJobSystem.enqueue(callback, :on_complete, encrypted)
|
|
723
|
+
end
|
|
724
|
+
```
|
|
720
725
|
|
|
721
|
-
|
|
722
|
-
proxy_url: "http://proxy.example.com:8080",
|
|
726
|
+
Decrypt the data before you create the object again:
|
|
723
727
|
|
|
724
|
-
|
|
725
|
-
|
|
728
|
+
```ruby
|
|
729
|
+
response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
|
|
730
|
+
```
|
|
726
731
|
|
|
727
|
-
|
|
728
|
-
# the server, preferring HTTP/2 for HTTPS). Forcing :http1 also limits the TLS
|
|
729
|
-
# ALPN advertisement to http/1.1, which can work around SSL-intercepting
|
|
730
|
-
# proxies that mishandle HTTP/2.
|
|
731
|
-
protocol: nil,
|
|
732
|
+
### Run a processor
|
|
732
733
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
)
|
|
734
|
+
```ruby
|
|
735
|
+
config = PatientHttp::Configuration.new(max_connections: 256, request_timeout: 60)
|
|
736
|
+
processor = PatientHttp::Processor.new(config)
|
|
737
|
+
processor.start
|
|
736
738
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
+
task = PatientHttp::RequestTask.new(
|
|
740
|
+
request: PatientHttp::Request.new(:get, "https://api.example.com/users/123"),
|
|
741
|
+
task_handler: MyTaskHandler.new("job-123"),
|
|
742
|
+
callback: "FetchUserCallback",
|
|
743
|
+
callback_args: {user_id: 123}
|
|
744
|
+
)
|
|
745
|
+
processor.enqueue(task)
|
|
739
746
|
```
|
|
740
747
|
|
|
741
|
-
|
|
748
|
+
If the processor isn't running, `enqueue` raises a `PatientHttp::NotRunningError`. If the processor is at `max_connections`, it raises a `PatientHttp::MaxCapacityError`.
|
|
742
749
|
|
|
743
|
-
|
|
744
|
-
- **max_connections_per_host**: Bounds sockets per host (default unlimited). Set a value such as 32 for high-concurrency deployments so one host cannot consume every file descriptor. Verify the process file descriptor limit covers `max_connections` plus pooled idle host connections plus the application's own connections.
|
|
745
|
-
- **request_timeout**: Set based on expected API response times. AI/LLM APIs may need minutes.
|
|
746
|
-
- **connection_pool_size**: Increase for applications calling many different API hosts.
|
|
747
|
-
- **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage. The limit applies to the inflated bytes of compressed responses.
|
|
748
|
-
- **Response compression**: Requests ask for `gzip` by default and the body is inflated on a completion worker thread. Set `accept-encoding` on a request to change this: `identity` skips compression, and any other encoding is delivered still encoded with its `content-encoding` header kept so you can decode it yourself.
|
|
749
|
-
- **completion_threads**: Number of threads that decode responses and deliver results (default 2). Increase when callbacks do heavier work (serialization, encryption) and completions back up behind them. Any value above 1 delivers results concurrently, so `TaskHandler` callbacks and completion-time observers must be thread-safe. Use 1 to serialize delivery.
|
|
750
|
-
- **completion_retries**: Delivery retries before a result is reported through `completion_failed` (default 2). A retry calls `on_complete`/`on_error` again, so a handler that raises *after* enqueuing its message delivers that message twice. Make handlers idempotent, or set `completion_retries: 0` to report the first failure without retrying.
|
|
751
|
-
- **shutdown_timeout**: Set below the process supervisor's termination window so the drain (including handed-off completions) finishes before a hard kill.
|
|
750
|
+
A process can run several named processors, each with its own capacity and threads:
|
|
752
751
|
|
|
753
|
-
|
|
752
|
+
```ruby
|
|
753
|
+
PatientHttp::Processor.new(config, name: :llm)
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
A request has an optional `processor` name, which is serialized with the request. Integrations use this name to send the request to a processor.
|
|
754
757
|
|
|
755
|
-
|
|
758
|
+
### Processor lifecycle
|
|
756
759
|
|
|
757
760
|
```
|
|
758
761
|
stopped -> starting -> running -> draining -> stopping -> stopped
|
|
759
762
|
```
|
|
760
763
|
|
|
761
|
-
-
|
|
762
|
-
-
|
|
763
|
-
-
|
|
764
|
-
-
|
|
765
|
-
-
|
|
764
|
+
- `stopped`: The processor doesn't run requests.
|
|
765
|
+
- `starting`: The processor starts the reactor thread.
|
|
766
|
+
- `running`: The processor accepts and runs requests.
|
|
767
|
+
- `draining`: The processor rejects new requests and finishes in-flight requests.
|
|
768
|
+
- `stopping`: The processor shuts down and re-enqueues requests that didn't finish.
|
|
766
769
|
|
|
767
770
|
```ruby
|
|
768
|
-
processor
|
|
769
|
-
|
|
770
|
-
processor.start # Start processing
|
|
771
|
+
processor.start # Start the processor.
|
|
771
772
|
processor.running? # => true
|
|
772
773
|
|
|
773
|
-
processor.drain # Stop accepting new requests
|
|
774
|
+
processor.drain # Stop accepting new requests.
|
|
774
775
|
processor.draining? # => true
|
|
775
776
|
|
|
776
|
-
processor.stop(timeout: 25) #
|
|
777
|
+
processor.stop(timeout: 25) # Shut down gracefully.
|
|
777
778
|
processor.stopped? # => true
|
|
778
779
|
```
|
|
779
780
|
|
|
780
|
-
When the processor stops with in-flight requests, it calls `TaskHandler#retry`
|
|
781
|
+
When the processor stops with in-flight requests, it calls `TaskHandler#retry` for each request that didn't finish, so that the job system can enqueue it again.
|
|
781
782
|
|
|
782
|
-
###
|
|
783
|
+
### Observe the processor
|
|
783
784
|
|
|
784
|
-
|
|
785
|
+
To receive processor events, subclass `PatientHttp::ProcessorObserver`, and override the methods for the events that you need:
|
|
785
786
|
|
|
786
787
|
```ruby
|
|
787
788
|
class MetricsObserver < PatientHttp::ProcessorObserver
|
|
@@ -805,27 +806,48 @@ end
|
|
|
805
806
|
processor.observe(MetricsObserver.new)
|
|
806
807
|
```
|
|
807
808
|
|
|
808
|
-
Observers can also track
|
|
809
|
+
Observers can also track each task through the processor:
|
|
809
810
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
- `request_requeued(request_task)
|
|
811
|
+
- `request_enqueued(request_task)`: Runs when a task is given to the processor, before the reactor can see it. This method always runs before `request_start`. As a result, observers can set up durable tracking, such as a crash-recovery registry entry, before `Processor#enqueue` returns or raises an error.
|
|
812
|
+
- `request_rejected(request_task)`: Runs when the processor doesn't accept a task, because it isn't running or is at capacity. Remove anything that `request_enqueued` set up.
|
|
813
|
+
- `request_requeued(request_task)`: Runs when a task that didn't finish is re-enqueued through its task handler, because the processor shut down or the reactor failed. After this call, the job system owns the request again.
|
|
814
|
+
- `completion_failed(request_task, error)`: Runs when a result can't be delivered after all retries. `request_end` doesn't run in this case, so durable tracking stays in place for external recovery.
|
|
813
815
|
|
|
814
|
-
|
|
816
|
+
To get the IDs of all queued, pending, and in-flight tasks, call `Processor#tracked_request_ids`. For example, use it to update heartbeats for tasks that haven't started yet.
|
|
815
817
|
|
|
816
|
-
|
|
818
|
+
For the thread that runs each observer method, see the `ProcessorObserver` documentation. Observers must be thread-safe.
|
|
817
819
|
|
|
818
|
-
|
|
820
|
+
### Register a request handler
|
|
819
821
|
|
|
820
|
-
|
|
822
|
+
To let application code use the `PatientHttp` module methods, register a request handler. The handler builds each task:
|
|
821
823
|
|
|
822
824
|
```ruby
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
825
|
+
PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
|
|
826
|
+
task = PatientHttp::RequestTask.new(
|
|
827
|
+
request: request,
|
|
828
|
+
task_handler: MyTaskHandler.new(MyJobSystem.current_job_id),
|
|
829
|
+
callback: callback,
|
|
830
|
+
callback_args: callback_args,
|
|
831
|
+
raise_error_responses: raise_error_responses
|
|
832
|
+
)
|
|
833
|
+
processor.enqueue(task)
|
|
834
|
+
task.id
|
|
835
|
+
end
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
To raise an error if a handler is already registered, use `PatientHttp.register_handler!`. To check whether a handler is registered, call `PatientHttp.handler_registered?`.
|
|
839
|
+
|
|
840
|
+
To make `PatientHttp.configure` yield your own configuration class, register the integration as the configuration provider. The provider must respond to `new_configuration`, which returns a new configuration, and to `configure`:
|
|
841
|
+
|
|
842
|
+
```ruby
|
|
843
|
+
PatientHttp.register_configuration_provider(MyIntegration)
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
### Test an integration
|
|
847
|
+
|
|
848
|
+
To test integration code without the async processor, use `SynchronousExecutor`. It runs a request on the calling thread, runs the optional hooks, and then calls the callback service:
|
|
828
849
|
|
|
850
|
+
```ruby
|
|
829
851
|
executor = PatientHttp::SynchronousExecutor.new(
|
|
830
852
|
task,
|
|
831
853
|
config: config,
|
|
@@ -836,25 +858,23 @@ executor = PatientHttp::SynchronousExecutor.new(
|
|
|
836
858
|
executor.call
|
|
837
859
|
```
|
|
838
860
|
|
|
839
|
-
##
|
|
840
|
-
|
|
841
|
-
For Sidekiq integration, see the [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) gem which provides workers, lifecycle hooks, crash recovery, and a Web UI built on this library.
|
|
842
|
-
|
|
843
|
-
For Solid Queue integration, see the [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gem which provides similar functionality for Solid Queue.
|
|
844
|
-
|
|
845
|
-
When using an integration gem, you can use the [standard interface](#standard-interface) to make requests without coupling your code to the underlying processor or task handler implementations.
|
|
861
|
+
## Installation
|
|
846
862
|
|
|
847
|
-
|
|
863
|
+
Most applications need only the integration gem for their job system, which depends on this gem:
|
|
848
864
|
|
|
849
|
-
|
|
865
|
+
```ruby
|
|
866
|
+
gem "patient_http-sidekiq"
|
|
867
|
+
# or
|
|
868
|
+
gem "patient_http-solid_queue"
|
|
869
|
+
```
|
|
850
870
|
|
|
851
|
-
|
|
871
|
+
To use this gem on its own, add it to your Gemfile:
|
|
852
872
|
|
|
853
873
|
```ruby
|
|
854
874
|
gem "patient_http"
|
|
855
875
|
```
|
|
856
876
|
|
|
857
|
-
Then
|
|
877
|
+
Then install it:
|
|
858
878
|
|
|
859
879
|
```bash
|
|
860
880
|
bundle install
|
|
@@ -864,11 +884,17 @@ bundle install
|
|
|
864
884
|
|
|
865
885
|
Open a pull request on [GitHub](https://github.com/bdurand/patient_http).
|
|
866
886
|
|
|
867
|
-
|
|
887
|
+
Follow the [standardrb](https://github.com/testdouble/standard) style, and run `standardrb --fix` before you submit a pull request.
|
|
888
|
+
|
|
889
|
+
Run the tests:
|
|
890
|
+
|
|
891
|
+
```bash
|
|
892
|
+
bundle exec rspec
|
|
893
|
+
```
|
|
868
894
|
|
|
869
|
-
The [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gems each
|
|
895
|
+
The [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq) and [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue) gems each have a test app for integration testing.
|
|
870
896
|
|
|
871
|
-
## Further
|
|
897
|
+
## Further reading
|
|
872
898
|
|
|
873
899
|
- [Architecture](ARCHITECTURE.md)
|
|
874
900
|
|