patient_http 1.6.1 → 1.8.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 +18 -8
- data/CHANGELOG.md +43 -0
- data/README.md +564 -508
- 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 +53 -26
- data/lib/patient_http/client_pool.rb +134 -39
- data/lib/patient_http/completion_executor.rb +20 -20
- data/lib/patient_http/configuration.rb +405 -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 +35 -29
- 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 +117 -46
- data/lib/patient_http/request_error.rb +47 -42
- data/lib/patient_http/request_helper.rb +178 -119
- data/lib/patient_http/request_preparer.rb +13 -10
- data/lib/patient_http/request_task.rb +115 -84
- data/lib/patient_http/request_template.rb +119 -64
- data/lib/patient_http/response.rb +58 -52
- data/lib/patient_http/response_reader.rb +166 -118
- 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 +84 -95
- data/lib/patient_http/task_handler.rb +23 -19
- data/lib/patient_http/time_helper.rb +8 -8
- data/lib/patient_http.rb +327 -186
- data/patient_http.gemspec +3 -2
- metadata +21 -5
data/README.md
CHANGED
|
@@ -6,230 +6,224 @@
|
|
|
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!
|
|
74
|
+
```
|
|
75
|
+
|
|
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.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
PatientHttp.inline!
|
|
80
|
+
PatientHttp.get("https://api.example.com/users/123", callback: FetchUserCallback)
|
|
81
|
+
# FetchUserCallback#on_complete has already run.
|
|
90
82
|
```
|
|
91
83
|
|
|
92
|
-
|
|
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
|
|
93
89
|
|
|
94
|
-
|
|
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
|
+
| `read_timeout:` | The maximum number of seconds to wait for data from the server. See [Streamed and long-running responses](#streamed-and-long-running-responses). |
|
|
113
|
+
| `max_response_size:` | The maximum response body size in bytes for this request. Overrides the configured `max_response_size`. |
|
|
114
|
+
| `raise_error_responses:` | Whether to treat non-2xx responses as errors. See [Handle HTTP error responses](#handle-http-error-responses). |
|
|
115
|
+
| `max_redirects:` | The maximum number of redirects to follow for this request. `0` turns off redirects. |
|
|
116
|
+
| `follow_method_changing_redirects:` | Whether to follow redirects that change the HTTP method. See [Redirects](#redirects). |
|
|
117
|
+
| `redirect_strip_headers:` | The names of headers to remove from redirected requests. |
|
|
118
|
+
| `preprocessors:` | The names of registered [preprocessors](#request-preprocessors) to run on the request. |
|
|
119
|
+
| `processor:` | The name of the processor that runs the request. See [Named processors](#named-processors). |
|
|
120
|
+
|
|
121
|
+
For more control, build a `PatientHttp::Request` object and pass it to `PatientHttp.execute`:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
|
|
125
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
126
|
+
params: {include: "profile"},
|
|
127
|
+
timeout: 30
|
|
128
|
+
)
|
|
129
|
+
PatientHttp.execute(request: request, callback: FetchUserCallback, callback_args: {user_id: 123})
|
|
109
130
|
```
|
|
110
131
|
|
|
111
|
-
|
|
132
|
+
Application code that uses these methods never names the job system. To move to another job system, change the integration gem in your Gemfile.
|
|
133
|
+
|
|
134
|
+
### Handle HTTP error responses
|
|
112
135
|
|
|
113
|
-
By default, HTTP error status codes (4xx
|
|
136
|
+
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
137
|
|
|
115
138
|
```ruby
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
139
|
+
class ApiCallback
|
|
140
|
+
def on_complete(response)
|
|
141
|
+
if response.success?
|
|
142
|
+
process_data(response.json)
|
|
143
|
+
elsif response.client_error?
|
|
144
|
+
handle_client_error(response.status, response.body)
|
|
145
|
+
elsif response.server_error?
|
|
146
|
+
handle_server_error(response.status, response.body)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def on_error(error)
|
|
151
|
+
Rails.logger.error("Request failed: #{error.message}")
|
|
123
152
|
end
|
|
124
153
|
end
|
|
125
154
|
```
|
|
126
155
|
|
|
127
|
-
To treat
|
|
156
|
+
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
157
|
|
|
129
158
|
```ruby
|
|
130
|
-
|
|
131
|
-
request: request,
|
|
132
|
-
task_handler: handler,
|
|
133
|
-
callback: "ApiCallback",
|
|
134
|
-
raise_error_responses: true
|
|
135
|
-
)
|
|
159
|
+
PatientHttp.get("https://api.example.com/data", callback: ApiCallback, raise_error_responses: true)
|
|
136
160
|
```
|
|
137
161
|
|
|
138
|
-
|
|
162
|
+
An `HttpError` gives you access to the request and the response:
|
|
139
163
|
|
|
140
164
|
```ruby
|
|
141
165
|
def on_error(error)
|
|
142
166
|
if error.is_a?(PatientHttp::HttpError)
|
|
143
|
-
puts error.status
|
|
144
|
-
puts error.url
|
|
145
|
-
puts error.
|
|
167
|
+
puts error.status # HTTP status code
|
|
168
|
+
puts error.url # Request URL
|
|
169
|
+
puts error.http_method # HTTP method
|
|
170
|
+
puts error.response.body # Response body
|
|
171
|
+
puts error.response.headers # Response headers
|
|
172
|
+
puts error.response.json # Response body parsed as JSON
|
|
146
173
|
end
|
|
147
174
|
end
|
|
148
175
|
```
|
|
149
176
|
|
|
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.
|
|
177
|
+
A 4xx response raises a `PatientHttp::ClientError`, and a 5xx response raises a `PatientHttp::ServerError`. Both are subclasses of `HttpError`.
|
|
167
178
|
|
|
168
|
-
|
|
179
|
+
To make `raise_error_responses` the default for every request, set it in the [configuration](#configuration).
|
|
169
180
|
|
|
170
|
-
|
|
181
|
+
### Callback arguments
|
|
171
182
|
|
|
172
|
-
|
|
183
|
+
To pass data to your callbacks, use the `callback_args` option:
|
|
173
184
|
|
|
174
185
|
```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
186
|
PatientHttp.get(
|
|
193
|
-
"https://api.example.com/users
|
|
187
|
+
"https://api.example.com/users/#{user_id}",
|
|
194
188
|
callback: FetchUserCallback,
|
|
195
|
-
callback_args: {user_id:
|
|
189
|
+
callback_args: {user_id: user_id, requested_at: Time.now.iso8601}
|
|
196
190
|
)
|
|
197
191
|
```
|
|
198
192
|
|
|
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:
|
|
193
|
+
The `response.callback_args` and `error.callback_args` methods return the arguments:
|
|
204
194
|
|
|
205
195
|
```ruby
|
|
206
|
-
|
|
196
|
+
response.callback_args[:user_id]
|
|
197
|
+
response.callback_args["user_id"]
|
|
207
198
|
```
|
|
208
199
|
|
|
209
|
-
|
|
200
|
+
The `callback_args` value follows these rules:
|
|
210
201
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
202
|
+
- 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`.
|
|
203
|
+
- Hash keys are converted to strings, including the keys of nested hashes and of hashes in arrays.
|
|
204
|
+
- You can read the arguments with symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`.
|
|
205
|
+
- Reading a key that isn't set raises a `KeyError`. To get a default value instead, use `callback_args.fetch(:user_id, nil)`.
|
|
206
|
+
|
|
207
|
+
### Use request templates
|
|
216
208
|
|
|
217
|
-
|
|
209
|
+
To share settings across requests to the same API, use `PatientHttp::RequestTemplate`:
|
|
218
210
|
|
|
219
211
|
```ruby
|
|
220
|
-
|
|
221
|
-
|
|
212
|
+
template = PatientHttp::RequestTemplate.new(
|
|
213
|
+
base_url: "https://api.example.com",
|
|
214
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
215
|
+
timeout: 60
|
|
216
|
+
)
|
|
222
217
|
|
|
223
|
-
|
|
218
|
+
request = template.get("/users/123")
|
|
219
|
+
PatientHttp.execute(request: request, callback: FetchUserCallback)
|
|
220
|
+
```
|
|
224
221
|
|
|
225
|
-
|
|
222
|
+
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. A template can also set defaults for `read_timeout` and `max_response_size`. A value given to a request overrides the template value.
|
|
226
223
|
|
|
227
|
-
Use
|
|
224
|
+
### Use the RequestHelper module
|
|
228
225
|
|
|
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`.
|
|
226
|
+
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
227
|
|
|
234
228
|
```ruby
|
|
235
229
|
class ApiClient
|
|
@@ -237,435 +231,466 @@ class ApiClient
|
|
|
237
231
|
|
|
238
232
|
request_template(
|
|
239
233
|
base_url: "https://api.example.com",
|
|
240
|
-
headers: {"Authorization" =>
|
|
234
|
+
headers: {"Authorization" => PatientHttp.secret(:api_token)},
|
|
241
235
|
timeout: 60
|
|
242
236
|
)
|
|
243
237
|
|
|
244
238
|
def fetch_user(user_id)
|
|
245
|
-
async_get(
|
|
246
|
-
"/users/#{user_id}",
|
|
247
|
-
callback: FetchUserCallback,
|
|
248
|
-
callback_args: {user_id: user_id}
|
|
249
|
-
)
|
|
239
|
+
async_get("/users/#{user_id}", callback: FetchUserCallback, callback_args: {user_id: user_id})
|
|
250
240
|
end
|
|
251
241
|
|
|
252
242
|
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
|
-
)
|
|
243
|
+
async_patch("/users/#{user_id}", json: data, callback: UpdateUserCallback, callback_args: {user_id: user_id})
|
|
259
244
|
end
|
|
260
245
|
end
|
|
261
246
|
```
|
|
262
247
|
|
|
263
|
-
|
|
248
|
+
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
249
|
|
|
265
|
-
|
|
250
|
+
### Streamed and long-running responses
|
|
266
251
|
|
|
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
|
-
```
|
|
252
|
+
The `timeout` option limits the full request, from the time it is sent until the last byte of the body arrives. A streamed response, such as server-sent events from an LLM API, can continue for many minutes. A large `timeout` lets the stream finish, but it cannot find a stream that stops.
|
|
275
253
|
|
|
276
|
-
|
|
254
|
+
To find a stream that stops, set `read_timeout`. This is the maximum number of seconds to wait for data from the server. The first timer starts before the connection opens. It covers the time to get a connection, send the request, and wait for the response headers. The timer starts again each time a body chunk arrives. A stream that sends data at regular intervals can continue for longer than `read_timeout`, until `timeout` stops it.
|
|
277
255
|
|
|
278
256
|
```ruby
|
|
279
|
-
|
|
280
|
-
|
|
257
|
+
PatientHttp.post(
|
|
258
|
+
"https://api.example.com/v1/generate",
|
|
259
|
+
json: {prompt: "...", stream: true},
|
|
260
|
+
callback: GenerateCallback,
|
|
261
|
+
timeout: 30 * 60, # Limit for the full stream.
|
|
262
|
+
read_timeout: 60, # Limit for each wait for data.
|
|
263
|
+
max_response_size: 8 * 1024 * 1024
|
|
264
|
+
)
|
|
281
265
|
```
|
|
282
266
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
## Response and Error Objects
|
|
267
|
+
When the read timeout stops a request, the `on_error` callback receives a `RequestError` with an `error_type` of `:timeout` and an `error_class` of `PatientHttp::ReadTimeoutError`. When `timeout` stops a request, the `error_class` is `Async::TimeoutError`. A request stopped by either timeout isn't retried, because the server may have processed it.
|
|
286
268
|
|
|
287
|
-
|
|
269
|
+
Do not set `read_timeout` for a request where the server does all of its work before it sends the response headers. For that request, the wait for the headers can be longer than the read timeout, and the request fails.
|
|
288
270
|
|
|
289
|
-
|
|
271
|
+
The callback receives the full body when the stream ends. A streamed body is usually larger than the equivalent JSON body, so set `max_response_size` on the request to increase the limit for these requests only.
|
|
290
272
|
|
|
291
|
-
|
|
292
|
-
def on_complete(response, callback)
|
|
293
|
-
# Serialize the response for background processing
|
|
294
|
-
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
295
|
-
end
|
|
273
|
+
## Configuration
|
|
296
274
|
|
|
297
|
-
|
|
298
|
-
# Serialize the error for background processing
|
|
299
|
-
MyJobSystem.enqueue(callback, :on_error, error.as_json)
|
|
300
|
-
end
|
|
301
|
-
```
|
|
275
|
+
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.
|
|
302
276
|
|
|
303
|
-
|
|
277
|
+
Every call yields the same configuration object, so options accumulate. Several initializers can each set options without overwriting one another.
|
|
304
278
|
|
|
305
279
|
```ruby
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
The `Response` object includes the HTTP status code, headers, body, and callback arguments. Error objects (`HttpError`, `RedirectError`, `RequestError`) include the error message, context about the request, and callback arguments.
|
|
280
|
+
PatientHttp.configure do |config|
|
|
281
|
+
# Maximum concurrent HTTP requests (default: 256).
|
|
282
|
+
config.max_connections = 256
|
|
311
283
|
|
|
312
|
-
|
|
284
|
+
# Maximum connections to each host (default: nil, no limit).
|
|
285
|
+
config.max_connections_per_host = 32
|
|
313
286
|
|
|
314
|
-
|
|
287
|
+
# Default timeout for HTTP requests in seconds (default: 60).
|
|
288
|
+
config.request_timeout = 60
|
|
315
289
|
|
|
316
|
-
|
|
290
|
+
# Default maximum number of seconds to wait for data from the server
|
|
291
|
+
# (default: nil, no limit). The timer covers the wait for the response
|
|
292
|
+
# headers and starts again each time a body chunk arrives. See "Streamed and
|
|
293
|
+
# long-running responses".
|
|
294
|
+
config.read_timeout = nil
|
|
317
295
|
|
|
318
|
-
|
|
296
|
+
# Timeout for graceful shutdown in seconds (default: 30).
|
|
297
|
+
config.shutdown_timeout = 30
|
|
319
298
|
|
|
320
|
-
|
|
299
|
+
# Maximum response body size in bytes (default: 1MB). Larger responses raise
|
|
300
|
+
# ResponseTooLargeError. A request can set its own max_response_size.
|
|
301
|
+
config.max_response_size = 1024 * 1024
|
|
321
302
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
config.register_payload_store(:my_store, adapter: :file, directory: "/tmp/payloads")
|
|
303
|
+
# Default User-Agent header for all requests (default: "PatientHttp").
|
|
304
|
+
config.user_agent = "MyApp/1.0"
|
|
325
305
|
|
|
326
|
-
#
|
|
327
|
-
|
|
306
|
+
# Whether to raise HttpError for non-2xx responses by default (default: false).
|
|
307
|
+
config.raise_error_responses = false
|
|
328
308
|
|
|
329
|
-
|
|
330
|
-
|
|
309
|
+
# Maximum number of redirects to follow (default: 5; 0 turns off redirects).
|
|
310
|
+
config.max_redirects = 5
|
|
331
311
|
|
|
332
|
-
|
|
333
|
-
#
|
|
312
|
+
# Whether to follow redirects that change the HTTP method, such as POST to
|
|
313
|
+
# GET on a 302 (default: true). If false, the request gets the redirect
|
|
314
|
+
# response.
|
|
315
|
+
config.follow_method_changing_redirects = true
|
|
334
316
|
|
|
335
|
-
|
|
336
|
-
|
|
317
|
+
# Names of headers to remove from all redirected requests (default: []).
|
|
318
|
+
# Names are case insensitive. Authorization and Cookie are always removed on
|
|
319
|
+
# cross-origin redirects.
|
|
320
|
+
config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
|
|
337
321
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
storage.delete(large_response_data) # Deletes the stored payload
|
|
342
|
-
```
|
|
322
|
+
# Maximum number of host clients to pool (default: 100).
|
|
323
|
+
config.connection_pool_size = 100
|
|
343
324
|
|
|
344
|
-
|
|
325
|
+
# Timeout in seconds to open a connection, including the TCP connect and the
|
|
326
|
+
# TLS handshake (default: nil, no limit). It doesn't limit the wait for a
|
|
327
|
+
# response; request_timeout does that.
|
|
328
|
+
config.connection_timeout = 10
|
|
345
329
|
|
|
346
|
-
|
|
330
|
+
# TCP keepalive for pooled connections (default: nil, the kernel sends no
|
|
331
|
+
# probes). A number sets the idle seconds before the first probe. A Hash also
|
|
332
|
+
# sets the interval and the probe count, for example
|
|
333
|
+
# {idle: 30, interval: 10, count: 3}. The Hash must contain :idle. The
|
|
334
|
+
# :interval default is 10 seconds, and the :count default is 3 probes.
|
|
335
|
+
config.tcp_keepalive = 30
|
|
347
336
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
337
|
+
# Seconds that sent data can stay unacknowledged before the kernel closes the
|
|
338
|
+
# connection (default: nil, the kernel default applies). Sets
|
|
339
|
+
# TCP_USER_TIMEOUT, which is available only on Linux.
|
|
340
|
+
config.tcp_user_timeout = 30
|
|
351
341
|
|
|
352
|
-
|
|
342
|
+
# HTTP or HTTPS proxy URL (default: nil). Supports authentication, for
|
|
343
|
+
# example "http://user:pass@proxy.example.com:8080".
|
|
344
|
+
config.proxy_url = "http://proxy.example.com:8080"
|
|
353
345
|
|
|
354
|
-
|
|
346
|
+
# Number of retries for failed requests (default: 3).
|
|
347
|
+
config.retries = 3
|
|
355
348
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
349
|
+
# HTTP protocol, :http1 or :http2 (default: nil, negotiated with the server,
|
|
350
|
+
# with HTTP/2 preferred for HTTPS). The :http1 value also limits the TLS ALPN
|
|
351
|
+
# advertisement to http/1.1, which can work around proxies that intercept SSL
|
|
352
|
+
# and don't handle HTTP/2 correctly.
|
|
353
|
+
config.protocol = nil
|
|
360
354
|
|
|
361
|
-
|
|
355
|
+
# Number of threads that decode responses and deliver results (default: 2).
|
|
356
|
+
config.completion_threads = 2
|
|
362
357
|
|
|
363
|
-
|
|
358
|
+
# Number of times to retry result delivery before the failure is reported
|
|
359
|
+
# (default: 2).
|
|
360
|
+
config.completion_retries = 2
|
|
364
361
|
|
|
365
|
-
|
|
362
|
+
# Size in bytes above which payloads are stored externally when a payload
|
|
363
|
+
# store is configured (default: 64KB).
|
|
364
|
+
config.payload_store_threshold = 64 * 1024
|
|
366
365
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
config.register_payload_store(:s3, adapter: :s3, bucket: bucket)
|
|
366
|
+
# Logger (default: a Logger that writes errors to standard error).
|
|
367
|
+
config.logger = Rails.logger
|
|
368
|
+
end
|
|
371
369
|
```
|
|
372
370
|
|
|
373
|
-
|
|
371
|
+
`PatientHttp.configuration` returns the same object outside a `configure` block.
|
|
374
372
|
|
|
375
|
-
|
|
373
|
+
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).
|
|
376
374
|
|
|
377
|
-
|
|
375
|
+
### Named processors
|
|
378
376
|
|
|
379
|
-
|
|
380
|
-
config.register_payload_store(:database, adapter: :active_record)
|
|
381
|
-
```
|
|
377
|
+
Named processors are available only when an integration gem is loaded. The integration gems add the `config.processor` method.
|
|
382
378
|
|
|
383
|
-
|
|
379
|
+
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:
|
|
384
380
|
|
|
385
381
|
```ruby
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
add_index :patient_http_payloads, :key, unique: true
|
|
396
|
-
add_index :patient_http_payloads, :created_at
|
|
397
|
-
end
|
|
382
|
+
PatientHttp.configure do |config|
|
|
383
|
+
config.processor(:llm, max_connections: 200, request_timeout: 120)
|
|
384
|
+
config.processor(:webhooks, max_connections: 64, request_timeout: 10)
|
|
398
385
|
end
|
|
399
386
|
```
|
|
400
387
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
#### Custom Stores
|
|
388
|
+
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.
|
|
404
389
|
|
|
405
|
-
|
|
390
|
+
To send a request to a processor, set the `processor:` option on the request method, on a `Request`, or on a `RequestTemplate`:
|
|
406
391
|
|
|
407
392
|
```ruby
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
def store(key, data)
|
|
412
|
-
# Store the hash and return the key
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
def fetch(key)
|
|
416
|
-
# Return the hash or nil if not found
|
|
417
|
-
end
|
|
393
|
+
PatientHttp.post(url, callback: MyCallback, processor: :llm)
|
|
394
|
+
```
|
|
418
395
|
|
|
419
|
-
|
|
420
|
-
# Delete the data (idempotent)
|
|
421
|
-
end
|
|
422
|
-
end
|
|
396
|
+
For how the processor name is kept through retries and crash recovery, see the documentation for your integration gem.
|
|
423
397
|
|
|
424
|
-
|
|
425
|
-
```
|
|
398
|
+
### Tuning tips
|
|
426
399
|
|
|
427
|
-
|
|
400
|
+
- `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.
|
|
401
|
+
- `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.
|
|
402
|
+
- `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.
|
|
403
|
+
- `read_timeout`: Limits each wait for data from the server, including the wait for the response headers. Set it on requests that stream their response, so that a stream that stops fails before `request_timeout`. Do not set it as a default if the APIs that you call do their work before they send the response headers.
|
|
404
|
+
- `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.
|
|
405
|
+
- `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.
|
|
406
|
+
- `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.
|
|
407
|
+
- `connection_pool_size`: Sets the maximum number of hosts whose connections are kept open. Increase it if your application calls many different hosts.
|
|
408
|
+
- `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. To increase the limit for some requests only, set `max_response_size` on those requests. For large responses, consider a [payload store](#payload-stores).
|
|
409
|
+
- `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.
|
|
410
|
+
- `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.
|
|
411
|
+
- `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.
|
|
428
412
|
|
|
429
|
-
|
|
413
|
+
### Response compression
|
|
430
414
|
|
|
431
|
-
|
|
415
|
+
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:
|
|
432
416
|
|
|
433
|
-
|
|
417
|
+
- `identity` turns off compression.
|
|
418
|
+
- Any other encoding is delivered still encoded, with its `content-encoding` header, so that you can decode it yourself.
|
|
434
419
|
|
|
435
|
-
|
|
420
|
+
## Sensitive and large payloads
|
|
436
421
|
|
|
437
|
-
|
|
422
|
+
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.
|
|
438
423
|
|
|
439
|
-
|
|
424
|
+
These features cover both cases. They work together:
|
|
440
425
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
config.
|
|
444
|
-
|
|
426
|
+
| Feature | When to use it | How to register it |
|
|
427
|
+
| --- | --- | --- |
|
|
428
|
+
| [Secrets](#secrets) | A sensitive header or query parameter, such as an API token. | `config.register_secret` |
|
|
429
|
+
| [Preprocessors](#request-preprocessors) | A signature calculated from the final request, such as AWS SigV4. | `config.register_preprocessor` |
|
|
430
|
+
| [Encryption](#encryption) | The request or response body is sensitive. | `config.encryption_key` |
|
|
431
|
+
| [Payload stores](#payload-stores) | Payloads are too large for the queue. | `config.register_payload_store` |
|
|
445
432
|
|
|
446
|
-
|
|
433
|
+
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.
|
|
447
434
|
|
|
448
|
-
|
|
449
|
-
config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
|
|
450
|
-
```
|
|
435
|
+
### Secrets
|
|
451
436
|
|
|
452
|
-
|
|
437
|
+
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.
|
|
453
438
|
|
|
454
|
-
|
|
439
|
+
Register a secret with a value, or with a block that runs each time the secret is resolved:
|
|
455
440
|
|
|
456
441
|
```ruby
|
|
457
|
-
|
|
458
|
-
config.
|
|
442
|
+
PatientHttp.configure do |config|
|
|
443
|
+
config.register_secret(:authorization, "Bearer #{ENV["API_TOKEN"]}")
|
|
444
|
+
config.register_secret(:api_key) { ENV["MY_API_KEY"] }
|
|
445
|
+
end
|
|
459
446
|
```
|
|
460
447
|
|
|
461
|
-
|
|
448
|
+
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:
|
|
462
449
|
|
|
463
450
|
```ruby
|
|
464
|
-
|
|
465
|
-
config.decryption(->(bytes) { MyEncryption.decrypt(bytes) })
|
|
451
|
+
PatientHttp.register_secret(:api_key) { ENV["MY_API_KEY"] }
|
|
466
452
|
```
|
|
467
453
|
|
|
468
|
-
|
|
454
|
+
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)`.
|
|
469
455
|
|
|
470
|
-
|
|
456
|
+
Use a secret reference as a header or query parameter value:
|
|
471
457
|
|
|
472
458
|
```ruby
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
459
|
+
PatientHttp.get(
|
|
460
|
+
"https://api.example.com/data",
|
|
461
|
+
callback: MyCallback,
|
|
462
|
+
headers: {"Authorization" => PatientHttp.secret(:authorization)},
|
|
463
|
+
params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
|
|
464
|
+
)
|
|
465
|
+
```
|
|
478
466
|
|
|
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
|
|
467
|
+
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`.
|
|
484
468
|
|
|
485
|
-
|
|
486
|
-
encrypted = @configuration.encryptor.encrypt(error.as_json)
|
|
487
|
-
MyJobSystem.enqueue(callback, :on_error, encrypted)
|
|
488
|
-
end
|
|
469
|
+
### Request preprocessors
|
|
489
470
|
|
|
490
|
-
|
|
491
|
-
MyJobSystem.enqueue_job(@job_id)
|
|
492
|
-
end
|
|
493
|
-
end
|
|
471
|
+
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.
|
|
494
472
|
|
|
495
|
-
|
|
496
|
-
handler = MyTaskHandler.new("job-123", configuration: config)
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
In your callback, decrypt before processing:
|
|
473
|
+
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.
|
|
500
474
|
|
|
501
475
|
```ruby
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
476
|
+
PatientHttp.configure do |config|
|
|
477
|
+
config.register_preprocessor(:aws_sigv4) do |request|
|
|
478
|
+
signer = Aws::Sigv4::Signer.new(
|
|
479
|
+
service: "execute-api",
|
|
480
|
+
region: "us-east-1",
|
|
481
|
+
credentials_provider: Aws::CredentialProviderChain.new.resolve
|
|
482
|
+
)
|
|
483
|
+
signature = signer.sign_request(
|
|
484
|
+
http_method: request.http_method.to_s.upcase,
|
|
485
|
+
url: request.url,
|
|
486
|
+
headers: request.headers.to_h,
|
|
487
|
+
body: request.body.to_s
|
|
488
|
+
)
|
|
489
|
+
signature.headers.each { |name, value| request.headers[name] = value }
|
|
510
490
|
end
|
|
511
491
|
end
|
|
492
|
+
|
|
493
|
+
PatientHttp.post("https://api.example.com/data", callback: MyCallback, json: {value: 1}, preprocessors: :aws_sigv4)
|
|
512
494
|
```
|
|
513
495
|
|
|
514
|
-
|
|
496
|
+
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:
|
|
515
497
|
|
|
516
|
-
|
|
498
|
+
- `http_method`, `url`, and `body`: Read-only. The URL includes the resolved secret query parameters.
|
|
499
|
+
- `headers`: The request headers. You can change them. Names are case insensitive.
|
|
500
|
+
- `add_param(name, value)`: Adds a query parameter to the URL, for signing schemes that use query parameters.
|
|
517
501
|
|
|
518
|
-
|
|
502
|
+
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`.
|
|
519
503
|
|
|
520
|
-
|
|
504
|
+
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.
|
|
521
505
|
|
|
522
|
-
|
|
506
|
+
### Encryption
|
|
523
507
|
|
|
524
|
-
|
|
508
|
+
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.
|
|
525
509
|
|
|
526
|
-
|
|
510
|
+
The simplest option is `encryption_key`. It uses [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) with AES-256-GCM:
|
|
527
511
|
|
|
528
512
|
```ruby
|
|
529
|
-
|
|
530
|
-
config.
|
|
531
|
-
|
|
513
|
+
PatientHttp.configure do |config|
|
|
514
|
+
config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
|
|
515
|
+
end
|
|
532
516
|
```
|
|
533
517
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
#### Module-level registration
|
|
537
|
-
|
|
538
|
-
If the `Configuration` is owned by an integration gem (patient_http-sidekiq, patient_http-solid_queue), your application code may not have a convenient reference to it — or may load before it exists. In that case, register secrets at the module level instead:
|
|
518
|
+
To rotate keys, pass an array. The first key encrypts data, and all keys are tried for decryption:
|
|
539
519
|
|
|
540
520
|
```ruby
|
|
541
|
-
PatientHttp.
|
|
542
|
-
|
|
521
|
+
PatientHttp.configure do |config|
|
|
522
|
+
config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
|
|
523
|
+
end
|
|
543
524
|
```
|
|
544
525
|
|
|
545
|
-
|
|
526
|
+
To use another encryption library, provide callables that take and return raw bytes as a String:
|
|
546
527
|
|
|
547
528
|
```ruby
|
|
548
|
-
PatientHttp.
|
|
529
|
+
PatientHttp.configure do |config|
|
|
530
|
+
config.encryption { |bytes| MyEncryption.encrypt(bytes) }
|
|
531
|
+
config.decryption { |bytes| MyEncryption.decrypt(bytes) }
|
|
532
|
+
end
|
|
549
533
|
```
|
|
550
534
|
|
|
551
|
-
|
|
535
|
+
You can also pass any object that responds to `call`.
|
|
536
|
+
|
|
537
|
+
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.
|
|
552
538
|
|
|
553
|
-
|
|
539
|
+
If you write your own `TaskHandler`, see [Build a custom integration](#build-a-custom-integration) for how to add encryption.
|
|
554
540
|
|
|
555
|
-
|
|
541
|
+
### Payload stores
|
|
542
|
+
|
|
543
|
+
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.
|
|
556
544
|
|
|
557
545
|
```ruby
|
|
558
|
-
PatientHttp.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
params: {"api_key" => PatientHttp.secret(:api_key), "page" => 2}
|
|
563
|
-
)
|
|
546
|
+
PatientHttp.configure do |config|
|
|
547
|
+
config.register_payload_store(:redis, adapter: :redis, redis: Redis.new(url: ENV["REDIS_URL"]), ttl: 86_400)
|
|
548
|
+
config.payload_store_threshold = 64 * 1024
|
|
549
|
+
end
|
|
564
550
|
```
|
|
565
551
|
|
|
566
|
-
|
|
552
|
+
These adapters are available:
|
|
567
553
|
|
|
568
|
-
|
|
554
|
+
| Adapter | Options | Notes |
|
|
555
|
+
| --- | --- | --- |
|
|
556
|
+
| `:file` | `directory:` | For development and tests only. Hosts don't share the files. |
|
|
557
|
+
| `:redis` | `redis:` (required), `ttl:`, `key_prefix:` (default `"patient_http:payloads:"`) | Requires the `redis` gem. The client must respond to `set`, `get`, `del`, and `exists`. |
|
|
558
|
+
| `:s3` | `bucket:` (required), `key_prefix:` (default `"patient_http/payloads/"`) | Requires the `aws-sdk-s3` gem. |
|
|
559
|
+
| `:active_record` | `model:` | Requires a database table. See the following section. |
|
|
569
560
|
|
|
570
|
-
|
|
561
|
+
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:
|
|
571
562
|
|
|
572
|
-
|
|
563
|
+
```ruby
|
|
564
|
+
require "patient_http/rails/engine"
|
|
565
|
+
```
|
|
573
566
|
|
|
574
|
-
|
|
567
|
+
```bash
|
|
568
|
+
bin/rails patient_http:install:migrations
|
|
569
|
+
bin/rails db:migrate
|
|
570
|
+
```
|
|
575
571
|
|
|
576
|
-
|
|
572
|
+
You can also add the migration yourself:
|
|
577
573
|
|
|
578
574
|
```ruby
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
body: request.body.to_s
|
|
591
|
-
)
|
|
592
|
-
signature.headers.each { |name, value| request.headers[name] = value }
|
|
575
|
+
class CreatePatientHttpPayloads < ActiveRecord::Migration[7.0]
|
|
576
|
+
def change
|
|
577
|
+
create_table :patient_http_payloads, id: false do |t|
|
|
578
|
+
t.string :key, null: false, limit: 36
|
|
579
|
+
t.text :data, null: false
|
|
580
|
+
t.timestamps
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
add_index :patient_http_payloads, :key, unique: true
|
|
584
|
+
add_index :patient_http_payloads, :created_at
|
|
585
|
+
end
|
|
593
586
|
end
|
|
594
587
|
```
|
|
595
588
|
|
|
596
|
-
|
|
589
|
+
To write your own adapter, subclass `PatientHttp::PayloadStore::Base`, and implement `store_json`, `fetch`, and `delete`. The adapter must be thread-safe.
|
|
597
590
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
591
|
+
```ruby
|
|
592
|
+
class MyStore < PatientHttp::PayloadStore::Base
|
|
593
|
+
register :my_store, self
|
|
601
594
|
|
|
602
|
-
|
|
595
|
+
def store_json(key, json)
|
|
596
|
+
# Store the JSON string and return the key.
|
|
597
|
+
end
|
|
603
598
|
|
|
604
|
-
|
|
599
|
+
def fetch(key)
|
|
600
|
+
# Return the parsed hash, or nil if the key isn't found.
|
|
601
|
+
end
|
|
605
602
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
)
|
|
603
|
+
def delete(key)
|
|
604
|
+
# Delete the data. Don't raise an error if the key doesn't exist.
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
PatientHttp.configure do |config|
|
|
609
|
+
config.register_payload_store(:custom, adapter: :my_store, **options)
|
|
610
|
+
end
|
|
613
611
|
```
|
|
614
612
|
|
|
615
|
-
|
|
613
|
+
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.
|
|
616
614
|
|
|
617
|
-
|
|
615
|
+
To store and fetch payloads yourself, use `PatientHttp::ExternalStorage`:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
storage = PatientHttp::ExternalStorage.new(PatientHttp.configuration)
|
|
618
619
|
|
|
619
|
-
|
|
620
|
+
data = storage.store(response.as_json, max_size: 1024) # Returns the original hash if it's 1KB or smaller.
|
|
621
|
+
storage.storage_ref?(data) # Returns true if the hash was stored.
|
|
622
|
+
storage.fetch(data) # Returns the original hash.
|
|
623
|
+
storage.delete(data) # Deletes the stored payload.
|
|
624
|
+
```
|
|
620
625
|
|
|
621
626
|
## Redirects
|
|
622
627
|
|
|
623
|
-
|
|
628
|
+
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
629
|
|
|
625
630
|
The HTTP method of the redirected request follows RFC 9110:
|
|
626
631
|
|
|
627
632
|
| Status | Method |
|
|
628
633
|
| --- | --- |
|
|
629
|
-
| 301, 302 | `POST`
|
|
630
|
-
| 303 | `GET` and `HEAD`
|
|
631
|
-
| 300, 307, 308 | The method and body
|
|
634
|
+
| 301, 302 | `POST` changes to `GET`, and the body is removed. Other methods, including `HEAD`, `PUT`, `DELETE`, and `QUERY`, keep their method and body. |
|
|
635
|
+
| 303 | `GET` and `HEAD` keep their method. All other methods change to `GET`, and the body is removed. |
|
|
636
|
+
| 300, 307, 308 | The method and body don't change. |
|
|
637
|
+
|
|
638
|
+
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
639
|
|
|
633
|
-
|
|
640
|
+
### Prevent method changes
|
|
634
641
|
|
|
635
|
-
|
|
642
|
+
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
643
|
|
|
637
|
-
|
|
644
|
+
You can set the option in the configuration or on a request. If both are set, the request value applies.
|
|
638
645
|
|
|
639
646
|
```ruby
|
|
640
|
-
|
|
647
|
+
PatientHttp.configure do |config|
|
|
648
|
+
config.follow_method_changing_redirects = false
|
|
649
|
+
end
|
|
641
650
|
|
|
642
|
-
# Or
|
|
643
|
-
|
|
651
|
+
# Or for one request.
|
|
652
|
+
PatientHttp.post(url, callback: MyCallback, body: payload, follow_method_changing_redirects: false)
|
|
644
653
|
```
|
|
645
654
|
|
|
646
|
-
###
|
|
655
|
+
### Remove headers on redirects
|
|
647
656
|
|
|
648
|
-
`Authorization` and `Cookie` headers are always removed on cross-origin redirects. To
|
|
657
|
+
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
658
|
|
|
650
659
|
```ruby
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
request = PatientHttp::Request.new(:get, "https://api.example.com/data", headers: headers, redirect_strip_headers: "X-Signature")
|
|
660
|
+
PatientHttp.configure do |config|
|
|
661
|
+
config.redirect_strip_headers = ["X-Api-Key", "X-Internal-Token"]
|
|
662
|
+
end
|
|
655
663
|
|
|
656
|
-
#
|
|
664
|
+
# Or for one request. These headers are removed in addition to the configured headers.
|
|
657
665
|
PatientHttp.get("https://api.example.com/data", callback: FetchCallback, redirect_strip_headers: "X-Signature")
|
|
658
666
|
```
|
|
659
667
|
|
|
660
|
-
|
|
668
|
+
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
669
|
|
|
662
|
-
|
|
670
|
+
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.
|
|
671
|
+
|
|
672
|
+
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`.
|
|
673
|
+
|
|
674
|
+
## Response and error objects
|
|
675
|
+
|
|
676
|
+
`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:
|
|
677
|
+
|
|
678
|
+
```ruby
|
|
679
|
+
response = PatientHttp::Response.load(json_data)
|
|
680
|
+
error = PatientHttp::Error.load(json_data)
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
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`.
|
|
684
|
+
|
|
685
|
+
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.
|
|
686
|
+
|
|
687
|
+
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
688
|
|
|
664
689
|
## Troubleshooting
|
|
665
690
|
|
|
666
691
|
### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
|
|
667
692
|
|
|
668
|
-
On some Ruby versions
|
|
693
|
+
On some Ruby versions, a warning like this can appear in your logs:
|
|
669
694
|
|
|
670
695
|
```
|
|
671
696
|
warn: Async::Task: Async::Pool::Controller Gardener [...]
|
|
@@ -674,114 +699,122 @@ warn: Async::Task: Async::Pool::Controller Gardener [...]
|
|
|
674
699
|
| → .../async-pool-x.y.z/lib/async/pool/controller.rb:132 in `synchronize'
|
|
675
700
|
```
|
|
676
701
|
|
|
677
|
-
|
|
702
|
+
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
703
|
|
|
679
|
-
The warning is harmless
|
|
704
|
+
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
705
|
|
|
681
|
-
##
|
|
706
|
+
## Build a custom integration
|
|
682
707
|
|
|
683
|
-
|
|
684
|
-
config = PatientHttp::Configuration.new(
|
|
685
|
-
# Maximum concurrent HTTP requests (default: 256)
|
|
686
|
-
max_connections: 256,
|
|
708
|
+
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
709
|
|
|
688
|
-
|
|
689
|
-
request_timeout: 60,
|
|
710
|
+
An integration has these parts:
|
|
690
711
|
|
|
691
|
-
|
|
692
|
-
|
|
712
|
+
- A `TaskHandler` that delivers results to your job system.
|
|
713
|
+
- A `Processor` that runs requests.
|
|
714
|
+
- A registered request handler, so that application code can use the `PatientHttp` module methods.
|
|
693
715
|
|
|
694
|
-
|
|
695
|
-
max_response_size: 1024 * 1024,
|
|
716
|
+
### Create a task handler
|
|
696
717
|
|
|
697
|
-
|
|
698
|
-
user_agent: "MyApp/1.0",
|
|
718
|
+
The `TaskHandler` connects the processor to your job system:
|
|
699
719
|
|
|
700
|
-
|
|
701
|
-
|
|
720
|
+
```ruby
|
|
721
|
+
class MyTaskHandler < PatientHttp::TaskHandler
|
|
722
|
+
def initialize(job_id)
|
|
723
|
+
@job_id = job_id
|
|
724
|
+
end
|
|
702
725
|
|
|
703
|
-
|
|
704
|
-
|
|
726
|
+
def on_complete(response, callback)
|
|
727
|
+
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
728
|
+
end
|
|
705
729
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
730
|
+
def on_error(error, callback)
|
|
731
|
+
MyJobSystem.enqueue(callback, :on_error, error.as_json)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
def retry
|
|
735
|
+
# Re-enqueue the original job when the processor shuts down before the
|
|
736
|
+
# request finishes.
|
|
737
|
+
MyJobSystem.enqueue_job(@job_id)
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
```
|
|
709
741
|
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
742
|
+
> [!IMPORTANT]
|
|
743
|
+
> 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.
|
|
744
|
+
>
|
|
745
|
+
> 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.
|
|
746
|
+
>
|
|
747
|
+
> 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
748
|
|
|
715
|
-
|
|
716
|
-
connection_pool_size: 100,
|
|
749
|
+
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
750
|
|
|
718
|
-
|
|
719
|
-
|
|
751
|
+
```ruby
|
|
752
|
+
def on_complete(response, callback)
|
|
753
|
+
encrypted = @configuration.encryptor.encrypt(response.as_json)
|
|
754
|
+
MyJobSystem.enqueue(callback, :on_complete, encrypted)
|
|
755
|
+
end
|
|
756
|
+
```
|
|
720
757
|
|
|
721
|
-
|
|
722
|
-
proxy_url: "http://proxy.example.com:8080",
|
|
758
|
+
Decrypt the data before you create the object again:
|
|
723
759
|
|
|
724
|
-
|
|
725
|
-
|
|
760
|
+
```ruby
|
|
761
|
+
response = PatientHttp::Response.load(@configuration.encryptor.decrypt(data))
|
|
762
|
+
```
|
|
726
763
|
|
|
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,
|
|
764
|
+
### Run a processor
|
|
732
765
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
)
|
|
766
|
+
```ruby
|
|
767
|
+
config = PatientHttp::Configuration.new(max_connections: 256, request_timeout: 60)
|
|
768
|
+
processor = PatientHttp::Processor.new(config)
|
|
769
|
+
processor.start
|
|
736
770
|
|
|
737
|
-
|
|
738
|
-
|
|
771
|
+
task = PatientHttp::RequestTask.new(
|
|
772
|
+
request: PatientHttp::Request.new(:get, "https://api.example.com/users/123"),
|
|
773
|
+
task_handler: MyTaskHandler.new("job-123"),
|
|
774
|
+
callback: "FetchUserCallback",
|
|
775
|
+
callback_args: {user_id: 123}
|
|
776
|
+
)
|
|
777
|
+
processor.enqueue(task)
|
|
739
778
|
```
|
|
740
779
|
|
|
741
|
-
|
|
780
|
+
If the processor isn't running, `enqueue` raises a `PatientHttp::NotRunningError`. If the processor is at `max_connections`, it raises a `PatientHttp::MaxCapacityError`.
|
|
742
781
|
|
|
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.
|
|
782
|
+
A process can run several named processors, each with its own capacity and threads:
|
|
752
783
|
|
|
753
|
-
|
|
784
|
+
```ruby
|
|
785
|
+
PatientHttp::Processor.new(config, name: :llm)
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
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
789
|
|
|
755
|
-
|
|
790
|
+
### Processor lifecycle
|
|
756
791
|
|
|
757
792
|
```
|
|
758
793
|
stopped -> starting -> running -> draining -> stopping -> stopped
|
|
759
794
|
```
|
|
760
795
|
|
|
761
|
-
-
|
|
762
|
-
-
|
|
763
|
-
-
|
|
764
|
-
-
|
|
765
|
-
-
|
|
796
|
+
- `stopped`: The processor doesn't run requests.
|
|
797
|
+
- `starting`: The processor starts the reactor thread.
|
|
798
|
+
- `running`: The processor accepts and runs requests.
|
|
799
|
+
- `draining`: The processor rejects new requests and finishes in-flight requests.
|
|
800
|
+
- `stopping`: The processor shuts down and re-enqueues requests that didn't finish.
|
|
766
801
|
|
|
767
802
|
```ruby
|
|
768
|
-
processor
|
|
769
|
-
|
|
770
|
-
processor.start # Start processing
|
|
803
|
+
processor.start # Start the processor.
|
|
771
804
|
processor.running? # => true
|
|
772
805
|
|
|
773
|
-
processor.drain # Stop accepting new requests
|
|
806
|
+
processor.drain # Stop accepting new requests.
|
|
774
807
|
processor.draining? # => true
|
|
775
808
|
|
|
776
|
-
processor.stop(timeout: 25) #
|
|
809
|
+
processor.stop(timeout: 25) # Shut down gracefully.
|
|
777
810
|
processor.stopped? # => true
|
|
778
811
|
```
|
|
779
812
|
|
|
780
|
-
When the processor stops with in-flight requests, it calls `TaskHandler#retry`
|
|
813
|
+
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
814
|
|
|
782
|
-
###
|
|
815
|
+
### Observe the processor
|
|
783
816
|
|
|
784
|
-
|
|
817
|
+
To receive processor events, subclass `PatientHttp::ProcessorObserver`, and override the methods for the events that you need:
|
|
785
818
|
|
|
786
819
|
```ruby
|
|
787
820
|
class MetricsObserver < PatientHttp::ProcessorObserver
|
|
@@ -805,27 +838,48 @@ end
|
|
|
805
838
|
processor.observe(MetricsObserver.new)
|
|
806
839
|
```
|
|
807
840
|
|
|
808
|
-
Observers can also track
|
|
841
|
+
Observers can also track each task through the processor:
|
|
809
842
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
- `request_requeued(request_task)
|
|
843
|
+
- `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.
|
|
844
|
+
- `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.
|
|
845
|
+
- `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.
|
|
846
|
+
- `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
847
|
|
|
814
|
-
|
|
848
|
+
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
849
|
|
|
816
|
-
|
|
850
|
+
For the thread that runs each observer method, see the `ProcessorObserver` documentation. Observers must be thread-safe.
|
|
817
851
|
|
|
818
|
-
|
|
852
|
+
### Register a request handler
|
|
819
853
|
|
|
820
|
-
|
|
854
|
+
To let application code use the `PatientHttp` module methods, register a request handler. The handler builds each task:
|
|
821
855
|
|
|
822
856
|
```ruby
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
857
|
+
PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_error_responses: nil|
|
|
858
|
+
task = PatientHttp::RequestTask.new(
|
|
859
|
+
request: request,
|
|
860
|
+
task_handler: MyTaskHandler.new(MyJobSystem.current_job_id),
|
|
861
|
+
callback: callback,
|
|
862
|
+
callback_args: callback_args,
|
|
863
|
+
raise_error_responses: raise_error_responses
|
|
864
|
+
)
|
|
865
|
+
processor.enqueue(task)
|
|
866
|
+
task.id
|
|
867
|
+
end
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
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?`.
|
|
871
|
+
|
|
872
|
+
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`:
|
|
873
|
+
|
|
874
|
+
```ruby
|
|
875
|
+
PatientHttp.register_configuration_provider(MyIntegration)
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
### Test an integration
|
|
828
879
|
|
|
880
|
+
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:
|
|
881
|
+
|
|
882
|
+
```ruby
|
|
829
883
|
executor = PatientHttp::SynchronousExecutor.new(
|
|
830
884
|
task,
|
|
831
885
|
config: config,
|
|
@@ -836,25 +890,23 @@ executor = PatientHttp::SynchronousExecutor.new(
|
|
|
836
890
|
executor.call
|
|
837
891
|
```
|
|
838
892
|
|
|
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.
|
|
893
|
+
## Installation
|
|
846
894
|
|
|
847
|
-
|
|
895
|
+
Most applications need only the integration gem for their job system, which depends on this gem:
|
|
848
896
|
|
|
849
|
-
|
|
897
|
+
```ruby
|
|
898
|
+
gem "patient_http-sidekiq"
|
|
899
|
+
# or
|
|
900
|
+
gem "patient_http-solid_queue"
|
|
901
|
+
```
|
|
850
902
|
|
|
851
|
-
|
|
903
|
+
To use this gem on its own, add it to your Gemfile:
|
|
852
904
|
|
|
853
905
|
```ruby
|
|
854
906
|
gem "patient_http"
|
|
855
907
|
```
|
|
856
908
|
|
|
857
|
-
Then
|
|
909
|
+
Then install it:
|
|
858
910
|
|
|
859
911
|
```bash
|
|
860
912
|
bundle install
|
|
@@ -864,13 +916,17 @@ bundle install
|
|
|
864
916
|
|
|
865
917
|
Open a pull request on [GitHub](https://github.com/bdurand/patient_http).
|
|
866
918
|
|
|
867
|
-
|
|
919
|
+
Follow the [standardrb](https://github.com/testdouble/standard) style, and run `standardrb --fix` before you submit a pull request.
|
|
920
|
+
|
|
921
|
+
Run the tests:
|
|
868
922
|
|
|
869
|
-
|
|
923
|
+
```bash
|
|
924
|
+
bundle exec rspec
|
|
925
|
+
```
|
|
870
926
|
|
|
871
|
-
|
|
927
|
+
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.
|
|
872
928
|
|
|
873
|
-
## Further
|
|
929
|
+
## Further reading
|
|
874
930
|
|
|
875
931
|
- [Architecture](ARCHITECTURE.md)
|
|
876
932
|
|