patient_http 1.4.0 → 1.6.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 +19 -7
- data/CHANGELOG.md +43 -0
- data/README.md +68 -7
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +27 -2
- data/lib/patient_http/client_pool.rb +14 -7
- data/lib/patient_http/completion_executor.rb +139 -0
- data/lib/patient_http/configuration.rb +79 -1
- data/lib/patient_http/outgoing_request.rb +1 -1
- data/lib/patient_http/payload.rb +37 -15
- data/lib/patient_http/processor.rb +285 -95
- data/lib/patient_http/processor_observer.rb +36 -3
- data/lib/patient_http/redirect_helper.rb +84 -1
- data/lib/patient_http/request.rb +64 -6
- data/lib/patient_http/request_helper.rb +63 -10
- data/lib/patient_http/request_preparer.rb +7 -0
- data/lib/patient_http/request_task.rb +26 -12
- data/lib/patient_http/request_template.rb +46 -4
- data/lib/patient_http/response_reader.rb +233 -19
- data/lib/patient_http/synchronous_executor.rb +12 -71
- data/lib/patient_http.rb +49 -5
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 54ede397ae79574e7dd597ea642b4d039877d5d642ba49c68b76219b8b1c4a32
|
|
4
|
+
data.tar.gz: 83bb067b78912753791e6002abb3470ca70936f5fe862fc65ee65d7786e1ac53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0302b8cdf7042fa545e49ab1ce565d5f7d1fd388a8413bc8a19249477d46d2fc9f732142d75afbb6cc52d0dd7a228e01c6f0c1cbaea2f7b151568472ccfaecb4
|
|
7
|
+
data.tar.gz: f418c2d7b5b48ba81ac73de35533143d7e99c75fb108a619f9cf9a55ca204f56f42ffec86ee4fc6d2796730703aad488014955603f87fc94ccf3e66d6b7b8593
|
data/ARCHITECTURE.md
CHANGED
|
@@ -54,7 +54,16 @@ The `TaskHandler` abstract class defines how the processor communicates results
|
|
|
54
54
|
- **on_error(error, callback)**: Called when an HTTP request fails. Your implementation should enqueue the error for handling.
|
|
55
55
|
- **retry**: Called when the processor shuts down with in-flight requests. Your implementation should re-enqueue the original job.
|
|
56
56
|
|
|
57
|
-
> **Important:** TaskHandler callbacks run on the processor's reactor thread. They
|
|
57
|
+
> **Important:** TaskHandler callbacks run on the processor's completion worker threads (see `completion_threads`), not the reactor thread, so they no longer block the event loop. They can still slow the processor down by two routes, so keep them fast -- typically just enqueuing a message for another system to pick up:
|
|
58
|
+
>
|
|
59
|
+
> - Callbacks compete with the reactor thread for the GVL, so heavy CPU work in a callback can still add latency to in-flight requests.
|
|
60
|
+
> - A task stays in the capacity count until its result is delivered, so callbacks that back up consume request capacity and eventually make `enqueue` raise `MaxCapacityError`.
|
|
61
|
+
>
|
|
62
|
+
> **Callbacks must be thread-safe.** With the default `completion_threads` of 2, results are delivered concurrently, so two callbacks can run at the same time and in an order unrelated to the order the requests completed. Guard any state a handler shares between calls. Set `completion_threads: 1` to serialize delivery on a single worker thread.
|
|
63
|
+
>
|
|
64
|
+
> **Callbacks must be idempotent.** A failed delivery is retried `completion_retries` times (default 2), and each retry calls the callback again, so a callback that raises after enqueuing its message enqueues it more than once. Set `completion_retries: 0` if the callback cannot be made idempotent.
|
|
65
|
+
>
|
|
66
|
+
> If a callback keeps raising after the configured retries, the processor sends `completion_failed` to observers and does NOT send `request_end`, so durable tracking survives for external recovery.
|
|
58
67
|
|
|
59
68
|
Example:
|
|
60
69
|
```ruby
|
|
@@ -230,10 +239,12 @@ erDiagram
|
|
|
230
239
|
|
|
231
240
|
## Process Model
|
|
232
241
|
|
|
233
|
-
Each
|
|
234
|
-
-
|
|
235
|
-
- **One** async HTTP processor thread
|
|
242
|
+
Each `Processor` instance runs:
|
|
243
|
+
- **One** async HTTP processor (reactor) thread
|
|
236
244
|
- **One** fiber reactor within the processor thread
|
|
245
|
+
- A small pool of completion worker threads (`completion_threads`, default 2) that decode responses and deliver results
|
|
246
|
+
|
|
247
|
+
A process usually runs one processor, but `Processor` is fully instance-based: a process can run several named processors (`Processor.new(config, name: :llm)`), each with its own capacity, timeouts, and threads. Requests carry an optional `processor` name (serialized with the request) that integrations use for routing.
|
|
237
248
|
|
|
238
249
|
```
|
|
239
250
|
┌─────────────────────────────────────────────────────────────┐
|
|
@@ -266,9 +277,10 @@ Each application process can run:
|
|
|
266
277
|
The processor uses Ruby's Fiber scheduler (`async` gem) for non-blocking I/O:
|
|
267
278
|
|
|
268
279
|
1. **Application threads** remain free while HTTP requests execute
|
|
269
|
-
2. **Fiber reactor** multiplexes hundreds of HTTP connections
|
|
270
|
-
3. **Connection pooling** and HTTP/2 reuse connections efficiently
|
|
271
|
-
4. **
|
|
280
|
+
2. **Fiber reactor** multiplexes hundreds of HTTP connections and performs only socket I/O and light bookkeeping
|
|
281
|
+
3. **Connection pooling** and HTTP/2 reuse connections efficiently; `max_connections_per_host` bounds sockets per host
|
|
282
|
+
4. **Completion worker threads** decode response bodies (join, inflate, charset), build responses, and execute TaskHandler callbacks and `request_end` observers off the reactor. Decoding is CPU-bound and has no fiber yield point, so running it inline on the reactor would stop every other in-flight request until it finished. On a worker thread the Ruby scheduler can preempt it, and Zlib releases the GVL for part of the inflate
|
|
283
|
+
5. **Delivery is concurrent, not serialized.** Callbacks and completion-time observers run on any of the `completion_threads` workers, so they must be thread-safe. Completions are also bounded: a task stays in the capacity count until a worker claims its result, so the completion backlog can never exceed `max_connections`
|
|
272
284
|
|
|
273
285
|
## State Management
|
|
274
286
|
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## 1.6.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Configuration#follow_method_changing_redirects` and `Request#follow_method_changing_redirects` (default true): when false, a redirect that would change the HTTP method (such as POST to GET on a 301, 302, or 303) is not followed and the redirect response is delivered to the callback instead. Redirects that preserve the method are still followed. The request setting overrides the configuration.
|
|
12
|
+
- `Configuration#redirect_strip_headers` and `Request#redirect_strip_headers`: header names (matched case insensitively) that are always removed from redirected requests, so sensitive headers are never sent to a redirect target. Request-level names are applied in addition to the configured ones and survive serialization.
|
|
13
|
+
- `PatientHttp.request`, `RequestHelper#async_request`, and `RequestTemplate#request` (and their `get`, `post`, and other method helpers) accept `follow_method_changing_redirects:` and `redirect_strip_headers:` and pass them to the `Request`.
|
|
14
|
+
- `HEAD` and `QUERY` HTTP methods are supported by `Request`, `RequestTemplate`, `RequestHelper` (`async_head`, `async_query`), and `PatientHttp.head` / `PatientHttp.query`. `HEAD` requests cannot carry a body. The response size limit is not applied to the `Content-Length` of a `HEAD` response because no body is transferred.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- The HTTP method used when following a redirect now follows RFC 9110. On 301 and 302 responses only `POST` is changed to `GET`; `HEAD`, `PUT`, `PATCH`, `DELETE`, and `QUERY` are re-sent unchanged with their body. On 303 responses `GET` and `HEAD` are preserved and every other method becomes `GET`. Previously every method was changed to `GET` on 301, 302, and 303.
|
|
19
|
+
- When a redirect changes the method and drops the body, the `Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location` headers are removed from the redirected request as well.
|
|
20
|
+
- A 300 Multiple Choices response with a `Location` header is now followed, preserving the method and body. A 300 response without `Location` is delivered as a response, as before.
|
|
21
|
+
- `RequestTask#redirect_task` accepts a `strip_headers:` option with the configured header names to remove.
|
|
22
|
+
|
|
23
|
+
## 1.5.0
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- Completion executor: finished results are now delivered on a small pool of worker threads (`Configuration#completion_threads`, default 2, minimum 1) instead of the reactor thread. Response decoding, payload encoding, task-handler callbacks, and `request_end` observers all run on these threads, so the reactor only performs socket I/O and light bookkeeping.
|
|
28
|
+
- `Configuration#completion_retries` (default 2): delivery of a finished result is retried with a short backoff before the failure is reported. A retry calls `on_complete`/`on_error` again, so handlers must be idempotent; set `completion_retries: 0` to report the first failure without retrying.
|
|
29
|
+
- `ProcessorObserver#completion_failed(request_task, error)`: sent when a result could not be delivered after all retries. `request_end` is NOT sent in this case, so durable tracking (crash-recovery records) stays in place and the request can be recovered by an external process instead of being silently lost. This replaces the previous behavior where a delivery failure was logged, swallowed, and the tracking was torn down.
|
|
30
|
+
- `Configuration#max_connections_per_host` (default nil = unlimited): bounds the number of connections each host's HTTP client may open, which bounds file descriptor usage.
|
|
31
|
+
- `Processor#remaining_capacity` and `Processor#capacity_available?`: cheap, advisory capacity checks with no observer notifications, so integrations can reject work before paying registration costs.
|
|
32
|
+
- Named processors: `Processor.new(config, name:)` names a processor (used in its thread names), and `Request` accepts a `processor:` option that survives serialization (`as_json` / `load`), so integrations can route requests to one of several processors in the same process. `RequestTemplate`, `RequestHelper`, and `PatientHttp.request` pass the option through. `PatientHttp::UnknownProcessorError` is defined for handlers to raise for unrecognized names. Serialized output for requests without a processor name is unchanged.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Compressed response bodies (gzip/deflate) are now inflated on a completion worker thread instead of the reactor thread. The size limit still applies to the inflated bytes, so gzip-bomb protection is unchanged. The `content-encoding` header is removed from the response after decoding, as before.
|
|
37
|
+
- Requests send `accept-encoding: gzip` by default, but a request that sets the header keeps its own value. Set `accept-encoding: identity` on a request to opt out of compression, or name another encoding to receive the body still encoded. Previously the header was set unconditionally.
|
|
38
|
+
- Inline and synchronous execution decodes response bodies through the same reader as the async path instead of a `Protocol::HTTP::AcceptEncoding` middleware. The middleware overwrote the request's `accept-encoding` header, so a request opting out of compression was previously honored only when it ran on the processor.
|
|
39
|
+
- A `content-encoding` header naming more than one encoding is now decoded from the outermost encoding inward, and `identity` is recognized. Previously only a header holding exactly one supported name was decoded, so a value such as `gzip, identity` delivered the body still compressed.
|
|
40
|
+
- A response body carrying an encoding the reader cannot decode is delivered unchanged with a `content-encoding` header naming only the encodings still applied, and the condition is now logged as a warning. Such a body keeps its binary encoding, because the Content-Type charset does not describe encoded bytes.
|
|
41
|
+
- `ProcessorObserver` hooks no longer all run on the reactor thread; see the class documentation for the thread each hook runs on. `request_end` and `request_error` for completed requests now run on completion worker threads.
|
|
42
|
+
- **Breaking for callbacks:** `TaskHandler` callbacks and completion-time observer hooks must now be thread-safe. The reactor thread previously serialized them; they now run concurrently on `completion_threads` workers, in an order unrelated to the order the requests completed. Set `completion_threads: 1` to restore serialized delivery.
|
|
43
|
+
- On shutdown, results that were already handed off for delivery are delivered before remaining tasks are re-enqueued.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- A `deflate` response body carrying a zlib header, which is the format RFC 9110 specifies for that encoding, failed to inflate with `Zlib::DataError: invalid stored block lengths`. Both the zlib and the raw deflate wire formats are now supported.
|
|
48
|
+
- A response body that a text content type claims is text, but that does not hold text, is now stored as a binary payload instead of as text. Such a body could not be serialized, so `JSON.generate` raised `JSON::GeneratorError` and the result could never be delivered. This happened for a body still carrying a content encoding the reader cannot decode (for example `br`), and for text holding an invalid byte sequence.
|
|
49
|
+
|
|
7
50
|
## 1.4.0
|
|
8
51
|
|
|
9
52
|
### Added
|
data/README.md
CHANGED
|
@@ -34,8 +34,8 @@ class MyTaskHandler < PatientHttp::TaskHandler
|
|
|
34
34
|
|
|
35
35
|
def on_complete(response, callback)
|
|
36
36
|
# Enqueue a message for your application to process the response.
|
|
37
|
-
# Keep this lightweight --
|
|
38
|
-
#
|
|
37
|
+
# Keep this lightweight and thread-safe -- it runs on a completion
|
|
38
|
+
# worker thread, concurrently with other completions.
|
|
39
39
|
MyJobSystem.enqueue(callback, :on_complete, response.as_json)
|
|
40
40
|
end
|
|
41
41
|
|
|
@@ -51,7 +51,11 @@ class MyTaskHandler < PatientHttp::TaskHandler
|
|
|
51
51
|
end
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
> **Important:** TaskHandler callbacks run on the processor's reactor thread.
|
|
54
|
+
> **Important:** TaskHandler callbacks run on the processor's completion worker threads (see `completion_threads`), not the reactor thread, so they no longer block the event loop. Keep them lightweight anyway -- typically just enqueuing a message for another system to pick up. Heavy callbacks compete with the reactor for the GVL, and because a task stays in the capacity count until its result is delivered, callbacks that back up consume request capacity.
|
|
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.
|
|
55
59
|
|
|
56
60
|
### 2. Create and Enqueue Requests
|
|
57
61
|
|
|
@@ -159,7 +163,7 @@ get_request = template.get("/users/123")
|
|
|
159
163
|
post_request = template.post("/users", json: {name: "John"})
|
|
160
164
|
```
|
|
161
165
|
|
|
162
|
-
Templates support all HTTP methods (`get`, `post`, `put`, `patch`, `delete`) and handle URL joining, header merging, and query parameter encoding.
|
|
166
|
+
Templates support all HTTP methods (`get`, `head`, `post`, `put`, `patch`, `delete`, `query`) and handle URL joining, header merging, and query parameter encoding.
|
|
163
167
|
|
|
164
168
|
## Standard Interface
|
|
165
169
|
|
|
@@ -184,7 +188,7 @@ PatientHttp.register_handler do |request:, callback:, callback_args: nil, raise_
|
|
|
184
188
|
end
|
|
185
189
|
|
|
186
190
|
# Now you can make requests directly through the PatientHttp interface with the .request,
|
|
187
|
-
# .get, .post, .patch, .put, and .
|
|
191
|
+
# .get, .head, .post, .patch, .put, .delete, and .query class methods:
|
|
188
192
|
PatientHttp.get(
|
|
189
193
|
"https://api.example.com/users/123",
|
|
190
194
|
callback: FetchUserCallback,
|
|
@@ -225,7 +229,7 @@ Use `PatientHttp::RequestHelper` when you want a simple API for creating and dis
|
|
|
225
229
|
1. Register a request handler with `PatientHttp.register_handler` that defines how requests are dispatched to your job queue or background processing system.
|
|
226
230
|
2. Include `PatientHttp::RequestHelper` in your class.
|
|
227
231
|
3. Optionally define a `request_template` for shared `base_url`, headers, and timeout.
|
|
228
|
-
4. Call `async_get`, `async_post`, `async_put`, `async_patch`, `async_delete`, or `async_request`.
|
|
232
|
+
4. Call `async_get`, `async_head`, `async_post`, `async_put`, `async_patch`, `async_delete`, `async_query`, or `async_request`.
|
|
229
233
|
|
|
230
234
|
```ruby
|
|
231
235
|
class ApiClient
|
|
@@ -614,6 +618,49 @@ If a request references a preprocessor name that is not registered, a `PatientHt
|
|
|
614
618
|
|
|
615
619
|
When redirects are followed, preprocessors are re-run against each redirect URL so signatures stay valid. On cross-origin redirects they are dropped entirely, consistent with the stripping of `Authorization` and `Cookie` headers, so signed credentials are never sent to an unexpected origin.
|
|
616
620
|
|
|
621
|
+
## Redirects
|
|
622
|
+
|
|
623
|
+
Redirect responses (300, 301, 302, 303, 307, and 308) with a `Location` header are followed automatically, up to `max_redirects` hops. A 300 response is followed only when the server names a preferred choice in `Location`. Redirect loops raise `RecursiveRedirectError` and exceeding the limit raises `TooManyRedirectsError`. Any redirect that is not followed is delivered to the callback as a normal response.
|
|
624
|
+
|
|
625
|
+
The HTTP method of the redirected request follows RFC 9110:
|
|
626
|
+
|
|
627
|
+
| Status | Method |
|
|
628
|
+
| --- | --- |
|
|
629
|
+
| 301, 302 | `POST` becomes `GET` and the body is dropped. Other methods (including `HEAD`, `PUT`, `DELETE`, and `QUERY`) are preserved with their body. |
|
|
630
|
+
| 303 | `GET` and `HEAD` are preserved. Every other method becomes `GET` and the body is dropped. |
|
|
631
|
+
| 300, 307, 308 | The method and body are preserved. |
|
|
632
|
+
|
|
633
|
+
The QUERY specification states that the POST-to-GET exception on 301 and 302 does not apply to `QUERY`, so a redirected `QUERY` is re-sent as a `QUERY` with its body, and a 303 turns it into a `GET`.
|
|
634
|
+
|
|
635
|
+
### Preventing method changes
|
|
636
|
+
|
|
637
|
+
Set `follow_method_changing_redirects: false` to stop following redirects that would change the HTTP method. A `POST` that receives a 302 then completes with the 302 response instead of being retried as a `GET`. Redirects that preserve the method (a `PUT` on a 301, or any method on a 307) are still followed. The option can be set on the `Configuration` or on a single `Request`; the request value wins when both are set.
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
config = PatientHttp::Configuration.new(follow_method_changing_redirects: false)
|
|
641
|
+
|
|
642
|
+
# Or per request
|
|
643
|
+
request = PatientHttp::Request.new(:post, "https://api.example.com/submit", body: payload, follow_method_changing_redirects: false)
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
### Stripping headers on redirects
|
|
647
|
+
|
|
648
|
+
`Authorization` and `Cookie` headers are always removed on cross-origin redirects. To make sure other sensitive headers are never sent to a redirect target, list them in `redirect_strip_headers`. Header names are matched case insensitively. Listed headers are removed from every redirected request, same-origin or not.
|
|
649
|
+
|
|
650
|
+
```ruby
|
|
651
|
+
config = PatientHttp::Configuration.new(redirect_strip_headers: ["X-Api-Key", "X-Internal-Token"])
|
|
652
|
+
|
|
653
|
+
# Or per request; these are stripped in addition to the configured headers
|
|
654
|
+
request = PatientHttp::Request.new(:get, "https://api.example.com/data", headers: headers, redirect_strip_headers: "X-Signature")
|
|
655
|
+
|
|
656
|
+
# The same options are accepted by PatientHttp.request, the async_* helpers, and RequestTemplate
|
|
657
|
+
PatientHttp.get("https://api.example.com/data", callback: FetchCallback, redirect_strip_headers: "X-Signature")
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Per-request header names survive serialization into the job queue, so they apply no matter which process follows the redirect.
|
|
661
|
+
|
|
662
|
+
Stripping applies to the headers set on the request. Preprocessors run again on each same-origin redirect and can add headers after the strip, so a header that a preprocessor sets is sent to the redirect target. When a redirect changes the method and drops the body, the headers that describe the body (`Content-Type`, `Content-Length`, `Content-Encoding`, `Content-Language`, and `Content-Location`) are removed as well.
|
|
663
|
+
|
|
617
664
|
## Troubleshooting
|
|
618
665
|
|
|
619
666
|
### Warning: `ThreadError: Attempt to unlock a mutex which is not locked`
|
|
@@ -656,6 +703,15 @@ config = PatientHttp::Configuration.new(
|
|
|
656
703
|
# Maximum redirects to follow (default: 5, 0 disables)
|
|
657
704
|
max_redirects: 5,
|
|
658
705
|
|
|
706
|
+
# Follow redirects that must change the HTTP method, such as POST to GET on
|
|
707
|
+
# a 302 (default: true). When false, those requests receive the redirect response.
|
|
708
|
+
follow_method_changing_redirects: true,
|
|
709
|
+
|
|
710
|
+
# Header names (case insensitive) always stripped from redirected requests
|
|
711
|
+
# (default: []). Authorization and Cookie are always stripped on cross-origin
|
|
712
|
+
# redirects.
|
|
713
|
+
redirect_strip_headers: ["X-Api-Key", "X-Internal-Token"],
|
|
714
|
+
|
|
659
715
|
# Maximum number of hosts to maintain persistent connections for (default: 100)
|
|
660
716
|
connection_pool_size: 100,
|
|
661
717
|
|
|
@@ -685,9 +741,14 @@ config.register_secret(:api_token, ENV["MY_API_TOKEN"])
|
|
|
685
741
|
### Tuning Tips
|
|
686
742
|
|
|
687
743
|
- **max_connections**: Each connection uses memory and file descriptors. A tuned system can handle thousands.
|
|
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.
|
|
688
745
|
- **request_timeout**: Set based on expected API response times. AI/LLM APIs may need minutes.
|
|
689
746
|
- **connection_pool_size**: Increase for applications calling many different API hosts.
|
|
690
|
-
- **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage.
|
|
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.
|
|
691
752
|
|
|
692
753
|
## Processor Lifecycle
|
|
693
754
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.6.0
|
data/lib/patient_http/client.rb
CHANGED
|
@@ -9,7 +9,8 @@ module PatientHttp
|
|
|
9
9
|
connection_timeout: config.connection_timeout,
|
|
10
10
|
proxy_url: config.proxy_url,
|
|
11
11
|
retries: config.retries,
|
|
12
|
-
protocol: config.protocol
|
|
12
|
+
protocol: config.protocol,
|
|
13
|
+
connection_limit: config.max_connections_per_host
|
|
13
14
|
)
|
|
14
15
|
@response_reader = ResponseReader.new(@processor)
|
|
15
16
|
@request_preparer = RequestPreparer.new(config)
|
|
@@ -17,6 +18,10 @@ module PatientHttp
|
|
|
17
18
|
|
|
18
19
|
# Make an asynchronous HTTP request.
|
|
19
20
|
#
|
|
21
|
+
# The returned body is the array of raw (possibly compressed) body chunks;
|
|
22
|
+
# use {#decode_response} to produce the final body string. Splitting the
|
|
23
|
+
# decode out keeps CPU-bound work off the reactor thread.
|
|
24
|
+
#
|
|
20
25
|
# @param request [Request] the request to make
|
|
21
26
|
# @param request_id [String] unique request identifier
|
|
22
27
|
# @return [Hash] the response data with keys for :status, :headers, and :body
|
|
@@ -35,7 +40,7 @@ module PatientHttp
|
|
|
35
40
|
# Note: headers that appear multiple times (e.g. set-cookie) are
|
|
36
41
|
# flattened to a single joined string value.
|
|
37
42
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
38
|
-
body = @response_reader.
|
|
43
|
+
body = @response_reader.read_raw_body(async_response, headers_hash)
|
|
39
44
|
|
|
40
45
|
{
|
|
41
46
|
status: async_response.status,
|
|
@@ -54,6 +59,26 @@ module PatientHttp
|
|
|
54
59
|
end
|
|
55
60
|
end
|
|
56
61
|
|
|
62
|
+
# Decode raw response data into deliverable response data.
|
|
63
|
+
#
|
|
64
|
+
# Joins and inflates the raw body chunks, applies the charset, and rewrites
|
|
65
|
+
# the content-encoding header to name only the encodings still applied to
|
|
66
|
+
# the body. The header is removed when nothing is left, and kept when the
|
|
67
|
+
# server used an encoding the reader cannot decode, so the delivered
|
|
68
|
+
# response always describes the body it carries. This is CPU-bound work
|
|
69
|
+
# intended to run on a completion worker thread.
|
|
70
|
+
#
|
|
71
|
+
# @param response_data [Hash] raw response data from {#make_request}
|
|
72
|
+
# @return [Hash] response data with the decoded body string
|
|
73
|
+
# @raise [ResponseTooLargeError] if the inflated body exceeds max_response_size
|
|
74
|
+
def decode_response(response_data)
|
|
75
|
+
headers = response_data[:headers]
|
|
76
|
+
body = @response_reader.decode_body(response_data[:body], headers)
|
|
77
|
+
headers = ResponseReader.rewrite_content_encoding(headers)
|
|
78
|
+
|
|
79
|
+
response_data.merge(headers: headers, body: body)
|
|
80
|
+
end
|
|
81
|
+
|
|
57
82
|
# Close all clients and release resources.
|
|
58
83
|
#
|
|
59
84
|
# @return [void]
|
|
@@ -15,7 +15,7 @@ module PatientHttp
|
|
|
15
15
|
http2: Async::HTTP::Protocol::HTTP2
|
|
16
16
|
}.freeze
|
|
17
17
|
|
|
18
|
-
def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil)
|
|
18
|
+
def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil, connection_limit: nil)
|
|
19
19
|
if protocol && !PROTOCOLS.include?(protocol)
|
|
20
20
|
raise ArgumentError.new("protocol must be one of #{PROTOCOLS.keys.inspect}, got: #{protocol.inspect}")
|
|
21
21
|
end
|
|
@@ -26,16 +26,17 @@ module PatientHttp
|
|
|
26
26
|
@proxy_url = proxy_url
|
|
27
27
|
@retries = retries
|
|
28
28
|
@protocol = protocol
|
|
29
|
+
@connection_limit = connection_limit
|
|
29
30
|
@mutex = Mutex.new
|
|
30
31
|
@proxy_client = nil
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol
|
|
34
|
+
attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol, :connection_limit
|
|
34
35
|
|
|
35
36
|
# Get or create a client for the given endpoint.
|
|
36
37
|
#
|
|
37
38
|
# @param endpoint [Async::HTTP::Endpoint] the target endpoint
|
|
38
|
-
# @return [
|
|
39
|
+
# @return [Async::HTTP::Client] the client for the endpoint's host
|
|
39
40
|
def client_for(endpoint)
|
|
40
41
|
key = host_key(endpoint)
|
|
41
42
|
|
|
@@ -154,13 +155,15 @@ module PatientHttp
|
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
def make_client(endpoint)
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
# Response bodies are decoded by ResponseReader on a completion worker
|
|
159
|
+
# thread instead of a Protocol::HTTP::AcceptEncoding wrapper, so the
|
|
160
|
+
# reactor thread never pays for inflating compressed bodies.
|
|
161
|
+
@proxy_url ? make_proxied_client(endpoint) : make_direct_client(endpoint)
|
|
159
162
|
end
|
|
160
163
|
|
|
161
164
|
def make_direct_client(endpoint)
|
|
162
165
|
configured_endpoint = configure_endpoint(endpoint)
|
|
163
|
-
Async::HTTP::Client.new(configured_endpoint, retries: @retries)
|
|
166
|
+
Async::HTTP::Client.new(configured_endpoint, retries: @retries, **client_options)
|
|
164
167
|
end
|
|
165
168
|
|
|
166
169
|
def make_proxied_client(endpoint)
|
|
@@ -170,7 +173,11 @@ module PatientHttp
|
|
|
170
173
|
configured_endpoint = configure_endpoint(endpoint)
|
|
171
174
|
|
|
172
175
|
proxy = @proxy_client.proxy(configured_endpoint)
|
|
173
|
-
Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries)
|
|
176
|
+
Async::HTTP::Client.new(proxy.wrap_endpoint(configured_endpoint), retries: @retries, **client_options)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def client_options
|
|
180
|
+
@connection_limit ? {limit: @connection_limit} : {}
|
|
174
181
|
end
|
|
175
182
|
|
|
176
183
|
def create_proxy_client
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# Fixed pool of worker threads that deliver completed request results.
|
|
5
|
+
#
|
|
6
|
+
# The processor's reactor thread hands each finished HTTP exchange to this
|
|
7
|
+
# pool so response decoding, serialization, and callback delivery never
|
|
8
|
+
# block the event loop. Jobs are arbitrary callables consumed from a single
|
|
9
|
+
# queue.
|
|
10
|
+
#
|
|
11
|
+
# @api private
|
|
12
|
+
class CompletionExecutor
|
|
13
|
+
# Initialize the executor and start its worker threads.
|
|
14
|
+
#
|
|
15
|
+
# @param threads [Integer] number of worker threads
|
|
16
|
+
# @param logger [Logger, nil] logger for unexpected job errors
|
|
17
|
+
# @param thread_name_prefix [String] prefix for worker thread names
|
|
18
|
+
# @param on_finished [#call, nil] invoked after each job completes, outside
|
|
19
|
+
# any executor lock, so the owner can re-check idle conditions
|
|
20
|
+
def initialize(threads:, logger: nil, thread_name_prefix: "patient-http-completion", on_finished: nil)
|
|
21
|
+
@queue = Thread::Queue.new
|
|
22
|
+
@logger = logger
|
|
23
|
+
@on_finished = on_finished
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
# Jobs enqueued but not yet fully executed. Tracked separately from the
|
|
26
|
+
# queue size so a job that has been popped but is still running keeps
|
|
27
|
+
# the executor non-idle.
|
|
28
|
+
@outstanding = 0
|
|
29
|
+
@threads = Array.new(threads) do |index|
|
|
30
|
+
Thread.new do
|
|
31
|
+
Thread.current.name = "#{thread_name_prefix}-#{index + 1}"
|
|
32
|
+
run_worker
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Enqueue a job for execution.
|
|
38
|
+
#
|
|
39
|
+
# @param job [#call] the job to run
|
|
40
|
+
# @raise [ClosedQueueError] if the executor has been shut down
|
|
41
|
+
# @return [void]
|
|
42
|
+
def enqueue(job)
|
|
43
|
+
@mutex.synchronize { @outstanding += 1 }
|
|
44
|
+
begin
|
|
45
|
+
@queue.push(job)
|
|
46
|
+
rescue ClosedQueueError
|
|
47
|
+
@mutex.synchronize { @outstanding -= 1 }
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check whether the executor has no queued or running jobs.
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def idle?
|
|
57
|
+
@mutex.synchronize { @outstanding == 0 }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check whether the given thread is one of this executor's workers.
|
|
61
|
+
#
|
|
62
|
+
# @param thread [Thread] the thread to check
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def worker_thread?(thread = Thread.current)
|
|
65
|
+
@threads.include?(thread)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Shut down the executor: close the queue so workers drain remaining jobs
|
|
69
|
+
# and exit, then join them within the timeout. Workers still alive after
|
|
70
|
+
# the deadline are killed; their tasks remain durably tracked and are
|
|
71
|
+
# recovered by the owner's re-enqueue logic.
|
|
72
|
+
#
|
|
73
|
+
# Safe to call more than once and from a worker thread itself (the
|
|
74
|
+
# current thread is never joined or killed).
|
|
75
|
+
#
|
|
76
|
+
# @param timeout [Numeric] seconds to wait for workers to drain
|
|
77
|
+
# @return [void]
|
|
78
|
+
def shutdown(timeout: 5)
|
|
79
|
+
@queue.close
|
|
80
|
+
|
|
81
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
82
|
+
@threads.each do |thread|
|
|
83
|
+
next if thread.equal?(Thread.current)
|
|
84
|
+
|
|
85
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
thread.join(remaining.positive? ? remaining : 0)
|
|
87
|
+
if thread.alive?
|
|
88
|
+
thread.kill
|
|
89
|
+
thread.join(1)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
discard_undrained_jobs
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Drop jobs left in the closed queue by workers that were killed at the
|
|
100
|
+
# shutdown deadline. Those jobs can never run, so they must stop counting
|
|
101
|
+
# against the outstanding total or the executor would never report itself
|
|
102
|
+
# idle again.
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def discard_undrained_jobs
|
|
106
|
+
discarded = 0
|
|
107
|
+
|
|
108
|
+
loop do
|
|
109
|
+
break unless @queue.pop(true)
|
|
110
|
+
discarded += 1
|
|
111
|
+
rescue ThreadError
|
|
112
|
+
break
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@mutex.synchronize { @outstanding -= discarded } if discarded > 0
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run_worker
|
|
120
|
+
while (job = @queue.pop)
|
|
121
|
+
begin
|
|
122
|
+
job.call
|
|
123
|
+
rescue => e
|
|
124
|
+
@logger&.error(
|
|
125
|
+
"[PatientHttp] Completion worker error: #{e.class} - #{e.message}\n#{e.backtrace&.join("\n")}"
|
|
126
|
+
)
|
|
127
|
+
warn("#{e.inspect}\n#{e.backtrace&.join("\n")}") if PatientHttp.testing?
|
|
128
|
+
ensure
|
|
129
|
+
@mutex.synchronize { @outstanding -= 1 }
|
|
130
|
+
begin
|
|
131
|
+
@on_finished&.call
|
|
132
|
+
rescue => e
|
|
133
|
+
@logger&.error("[PatientHttp] Completion executor callback error: #{e.inspect}")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|