patient_http-sidekiq 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/ARCHITECTURE.md +496 -0
- data/CHANGELOG.md +16 -0
- data/MIT-LICENSE +20 -0
- data/README.md +620 -0
- data/VERSION +1 -0
- data/lib/patient_http/sidekiq/callback_worker.rb +96 -0
- data/lib/patient_http/sidekiq/configuration.rb +175 -0
- data/lib/patient_http/sidekiq/context.rb +61 -0
- data/lib/patient_http/sidekiq/lifecycle_hooks.rb +42 -0
- data/lib/patient_http/sidekiq/processor_observer.rb +49 -0
- data/lib/patient_http/sidekiq/request_executor.rb +104 -0
- data/lib/patient_http/sidekiq/request_worker.rb +57 -0
- data/lib/patient_http/sidekiq/stats.rb +119 -0
- data/lib/patient_http/sidekiq/task_handler.rb +81 -0
- data/lib/patient_http/sidekiq/task_monitor.rb +542 -0
- data/lib/patient_http/sidekiq/task_monitor_thread.rb +154 -0
- data/lib/patient_http/sidekiq/web_ui/assets/patient-http/css/patient_http.css +249 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ar.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/cs.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/da.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/de.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/el.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/en.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/es.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/fa.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/fr.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/gd.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/he.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/hi.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/it.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ja.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ko.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/lt.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/nb.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/nl.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/pl.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/pt-BR.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/pt.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ru.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/sv.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ta.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/tr.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/uk.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/ur.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/vi.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/zh-CN.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/locales/zh-TW.yml +26 -0
- data/lib/patient_http/sidekiq/web_ui/views/patient_http.html.erb +142 -0
- data/lib/patient_http/sidekiq/web_ui.rb +69 -0
- data/lib/patient_http/sidekiq.rb +328 -0
- data/lib/patient_http-sidekiq.rb +3 -0
- data/patient_http-sidekiq.gemspec +46 -0
- metadata +140 -0
data/README.md
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
# PatientHttp::Sidekiq
|
|
2
|
+
|
|
3
|
+
[](https://github.com/bdurand/patient_http-sidekiq/actions/workflows/continuous_integration.yml)
|
|
4
|
+
[](https://github.com/testdouble/standard)
|
|
5
|
+
[](https://badge.fury.io/rb/patient_http-sidekiq)
|
|
6
|
+
|
|
7
|
+
*Built for APIs that like to think.*
|
|
8
|
+
|
|
9
|
+
This gem provides a mechanism to offload HTTP requests to a dedicated async I/O processor running in your Sidekiq process using the [patient_http gem](https://github.com/bdurand/patient_http). Worker threads are freed immediately while HTTP requests are in flight so that they can do other work instead of waiting for HTTP responses.
|
|
10
|
+
|
|
11
|
+
## Motivation
|
|
12
|
+
|
|
13
|
+
Sidekiq is designed with the assumption that jobs are short-lived and complete quickly. Long-running HTTP requests block worker threads from processing other jobs, leading to increased latency and reduced throughput. This is particularly problematic when calling LLM or AI APIs, where requests can take many seconds to complete.
|
|
14
|
+
|
|
15
|
+
**The Problem:**
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
┌────────────────────────────────────────────────────────────────────────┐
|
|
19
|
+
│ Traditional Sidekiq Job │
|
|
20
|
+
│ │
|
|
21
|
+
│ Worker Thread 1: [████████████ HTTP Request (5s) ████████████████] │
|
|
22
|
+
│ Worker Thread 2: [████████████ HTTP Request (5s) ████████████████] │
|
|
23
|
+
│ Worker Thread 3: [████████████ HTTP Request (5s) ████████████████] │
|
|
24
|
+
│ │
|
|
25
|
+
│ → 3 workers blocked for 5 seconds = 0 jobs processed │
|
|
26
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**The Solution:**
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
┌────────────────────────────────────────────────────────────────────────┐
|
|
33
|
+
│ With Async HTTP Processor │
|
|
34
|
+
│ │
|
|
35
|
+
│ Worker Thread 1: [█ Enqueue █][█ Job █][█ Job █][█ Job █][█ Job █] │
|
|
36
|
+
│ Worker Thread 2: [█ Enqueue █][█ Job █][█ Job █][█ Job █][█ Job █] │
|
|
37
|
+
│ Worker Thread 3: [█ Enqueue █][█ Job █][█ Job █][█ Job █][█ Job █] │
|
|
38
|
+
│ │
|
|
39
|
+
│ Async Processor: [═══════════ 100+ concurrent HTTP requests ════════] │
|
|
40
|
+
│ │
|
|
41
|
+
│ → Workers immediately free = dozens of jobs processed │
|
|
42
|
+
└────────────────────────────────────────────────────────────────────────┘
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The async processor runs in a dedicated thread within your Sidekiq process, using Ruby's Fiber-based concurrency to handle hundreds of concurrent HTTP requests without blocking. When an HTTP request completes, a callback service is invoked for processing.
|
|
46
|
+
|
|
47
|
+
## Quick Start
|
|
48
|
+
|
|
49
|
+
### 1. Create a Callback Service
|
|
50
|
+
|
|
51
|
+
Define a callback service class with `on_complete` and `on_error` methods:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
class FetchDataCallback
|
|
55
|
+
def on_complete(response)
|
|
56
|
+
user_id = response.callback_args[:user_id]
|
|
57
|
+
if response.success?
|
|
58
|
+
data = response.json
|
|
59
|
+
User.find(user_id).update!(external_data: data)
|
|
60
|
+
else
|
|
61
|
+
Rails.logger.error("HTTP #{response.status} fetching data for user #{user_id}")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def on_error(error)
|
|
66
|
+
user_id = error.callback_args[:user_id]
|
|
67
|
+
Rails.logger.error("Failed to fetch data for user #{user_id}: #{error.message}")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### 2. Make HTTP Requests
|
|
73
|
+
|
|
74
|
+
Make HTTP requests from anywhere in your code using `PatientHttp`:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
PatientHttp.get(
|
|
78
|
+
"https://api.example.com/users/#{user_id}",
|
|
79
|
+
headers: {"Authorization" => "Bearer #{ENV['API_KEY']}"},
|
|
80
|
+
callback: FetchDataCallback,
|
|
81
|
+
callback_args: {user_id: user_id}
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 3. That's It!
|
|
86
|
+
|
|
87
|
+
The request will be enqueued as a Sidekiq job and passed to a [PatientHttp](https://github.com/bdurand/patient_http) processor to execute asynchronously. When the HTTP request completes, your callback's `on_complete` method is executed in another Sidekiq job.
|
|
88
|
+
|
|
89
|
+
If an error occurs during the request, the `on_error` method is called instead.
|
|
90
|
+
|
|
91
|
+
You can also call `PatientHttp.post`, `PatientHttp.put`, `PatientHttp.patch`, and `PatientHttp.delete` for other HTTP methods. See the [patient_http docs](https://github.com/bdurand/patient_http) for the full API reference.
|
|
92
|
+
|
|
93
|
+
The `response.callback_args` and `error.callback_args` provide access to the arguments you passed via the `callback_args` option.
|
|
94
|
+
|
|
95
|
+
> [!IMPORTANT]
|
|
96
|
+
> Do not re-raise errors in the `on_error` callback as a means to retry the request. That will just retry the error callback job. If you want to retry the original request, you can enqueue a new request from within `on_error`. Be careful with this approach, though, as it can lead to infinite retry loops if the error condition is not resolved.
|
|
97
|
+
>
|
|
98
|
+
> Also note that the error callback is only called when an exception occurs during the HTTP request (timeout, connection failure, etc). HTTP error status codes (4xx, 5xx) do not trigger the error callback by default. Instead, they are treated as completed requests and passed to the `on_complete` callback. See the "Handling HTTP Error Responses" section below for how to treat HTTP errors as exceptions.
|
|
99
|
+
|
|
100
|
+
### Handling HTTP Error Responses
|
|
101
|
+
|
|
102
|
+
By default, HTTP error status codes (4xx, 5xx) are treated as successful responses and passed to the `on_complete` callback. You can check the status using `response.success?`, `response.client_error?`, or `response.server_error?`:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class ApiCallback
|
|
106
|
+
def on_complete(response)
|
|
107
|
+
if response.success?
|
|
108
|
+
process_data(response.json)
|
|
109
|
+
elsif response.client_error?
|
|
110
|
+
handle_client_error(response.status, response.body)
|
|
111
|
+
elsif response.server_error?
|
|
112
|
+
handle_server_error(response.status, response.body)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def on_error(error)
|
|
117
|
+
Rails.logger.error("Request failed: #{error.message}")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
PatientHttp.get(
|
|
122
|
+
"https://api.example.com/data/#{id}",
|
|
123
|
+
callback: ApiCallback
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
If you prefer to treat HTTP errors as exceptions, you can use the `raise_error_responses` option. When enabled, non-2xx responses will call the `on_error` callback with an `HttpError` instead:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class ApiCallback
|
|
131
|
+
def on_complete(response)
|
|
132
|
+
# Only called for 2xx responses
|
|
133
|
+
process_data(response.json)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def on_error(error)
|
|
137
|
+
# Called for exceptions AND HTTP errors when using raise_error_responses
|
|
138
|
+
if error.is_a?(PatientHttp::HttpError)
|
|
139
|
+
# Access the response via error.response
|
|
140
|
+
Rails.logger.error("HTTP #{error.status} from #{error.url}: #{error.response.body}")
|
|
141
|
+
else
|
|
142
|
+
# Regular request errors (timeout, connection, etc)
|
|
143
|
+
Rails.logger.error("Request failed: #{error.message}")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
PatientHttp.get(
|
|
149
|
+
"https://api.example.com/data/#{id}",
|
|
150
|
+
callback: ApiCallback,
|
|
151
|
+
raise_error_responses: true
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The `HttpError` provides convenient access to the response:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
def on_error(error)
|
|
159
|
+
if error.is_a?(PatientHttp::HttpError)
|
|
160
|
+
puts error.status # HTTP status code
|
|
161
|
+
puts error.url # Request URL
|
|
162
|
+
puts error.http_method # HTTP method
|
|
163
|
+
puts error.response.body # Response body
|
|
164
|
+
puts error.response.headers # Response headers
|
|
165
|
+
puts error.response.json # Parse JSON response (if applicable)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Usage Patterns
|
|
171
|
+
|
|
172
|
+
### Making Requests
|
|
173
|
+
|
|
174
|
+
The primary interface for making requests is through the `PatientHttp` module, which provides convenience methods for all HTTP verbs:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
# GET request
|
|
178
|
+
PatientHttp.get("https://api.example.com/users/123",
|
|
179
|
+
callback: MyCallback, callback_args: {user_id: 123})
|
|
180
|
+
|
|
181
|
+
# POST request with JSON body
|
|
182
|
+
PatientHttp.post("https://api.example.com/users",
|
|
183
|
+
json: {name: "John", email: "john@example.com"},
|
|
184
|
+
callback: MyCallback)
|
|
185
|
+
|
|
186
|
+
# PUT request
|
|
187
|
+
PatientHttp.put("https://api.example.com/users/123",
|
|
188
|
+
json: {name: "Updated Name"},
|
|
189
|
+
callback: MyCallback)
|
|
190
|
+
|
|
191
|
+
# PATCH request
|
|
192
|
+
PatientHttp.patch("https://api.example.com/users/123",
|
|
193
|
+
json: {status: "active"},
|
|
194
|
+
callback: MyCallback)
|
|
195
|
+
|
|
196
|
+
# DELETE request
|
|
197
|
+
PatientHttp.delete("https://api.example.com/users/123",
|
|
198
|
+
callback: MyCallback)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Available request options:
|
|
202
|
+
|
|
203
|
+
- `callback:` - (required) Callback service class or class name
|
|
204
|
+
- `callback_args:` - Hash of arguments passed to callback via response/error
|
|
205
|
+
- `headers:` - Request headers
|
|
206
|
+
- `body:` - Request body (for POST/PUT/PATCH)
|
|
207
|
+
- `json:` - Object to serialize as JSON body (cannot use with body)
|
|
208
|
+
- `params:` - Query parameters to append to URL
|
|
209
|
+
- `timeout:` - Request timeout in seconds
|
|
210
|
+
- `raise_error_responses:` - Treat non-2xx responses as errors
|
|
211
|
+
|
|
212
|
+
You can also build a `PatientHttp::Request` object and pass it to `PatientHttp.execute` for more control:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
request = PatientHttp::Request.new(:get, "https://api.example.com/users/123",
|
|
216
|
+
headers: {"Authorization" => "Bearer token"},
|
|
217
|
+
params: {include: "profile"},
|
|
218
|
+
timeout: 30
|
|
219
|
+
)
|
|
220
|
+
PatientHttp.execute(request: request, callback: MyCallback, callback_args: {user_id: 123})
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
See the [patient_http docs](https://github.com/bdurand/patient_http) for the full `Request` and `Response` API reference.
|
|
224
|
+
|
|
225
|
+
### Using Request Templates
|
|
226
|
+
|
|
227
|
+
For repeated requests to the same API, use `PatientHttp::RequestTemplate` to share configuration:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
class ApiService
|
|
231
|
+
def initialize
|
|
232
|
+
@template = PatientHttp::RequestTemplate.new(
|
|
233
|
+
base_url: "https://api.example.com",
|
|
234
|
+
headers: {"Authorization" => "Bearer #{ENV['API_KEY']}"},
|
|
235
|
+
timeout: 60
|
|
236
|
+
)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def fetch_user(user_id)
|
|
240
|
+
request = @template.get("/users/#{user_id}")
|
|
241
|
+
PatientHttp.execute(
|
|
242
|
+
request: request,
|
|
243
|
+
callback: FetchUserCallback,
|
|
244
|
+
callback_args: {user_id: user_id}
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def update_user(user_id, attributes)
|
|
249
|
+
request = @template.patch("/users/#{user_id}", json: attributes)
|
|
250
|
+
PatientHttp.execute(
|
|
251
|
+
request: request,
|
|
252
|
+
callback: UpdateUserCallback,
|
|
253
|
+
callback_args: {user_id: user_id}
|
|
254
|
+
)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Using the RequestHelper Module
|
|
260
|
+
|
|
261
|
+
For classes that make many async HTTP requests, you can include `PatientHttp::RequestHelper` to get convenient instance methods like `async_get`, `async_post`, `async_put`, `async_patch`, and `async_delete`. You can also define a request template at the class level using the `request_template` class method to set shared options like `base_url`, `headers`, and `timeout`.
|
|
262
|
+
|
|
263
|
+
When using this gem, the request handler is automatically registered when the processor starts and unregistered when it stops — no manual setup is required.
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class NotificationService
|
|
267
|
+
include PatientHttp::RequestHelper
|
|
268
|
+
|
|
269
|
+
request_template base_url: "https://api.example.com",
|
|
270
|
+
headers: {"Authorization" => "Bearer #{ENV['API_KEY']}"},
|
|
271
|
+
timeout: 30
|
|
272
|
+
|
|
273
|
+
def notify_user(user_id, message)
|
|
274
|
+
async_post("/notifications",
|
|
275
|
+
json: {user_id: user_id, message: message},
|
|
276
|
+
callback: NotificationCallback,
|
|
277
|
+
callback_args: {user_id: user_id}
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def fetch_user(user_id)
|
|
282
|
+
async_get("/users/#{user_id}",
|
|
283
|
+
callback: FetchUserCallback,
|
|
284
|
+
callback_args: {user_id: user_id}
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The `async_*` methods accept the same options as `PatientHttp.get`, `PatientHttp.post`, etc. Paths are resolved relative to the `base_url` defined in the request template.
|
|
291
|
+
|
|
292
|
+
See the [patient_http gem](https://github.com/bdurand/patient_http) for the full `RequestHelper` documentation.
|
|
293
|
+
|
|
294
|
+
### Callback Arguments
|
|
295
|
+
|
|
296
|
+
Pass custom data to your callbacks using the `callback_args` option:
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
class FetchDataCallback
|
|
300
|
+
def on_complete(response)
|
|
301
|
+
# Access callback_args using symbol or string keys
|
|
302
|
+
user_id = response.callback_args[:user_id]
|
|
303
|
+
request_timestamp = response.callback_args[:request_timestamp]
|
|
304
|
+
|
|
305
|
+
User.find(user_id).update!(
|
|
306
|
+
external_data: response.json,
|
|
307
|
+
fetched_at: request_timestamp
|
|
308
|
+
)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def on_error(error)
|
|
312
|
+
user_id = error.callback_args[:user_id]
|
|
313
|
+
request_timestamp = error.callback_args[:request_timestamp]
|
|
314
|
+
|
|
315
|
+
Rails.logger.error(
|
|
316
|
+
"Failed to fetch data for user #{user_id} at #{request_timestamp}: #{error.message}"
|
|
317
|
+
)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Pass data via callback_args option
|
|
322
|
+
PatientHttp.get(
|
|
323
|
+
"https://api.example.com/users/#{user_id}",
|
|
324
|
+
callback: FetchDataCallback,
|
|
325
|
+
callback_args: {
|
|
326
|
+
user_id: user_id,
|
|
327
|
+
request_timestamp: Time.now.iso8601
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**Important details about callback_args:**
|
|
333
|
+
|
|
334
|
+
- Must be a Hash (or respond to `to_h`) containing only JSON-native types: `nil`, `true`, `false`, `String`, `Integer`, `Float`, `Array`, or `Hash`
|
|
335
|
+
- Hash keys will be converted to strings for serialization
|
|
336
|
+
- Nested hashes and hashes in arrays also have their keys converted to strings
|
|
337
|
+
- You can access callback_args using either symbol or string keys: `callback_args[:user_id]` or `callback_args["user_id"]`
|
|
338
|
+
|
|
339
|
+
### Sensitive Data Handling
|
|
340
|
+
|
|
341
|
+
Requests and responses from asynchronous HTTP requests will be pushed to Redis in order to call the completion job. This can raise security concerns if they contain sensitive data since the data will be stored in plain text.
|
|
342
|
+
|
|
343
|
+
You can configure encryption so that all request and response data is automatically encrypted before being stored in Sidekiq and decrypted when retrieved.
|
|
344
|
+
|
|
345
|
+
#### Using an encryption key
|
|
346
|
+
|
|
347
|
+
The simplest option is `encryption_key=`, which sets up [ActiveSupport::MessageEncryptor](https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) using AES-256-GCM:
|
|
348
|
+
|
|
349
|
+
```ruby
|
|
350
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
351
|
+
config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
|
|
352
|
+
end
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Pass an array to support key rotation (first key encrypts, all keys attempt decryption):
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
359
|
+
config.encryption_key = [ENV["PATIENT_HTTP_ENCRYPTION_KEY"], ENV["PATIENT_HTTP_OLD_KEY"]]
|
|
360
|
+
end
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
#### Using custom callables
|
|
364
|
+
|
|
365
|
+
For custom encryption libraries, provide callables that accept and return raw bytes (String):
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
369
|
+
config.encryption { |bytes| MyEncryption.encrypt(bytes) }
|
|
370
|
+
config.decryption { |bytes| MyEncryption.decrypt(bytes) }
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
You can also pass any object that responds to `call`:
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
378
|
+
config.encryption(->(bytes) { MyEncryption.encrypt(bytes) })
|
|
379
|
+
config.decryption(->(bytes) { MyEncryption.decrypt(bytes) })
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
## Configuration
|
|
384
|
+
|
|
385
|
+
The gem can be configured globally in an initializer:
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
389
|
+
# Maximum concurrent HTTP requests (default: 256)
|
|
390
|
+
config.max_connections = 256
|
|
391
|
+
|
|
392
|
+
# Default timeout for HTTP requests in seconds (default: 60)
|
|
393
|
+
config.request_timeout = 60
|
|
394
|
+
|
|
395
|
+
# Maximum number of host clients to pool (default: 100)
|
|
396
|
+
config.connection_pool_size = 100
|
|
397
|
+
|
|
398
|
+
# Connection timeout in seconds (default: nil, uses request_timeout)
|
|
399
|
+
config.connection_timeout = 10
|
|
400
|
+
|
|
401
|
+
# Number of retries for failed requests (default: 3)
|
|
402
|
+
config.retries = 3
|
|
403
|
+
|
|
404
|
+
# HTTP/HTTPS proxy URL (default: nil)
|
|
405
|
+
# Supports authentication: "http://user:pass@proxy.example.com:8080"
|
|
406
|
+
config.proxy_url = "http://proxy.example.com:8080"
|
|
407
|
+
|
|
408
|
+
# Default User-Agent header for all requests (default: "PatientHttp")
|
|
409
|
+
config.user_agent = "MyApp/1.0"
|
|
410
|
+
|
|
411
|
+
# Timeout for graceful shutdown in seconds (default: the Sidekiq
|
|
412
|
+
# shutdown timeout minus 2 seconds). This should be less than Sidekiq's
|
|
413
|
+
# shutdown timeout.
|
|
414
|
+
config.shutdown_timeout = 23
|
|
415
|
+
|
|
416
|
+
# Maximum response body size in bytes (default: 1MB)
|
|
417
|
+
# Responses larger than this will trigger ResponseTooLargeError
|
|
418
|
+
config.max_response_size = 1024 * 1024
|
|
419
|
+
|
|
420
|
+
# Maximum number of redirects to follow (default: 5, 0 disables)
|
|
421
|
+
config.max_redirects = 5
|
|
422
|
+
|
|
423
|
+
# Whether to raise HttpError for non-2xx responses by default (default: false)
|
|
424
|
+
config.raise_error_responses = false
|
|
425
|
+
|
|
426
|
+
# Heartbeat interval for crash recovery in seconds (default: 60)
|
|
427
|
+
config.heartbeat_interval = 60
|
|
428
|
+
|
|
429
|
+
# Orphan detection threshold in seconds (default: 300)
|
|
430
|
+
# Requests older than this without a heartbeat will be re-enqueued
|
|
431
|
+
config.orphan_threshold = 300
|
|
432
|
+
|
|
433
|
+
# Size threshold in bytes for external payload storage (default: 64KB)
|
|
434
|
+
# Payloads larger than this will be stored externally when a payload
|
|
435
|
+
# store is configured.
|
|
436
|
+
config.payload_store_threshold = 64 * 1024
|
|
437
|
+
|
|
438
|
+
# Sidekiq options for RequestWorker and CallbackWorker
|
|
439
|
+
config.sidekiq_options = {queue: "patient_http", retry: 5}
|
|
440
|
+
|
|
441
|
+
# Handler called when a callback job exhausts all Sidekiq retries
|
|
442
|
+
config.on_retries_exhausted { |error| MyAlertService.notify(error) }
|
|
443
|
+
|
|
444
|
+
# Custom logger (defaults to Sidekiq.logger)
|
|
445
|
+
config.logger = Rails.logger
|
|
446
|
+
|
|
447
|
+
# Encryption for sensitive data (see Sensitive Data Handling)
|
|
448
|
+
config.encryption_key = ENV["PATIENT_HTTP_ENCRYPTION_KEY"]
|
|
449
|
+
end
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
See the [Configuration](lib/patient_http/sidekiq/configuration.rb) class for all available options.
|
|
453
|
+
|
|
454
|
+
### Tuning Tips
|
|
455
|
+
|
|
456
|
+
- `max_connections`: Adjust this based on your system's resources. Each connection uses memory and file descriptors. A tuned system with sufficient resources can handle thousands of concurrent connections.
|
|
457
|
+
- `request_timeout`: Set this based on the expected response times of the APIs you are calling. AI APIs might sometimes take minutes to respond as they generate content.
|
|
458
|
+
- `connection_pool_size`: Controls how many connections to different hosts are kept alive. Increase for applications calling many different API endpoints.
|
|
459
|
+
- `connection_timeout`: Set this if you need to fail fast on connection establishment. Useful for detecting network issues quickly.
|
|
460
|
+
- `retries`: Number of times to retry a failed request before calling the error callback.
|
|
461
|
+
- `max_response_size`: Set this to limit the maximum size of HTTP responses. This helps prevent excessive memory usage from unexpectedly large responses. Responses need to be serialized to Redis as Sidekiq jobs and very large responses may cause performance issues in Redis. If a response body is text content, it will be compressed to save space in Redis. However, binary content needs to be Base64 encoded which increases size by ~33%.
|
|
462
|
+
|
|
463
|
+
> [!IMPORTANT]
|
|
464
|
+
>
|
|
465
|
+
> One difference between using this gem and making synchronous HTTP requests from a Sidekiq job is that if `max_connections` is reached due to slow asynchronous requests, new requests will trigger an error on the Sidekiq Job. The Sidekiq retry mechanism will handle re-enqueuing the job.
|
|
466
|
+
>
|
|
467
|
+
> In contrast, slow synchronous HTTP requests will fill up the Sidekiq worker pool and block new jobs from being dequeued until a worker thread becomes free.
|
|
468
|
+
>
|
|
469
|
+
> In general, the former behavior is preferable because it allows Sidekiq to continue processing other jobs and prevents getting into a state with 1000's of jobs stuck in the queue.
|
|
470
|
+
|
|
471
|
+
## Metrics and Monitoring
|
|
472
|
+
|
|
473
|
+
### Web UI
|
|
474
|
+
|
|
475
|
+
If you're using Sidekiq's Web UI, you can add a tab with the async HTTP processor statistics. The extension auto-registers when `Sidekiq::Web` is defined:
|
|
476
|
+
|
|
477
|
+
```ruby
|
|
478
|
+
# config/routes.rb (Rails)
|
|
479
|
+
require "sidekiq/web"
|
|
480
|
+
require "patient_http-sidekiq"
|
|
481
|
+
|
|
482
|
+
mount Sidekiq::Web => "/sidekiq"
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
The Web UI shows:
|
|
486
|
+
- Total requests, errors, and average duration
|
|
487
|
+
- Current capacity utilization
|
|
488
|
+
- Per-process inflight request counts
|
|
489
|
+
|
|
490
|
+
### Callbacks for Custom Monitoring
|
|
491
|
+
|
|
492
|
+
You can register callbacks to integrate with your monitoring system using the `after_completion` and `after_error` hooks:
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
PatientHttp::Sidekiq.after_completion do |response|
|
|
496
|
+
StatsD.timing("patient_http.duration", response.duration * 1000)
|
|
497
|
+
StatsD.increment("patient_http.status.#{response.status}")
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
PatientHttp::Sidekiq.after_error do |error|
|
|
501
|
+
StatsD.increment("patient_http.error.#{error.error_type}")
|
|
502
|
+
Sentry.capture_message("Async HTTP error: #{error.message}")
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
You can register multiple callbacks; they will be called in the order registered.
|
|
507
|
+
|
|
508
|
+
### Handling Exhausted Retries
|
|
509
|
+
|
|
510
|
+
When a callback worker job exhausts all of its Sidekiq retries, you can configure an `on_retries_exhausted` handler to be notified. This is useful for alerting or recording when a callback has permanently failed. The handler receives the same error object as the `on_error` callback:
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
514
|
+
config.on_retries_exhausted do |error|
|
|
515
|
+
Sentry.capture_message("Callback permanently failed: #{error.message}")
|
|
516
|
+
DeadLetterRecord.create!(
|
|
517
|
+
error_message: error.message,
|
|
518
|
+
callback_args: error.callback_args
|
|
519
|
+
)
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
You can also assign any object that responds to `call`:
|
|
525
|
+
|
|
526
|
+
```ruby
|
|
527
|
+
PatientHttp::Sidekiq.configure do |config|
|
|
528
|
+
config.on_retries_exhausted = ->(error) { MyAlertService.notify(error) }
|
|
529
|
+
end
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
> [!NOTE]
|
|
533
|
+
> The `on_retries_exhausted` handler is only invoked for jobs with an "error" result type. If the handler itself raises an exception, the error is logged as a warning and does not affect the normal dead job cleanup.
|
|
534
|
+
|
|
535
|
+
## Shutdown Behavior
|
|
536
|
+
|
|
537
|
+
The async HTTP processor automatically hooks in with Sidekiq's lifecycle events.
|
|
538
|
+
|
|
539
|
+
1. **Startup:** Processor starts automatically when Sidekiq starts
|
|
540
|
+
2. **Quiet (TSTP signal):** Processor stops accepting new requests but continues processing in-flight requests
|
|
541
|
+
3. **Shutdown:** Processor waits up to `shutdown_timeout` seconds for in-flight requests to complete
|
|
542
|
+
|
|
543
|
+
### Incomplete Request Handling
|
|
544
|
+
|
|
545
|
+
If requests are still in-flight when shutdown times out:
|
|
546
|
+
|
|
547
|
+
- In-flight requests are interrupted
|
|
548
|
+
- The **original Sidekiq job** is automatically re-enqueued
|
|
549
|
+
- Re-enqueued jobs will be processed again when Sidekiq restarts
|
|
550
|
+
|
|
551
|
+
This ensures no work is lost during deployments or restarts.
|
|
552
|
+
|
|
553
|
+
### Crash Recovery
|
|
554
|
+
|
|
555
|
+
The gem includes crash recovery to handle process failures:
|
|
556
|
+
|
|
557
|
+
1. **Heartbeat Tracking:** Every `heartbeat_interval` seconds, the processor updates heartbeat timestamps for all in-flight requests in Redis
|
|
558
|
+
2. **Orphan Detection:** One processor periodically checks for requests that haven't received a heartbeat update in `orphan_threshold` seconds
|
|
559
|
+
3. **Automatic Re-enqueue:** Orphaned requests have their original Sidekiq jobs re-enqueued
|
|
560
|
+
|
|
561
|
+
This ensures that if a Sidekiq process crashes, its in-flight requests will be retried by another process.
|
|
562
|
+
|
|
563
|
+
## Testing
|
|
564
|
+
|
|
565
|
+
The gem supports `Sidekiq::Testing.inline!` mode for synchronous testing. When in inline mode, async HTTP requests are executed immediately within the worker thread, blocking until completion. This allows you to write tests that verify the full request/response cycle without needing the async processor to be running.
|
|
566
|
+
|
|
567
|
+
## Installation
|
|
568
|
+
|
|
569
|
+
Add this line to your application's Gemfile:
|
|
570
|
+
|
|
571
|
+
```ruby
|
|
572
|
+
gem "patient_http-sidekiq"
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
Then execute:
|
|
576
|
+
|
|
577
|
+
```bash
|
|
578
|
+
bundle install
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
## Contributing
|
|
582
|
+
|
|
583
|
+
Open a pull request on [GitHub](https://github.com/bdurand/patient_http-sidekiq).
|
|
584
|
+
|
|
585
|
+
Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
|
|
586
|
+
|
|
587
|
+
Running the tests requires a Redis compatible server. There is a script to start one in a local container running on port 24455:
|
|
588
|
+
|
|
589
|
+
```bash
|
|
590
|
+
bin/run-valkey
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Then run the test suite with:
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
bundle exec rake
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
There is also a bundled test app in the `test_app` directory that can be used for manual testing and experimentation.
|
|
600
|
+
|
|
601
|
+
To run the test app, first install the dependencies:
|
|
602
|
+
|
|
603
|
+
```bash
|
|
604
|
+
bundle exec rake test_app:bundle
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
The server will run on http://localhost:9292 and can be started with:
|
|
608
|
+
|
|
609
|
+
```bash
|
|
610
|
+
bundle exec rake test_app
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
## Further Reading
|
|
614
|
+
|
|
615
|
+
- [Architecture](ARCHITECTURE.md)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
## License
|
|
619
|
+
|
|
620
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|