patient_http 1.4.0 → 1.5.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: e193efd74abdf58c93ef6d65796c881bf08042264da0da0db34ee030ec52d08a
4
+ data.tar.gz: cb61f5268f51d6959b04e096420419f3454852096b5e027d67f79fa2f9789be8
5
5
  SHA512:
6
- metadata.gz: 4bd3586a382436ac11872d10380afb28326497c9538383ae0e884ecaf901dd952df0202e9ed740047b6336c60960ebff20b83abcdc86a585c67e0915097dbe92
7
- data.tar.gz: d86c67c48c1aa03d0aa47f4675626696ab9012b526add67a4d8ebf611aca40b8229e722bd2e07c2ef6de6caa6110a01507d3317f029668fa89a768d145fea299
6
+ metadata.gz: 56896f881827163fffaebfc8c745e47d5abdbf3c11da1727d6801164b1ed1e4ac276ac4a9e288d8d8d9cc544aab2e1a67d28c8cd5cff312429d243b49658107f
7
+ data.tar.gz: 4e2a4c555e44ed3c0ee6997d74ca0dfa444e7ad4567a9b58386e3bbd442ce8a82d94558e7606aa8bab2749a9930221e0c2609ffced97922a47cf7f1c4261023a
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,33 @@ 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.5.0
8
+
9
+ ### Added
10
+
11
+ - 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.
12
+ - `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.
13
+ - `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.
14
+ - `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.
15
+ - `Processor#remaining_capacity` and `Processor#capacity_available?`: cheap, advisory capacity checks with no observer notifications, so integrations can reject work before paying registration costs.
16
+ - 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.
17
+
18
+ ### Changed
19
+
20
+ - 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.
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+ - `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.
26
+ - **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.
27
+ - On shutdown, results that were already handed off for delivery are delivered before remaining tasks are re-enqueued.
28
+
29
+ ### Fixed
30
+
31
+ - 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.
32
+ - 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.
33
+
7
34
  ## 1.4.0
8
35
 
9
36
  ### 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
 
@@ -685,9 +689,14 @@ config.register_secret(:api_token, ENV["MY_API_TOKEN"])
685
689
  ### Tuning Tips
686
690
 
687
691
  - **max_connections**: Each connection uses memory and file descriptors. A tuned system can handle thousands.
692
+ - **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
693
  - **request_timeout**: Set based on expected API response times. AI/LLM APIs may need minutes.
689
694
  - **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.
695
+ - **max_response_size**: Keeps memory usage bounded. Large responses may need external payload storage. The limit applies to the inflated bytes of compressed responses.
696
+ - **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.
697
+ - **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.
698
+ - **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.
699
+ - **shutdown_timeout**: Set below the process supervisor's termination window so the drain (including handed-off completions) finishes before a hard kill.
691
700
 
692
701
  ## Processor Lifecycle
693
702
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.4.0
1
+ 1.5.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
@@ -15,6 +15,16 @@ module PatientHttp
15
15
  # @return [Integer] Maximum number of concurrent connections
16
16
  attr_reader :max_connections
17
17
 
18
+ # @return [Integer, nil] Maximum number of connections per host (nil for unlimited)
19
+ attr_reader :max_connections_per_host
20
+
21
+ # @return [Integer] Number of threads that deliver completed results
22
+ attr_reader :completion_threads
23
+
24
+ # @return [Integer] Number of retries when delivering a completed result fails.
25
+ # A retry calls the task handler again, so handlers must be idempotent.
26
+ attr_reader :completion_retries
27
+
18
28
  # @return [Numeric] Default request timeout in seconds
19
29
  attr_reader :request_timeout
20
30
 
@@ -68,6 +78,12 @@ module PatientHttp
68
78
  # @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
69
79
  # @param retries [Integer] Number of retries for failed requests
70
80
  # @param protocol [Symbol, nil] HTTP protocol to use (:http1 or :http2); nil to negotiate
81
+ # @param max_connections_per_host [Integer, nil] Maximum number of connections per host (nil for unlimited)
82
+ # @param completion_threads [Integer] Number of threads that deliver completed results
83
+ # @param completion_retries [Integer] Number of retries when delivering a completed result fails.
84
+ # A retry calls TaskHandler#on_complete or #on_error again, so a handler that raises after
85
+ # its side effect delivers the callback more than once unless it is idempotent. Set to 0 to
86
+ # report the first failure without retrying.
71
87
  def initialize(
72
88
  max_connections: 256,
73
89
  request_timeout: 60,
@@ -82,7 +98,10 @@ module PatientHttp
82
98
  proxy_url: nil,
83
99
  retries: 3,
84
100
  protocol: nil,
85
- encryption_key: nil
101
+ encryption_key: nil,
102
+ max_connections_per_host: nil,
103
+ completion_threads: 2,
104
+ completion_retries: 2
86
105
  )
87
106
  @mutex = Mutex.new
88
107
 
@@ -113,6 +132,9 @@ module PatientHttp
113
132
  self.retries = retries
114
133
  self.protocol = protocol
115
134
  self.encryption_key = encryption_key
135
+ self.max_connections_per_host = max_connections_per_host
136
+ self.completion_threads = completion_threads
137
+ self.completion_retries = completion_retries
116
138
  end
117
139
 
118
140
  # Get the logger to use to report pool events. Default is to log errors to STDERR.
@@ -124,6 +146,26 @@ module PatientHttp
124
146
  @max_connections = value
125
147
  end
126
148
 
149
+ def max_connections_per_host=(value)
150
+ if value.nil?
151
+ @max_connections_per_host = nil
152
+ return
153
+ end
154
+
155
+ validate_positive_integer(:max_connections_per_host, value)
156
+ @max_connections_per_host = value
157
+ end
158
+
159
+ def completion_threads=(value)
160
+ validate_positive_integer(:completion_threads, value)
161
+ @completion_threads = value
162
+ end
163
+
164
+ def completion_retries=(value)
165
+ validate_non_negative_integer(:completion_retries, value)
166
+ @completion_retries = value
167
+ end
168
+
127
169
  def request_timeout=(value)
128
170
  validate_positive(:request_timeout, value)
129
171
  @request_timeout = value
@@ -388,6 +430,9 @@ module PatientHttp
388
430
  "proxy_url" => proxy_url,
389
431
  "retries" => retries,
390
432
  "protocol" => protocol,
433
+ "max_connections_per_host" => max_connections_per_host,
434
+ "completion_threads" => completion_threads,
435
+ "completion_retries" => completion_retries,
391
436
  "payload_stores" => payload_stores.keys,
392
437
  "default_payload_store" => default_payload_store_name,
393
438
  "secrets" => @mutex.synchronize { @secrets.keys },
@@ -29,7 +29,9 @@ module PatientHttp
29
29
  # Encodes a value based on its MIME type.
30
30
  #
31
31
  # For text-based content types, applies gzip compression if beneficial.
32
- # For binary content, uses Base64 encoding.
32
+ # For binary content, uses Base64 encoding. A value that a text MIME type
33
+ # claims is text but that does not hold text is encoded as binary as
34
+ # well, because the serialized form must survive JSON encoding.
33
35
  #
34
36
  # @param value [String] the value to encode
35
37
  # @param mimetype [String, nil] the MIME type of the content
@@ -38,21 +40,11 @@ module PatientHttp
38
40
  return nil if value.nil?
39
41
 
40
42
  if is_text_mimetype?(mimetype)
41
- value = text_value(value, charset(mimetype))
42
-
43
- if value.bytesize < 4096
44
- [:text, value, value.encoding.name]
45
- else
46
- gzipped = Zlib.gzip(value)
47
- if gzipped.bytesize < value.bytesize
48
- [:gzipped, [gzipped].pack("m0"), value.encoding.name]
49
- else
50
- [:text, value, value.encoding.name]
51
- end
52
- end
53
- else
54
- [:binary, [value].pack("m0"), Encoding::BINARY.name]
43
+ text = text_value(value, charset(mimetype))
44
+ return encode_text(text) if text?(text)
55
45
  end
46
+
47
+ [:binary, [value].pack("m0"), Encoding::BINARY.name]
56
48
  end
57
49
 
58
50
  # Decodes an encoded value based on its encoding type.
@@ -78,6 +70,36 @@ module PatientHttp
78
70
 
79
71
  private
80
72
 
73
+ # Encode a text value, compressing it when that makes it smaller.
74
+ #
75
+ # @param value [String] the text to encode
76
+ # @return [Array(Symbol, String, String)] [encoding, encoded_value, charset]
77
+ def encode_text(value)
78
+ return [:text, value, value.encoding.name] if value.bytesize < 4096
79
+
80
+ gzipped = Zlib.gzip(value)
81
+ if gzipped.bytesize < value.bytesize
82
+ [:gzipped, [gzipped].pack("m0"), value.encoding.name]
83
+ else
84
+ [:text, value, value.encoding.name]
85
+ end
86
+ end
87
+
88
+ # Whether a value can be serialized as text. JSON encoding converts a
89
+ # string to UTF-8, so the value must either be valid text in its own
90
+ # encoding or hold bytes that are already valid UTF-8. A body still
91
+ # carrying a content encoding the reader could not decode holds neither,
92
+ # even though its MIME type names a text type.
93
+ #
94
+ # @param value [String] the value to check
95
+ # @return [Boolean]
96
+ def text?(value)
97
+ return value.valid_encoding? unless value.encoding == Encoding::BINARY
98
+ return true if value.ascii_only?
99
+
100
+ value.dup.force_encoding(Encoding::UTF_8).valid_encoding?
101
+ end
102
+
81
103
  def is_text_mimetype?(mimetype)
82
104
  mimetype&.match?(/\Atext\/|application\/(?:json|xml|javascript)/)
83
105
  end