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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0888d7e92f43f2031bf7f0eb5b0f4dd528ac7b79957b506f1df61825309990a
4
- data.tar.gz: 87b5817b8205a0b605edb7cb41facf0f6b5690122ff63cb669dbcc6c6b090cd0
3
+ metadata.gz: 54ede397ae79574e7dd597ea642b4d039877d5d642ba49c68b76219b8b1c4a32
4
+ data.tar.gz: 83bb067b78912753791e6002abb3470ca70936f5fe862fc65ee65d7786e1ac53
5
5
  SHA512:
6
- metadata.gz: 4bd3586a382436ac11872d10380afb28326497c9538383ae0e884ecaf901dd952df0202e9ed740047b6336c60960ebff20b83abcdc86a585c67e0915097dbe92
7
- data.tar.gz: d86c67c48c1aa03d0aa47f4675626696ab9012b526add67a4d8ebf611aca40b8229e722bd2e07c2ef6de6caa6110a01507d3317f029668fa89a768d145fea299
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 should be lightweight and fast -- typically just enqueuing a message for another system to pick up. Doing heavy processing in a callback will block the reactor and delay other in-flight requests.
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 application process can run:
234
- - Multiple application threads
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. **TaskHandler callbacks** execute on the reactor thread and should be lightweight
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 -- don't do heavy processing here since
38
- # it runs on the processor thread.
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. They should be lightweight and fast -- typically just enqueuing a message for another system to pick up. Doing heavy processing in a callback will block the reactor and delay other in-flight requests.
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 .delete class methods:
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.4.0
1
+ 1.6.0
@@ -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.read_body(async_response, headers_hash)
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 [Protocol::HTTP::AcceptEncoding] wrapped client
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
- client = @proxy_url ? make_proxied_client(endpoint) : make_direct_client(endpoint)
158
- ::Protocol::HTTP::AcceptEncoding.new(client)
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