patient_http 1.1.1 → 1.2.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/CHANGELOG.md +32 -0
- data/README.md +64 -3
- data/VERSION +1 -1
- data/lib/patient_http/client.rb +10 -12
- data/lib/patient_http/client_pool.rb +23 -8
- data/lib/patient_http/configuration.rb +78 -1
- data/lib/patient_http/http_headers.rb +7 -0
- data/lib/patient_http/lifecycle_manager.rb +20 -8
- data/lib/patient_http/outgoing_request.rb +72 -0
- data/lib/patient_http/payload.rb +1 -1
- data/lib/patient_http/payload_store/redis_store.rb +4 -1
- data/lib/patient_http/processor.rb +290 -109
- data/lib/patient_http/redirect_error.rb +5 -0
- data/lib/patient_http/request.rb +26 -3
- data/lib/patient_http/request_error.rb +1 -1
- data/lib/patient_http/request_preparer.rb +52 -0
- data/lib/patient_http/request_task.rb +16 -7
- data/lib/patient_http/request_template.rb +9 -3
- data/lib/patient_http/response.rb +3 -0
- data/lib/patient_http/response_reader.rb +16 -4
- data/lib/patient_http/synchronous_executor.rb +53 -45
- data/lib/patient_http.rb +16 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 393cbf361701d9e1dc190b80f571d78059a21dae85f225750198a1ab173e8bb8
|
|
4
|
+
data.tar.gz: 3d5349e55335d4cd262699481517d3c44825c27449a2b3b11d9559a7b4f468b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '06894a234fca9e2c12aa77b69cf5347824de6f6be9e3b912dcc3e7834e2137231144e69ba58bef85fb026b200369ad976befc4e540ba9f2779a31fe3e68991a0'
|
|
7
|
+
data.tar.gz: ac60b71f532664b3083697dd1c225f4a5a787f07de04d2458d3f849c3b3962d68062740b7e0f03b981edf7aa9ab6fac98aadff98ef9549a0efe15c0b3b968de1
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ 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.2.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Request preprocessors for modifying the outgoing request just before it is sent — most usefully, to sign requests (e.g. AWS SigV4 signatures that must set multiple headers computed over the final request). Register a preprocessor on the `Configuration` with `register_preprocessor(name)` and attach it to a request with `preprocessors: name`. The request serializes only the preprocessor name; the callable (and any credentials it uses) stays on the processor side. Preprocessors receive an `OutgoingRequest` — a view of the request after secret resolution with read access to the method, URL, and body, mutable headers, and an `add_param` method for appending query parameters. On redirects, preprocessors re-run against each redirect URL and are dropped on cross-origin hops, consistent with sensitive header stripping.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `SynchronousExecutor` no longer resolves secret query params twice per request, which previously invoked callable secrets twice.
|
|
16
|
+
- Graceful shutdown now works as documented: requests that complete while the processor is stopping have their responses delivered instead of being discarded and retried, and `Processor#stop` returns as soon as in-flight requests finish rather than always blocking for the full `shutdown_timeout`.
|
|
17
|
+
- `Processor#stop` performs a second re-enqueue pass after the reactor thread exits, closing a race where a task dequeued but not yet tracked at shutdown could be silently lost.
|
|
18
|
+
- Task results are now claimed atomically at shutdown so a completing request can no longer trigger both its completion callback and a `TaskHandler#retry` for the same task.
|
|
19
|
+
- `Processor#start` on a draining processor is now a no-op instead of spawning a second reactor thread that shared the queue and connection pools with the draining one.
|
|
20
|
+
- The processor can no longer end up in a running state with a dead reactor thread when the reactor fails during startup.
|
|
21
|
+
- Processor observers are now invoked outside of internal locks, so an observer callback can safely call back into processor methods such as `total_count` without raising a recursive locking error.
|
|
22
|
+
- `SynchronousExecutor` no longer invokes the error callback twice when a redirect fails with too many redirects or a redirect loop.
|
|
23
|
+
- `HttpHeaders#merge` no longer mutates the receiver. This also fixes `RequestTemplate` permanently absorbing per-request headers into its defaults, which could leak headers (including credentials) across requests built from the same template.
|
|
24
|
+
- `Request` now copies headers passed to its constructor instead of holding (and potentially mutating) the caller's `HttpHeaders` instance.
|
|
25
|
+
- `Errno::ETIMEDOUT` is now treated as a connection error, evicting the pooled client and classifying the `RequestError` as `:connection`.
|
|
26
|
+
- `RedirectError.load` validates that the serialized error class is a `RedirectError` subclass instead of instantiating an arbitrary class name from the payload.
|
|
27
|
+
- Fixed the Redis payload store documentation to use the `redis` gem interface (`Redis.new`); the previously documented `RedisClient` does not respond to the methods the store calls.
|
|
28
|
+
|
|
29
|
+
## 1.1.2
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- Added `protocol` configuration option to force the HTTP protocol to `:http1` or `:http2` instead of negotiating with the server. Forcing `:http1` also limits the TLS ALPN advertisement to `http/1.1`, which can work around SSL-intercepting proxies that mishandle HTTP/2 negotiation.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- `Errno::ECONNABORTED` ("Software caused connection abort") is now treated as a connection error: the pooled client for the host is evicted so the aborted connection is not reused, and `RequestError` classifies it as `:connection` instead of `:unknown`. This error is commonly raised when an SSL-intercepting proxy kills a connection.
|
|
38
|
+
|
|
7
39
|
## 1.1.1
|
|
8
40
|
|
|
9
41
|
### Fixed
|
data/README.md
CHANGED
|
@@ -281,6 +281,8 @@ error = PatientHttp::HttpError.load(json_data)
|
|
|
281
281
|
|
|
282
282
|
The `Response` object includes the HTTP status code, headers, body, and callback arguments. Error objects (`HttpError`, `RedirectError`, `RequestError`) include the error message, context about the request, and callback arguments.
|
|
283
283
|
|
|
284
|
+
Response headers are case insensitive. Headers that appear multiple times in the response (such as `set-cookie`) are flattened into a single joined string value.
|
|
285
|
+
|
|
284
286
|
Response bodies are automatically encoded for JSON serialization. Binary content is Base64 encoded, and large text content is gzipped and then Base64 encoded to reduce payload size. Decoding is handled transparently when you access the `body` or `json` methods on the `Response` object.
|
|
285
287
|
|
|
286
288
|
### Payload Stores
|
|
@@ -321,10 +323,10 @@ config.register_payload_store(:files, adapter: :file, directory: "/tmp/payloads"
|
|
|
321
323
|
|
|
322
324
|
#### Redis Store
|
|
323
325
|
|
|
324
|
-
For production with shared state across processes:
|
|
326
|
+
For production with shared state across processes (requires the `redis` gem; the client must respond to `set`, `get`, `del`, and `exists`):
|
|
325
327
|
|
|
326
328
|
```ruby
|
|
327
|
-
redis =
|
|
329
|
+
redis = Redis.new(url: ENV["REDIS_URL"])
|
|
328
330
|
config.register_payload_store(:redis, adapter: :redis, redis: redis, ttl: 86400)
|
|
329
331
|
```
|
|
330
332
|
|
|
@@ -501,7 +503,7 @@ config.register_secret(:authorization, "Bearer #{ENV['API_TOKEN']}") # static va
|
|
|
501
503
|
config.register_secret(:api_key) { ENV["MY_API_KEY"] } # lazy block
|
|
502
504
|
```
|
|
503
505
|
|
|
504
|
-
|
|
506
|
+
If a secret is not found when resolving a request, a `PatientHttp::SecretManager::SecretNotFoundError` is raised, which surfaces through the normal request error path.
|
|
505
507
|
|
|
506
508
|
### Referencing secrets when building a request
|
|
507
509
|
|
|
@@ -518,6 +520,59 @@ PatientHttp.get(
|
|
|
518
520
|
|
|
519
521
|
The request serializes the secret header as `{"$secret" => "api_token"}` and keeps the secret query parameter out of the URL (non-secret params like `page` are still folded into the URL as usual). The processor dereferences both just before sending: the header is set to its resolved value and the resolved query parameter is appended to the URL.
|
|
520
522
|
|
|
523
|
+
## Request Preprocessors
|
|
524
|
+
|
|
525
|
+
Preprocessors let you modify a request just before it is sent — most usefully, to sign it. Signing schemes like AWS SigV4 need to compute values over the final outgoing request (method, URL, headers, body) and set multiple headers, which cannot be expressed as a static header value at build time.
|
|
526
|
+
|
|
527
|
+
Like secrets, preprocessors are registered on the `Configuration` and referenced from requests by name only. The serialized request carries just the name, so the signing logic and its credentials live on the processor side and are never written to the job queue.
|
|
528
|
+
|
|
529
|
+
### Defining preprocessors
|
|
530
|
+
|
|
531
|
+
Register a named preprocessor as a block or callable taking a single argument:
|
|
532
|
+
|
|
533
|
+
```ruby
|
|
534
|
+
config = PatientHttp::Configuration.new
|
|
535
|
+
config.register_preprocessor(:aws_sigv4) do |request|
|
|
536
|
+
signer = Aws::Sigv4::Signer.new(
|
|
537
|
+
service: "execute-api",
|
|
538
|
+
region: "us-east-1",
|
|
539
|
+
credentials_provider: Aws::CredentialProviderChain.new.resolve
|
|
540
|
+
)
|
|
541
|
+
signature = signer.sign_request(
|
|
542
|
+
http_method: request.http_method.to_s.upcase,
|
|
543
|
+
url: request.url,
|
|
544
|
+
headers: request.headers.to_h,
|
|
545
|
+
body: request.body.to_s
|
|
546
|
+
)
|
|
547
|
+
signature.headers.each { |name, value| request.headers[name] = value }
|
|
548
|
+
end
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
The argument is a `PatientHttp::OutgoingRequest` — a view of the request as it is about to be sent, after all secret references have been resolved and the `x-request-id` and default `User-Agent` headers have been set. It exposes:
|
|
552
|
+
|
|
553
|
+
- `http_method`, `url`, and `body` (read-only; the URL includes any resolved secret query params)
|
|
554
|
+
- `headers` — mutable, case-insensitive headers
|
|
555
|
+
- `add_param(name, value)` — appends a query parameter to the URL, for signed-query-param schemes
|
|
556
|
+
|
|
557
|
+
### Attaching preprocessors to a request
|
|
558
|
+
|
|
559
|
+
Reference registered preprocessors by name when building a request:
|
|
560
|
+
|
|
561
|
+
```ruby
|
|
562
|
+
PatientHttp.post(
|
|
563
|
+
"https://api.example.com/data",
|
|
564
|
+
callback: MyCallback,
|
|
565
|
+
json: {value: 1},
|
|
566
|
+
preprocessors: :aws_sigv4
|
|
567
|
+
)
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Multiple preprocessors can be given as an array; they run in order, each seeing the changes made by the ones before it. `RequestTemplate` also accepts `preprocessors:` as a template-wide default.
|
|
571
|
+
|
|
572
|
+
If a request references a preprocessor name that is not registered, a `PatientHttp::RequestPreparer::PreprocessorNotFoundError` is raised, which surfaces through the normal request error path.
|
|
573
|
+
|
|
574
|
+
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.
|
|
575
|
+
|
|
521
576
|
## Configuration
|
|
522
577
|
|
|
523
578
|
```ruby
|
|
@@ -555,6 +610,12 @@ config = PatientHttp::Configuration.new(
|
|
|
555
610
|
# Retries for failed requests (default: 3)
|
|
556
611
|
retries: 3,
|
|
557
612
|
|
|
613
|
+
# Force the HTTP protocol to :http1 or :http2 (default: nil, negotiates with
|
|
614
|
+
# the server, preferring HTTP/2 for HTTPS). Forcing :http1 also limits the TLS
|
|
615
|
+
# ALPN advertisement to http/1.1, which can work around SSL-intercepting
|
|
616
|
+
# proxies that mishandle HTTP/2.
|
|
617
|
+
protocol: nil,
|
|
618
|
+
|
|
558
619
|
# Logger instance (default: Logger to STDERR at ERROR level)
|
|
559
620
|
logger: Logger.new($stdout)
|
|
560
621
|
)
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.2.0
|
data/lib/patient_http/client.rb
CHANGED
|
@@ -8,9 +8,11 @@ module PatientHttp
|
|
|
8
8
|
max_size: config.connection_pool_size,
|
|
9
9
|
connection_timeout: config.connection_timeout,
|
|
10
10
|
proxy_url: config.proxy_url,
|
|
11
|
-
retries: config.retries
|
|
11
|
+
retries: config.retries,
|
|
12
|
+
protocol: config.protocol
|
|
12
13
|
)
|
|
13
14
|
@response_reader = ResponseReader.new(@processor)
|
|
15
|
+
@request_preparer = RequestPreparer.new(config)
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
# Make an asynchronous HTTP request.
|
|
@@ -22,13 +24,16 @@ module PatientHttp
|
|
|
22
24
|
async_response = nil
|
|
23
25
|
|
|
24
26
|
begin
|
|
25
|
-
|
|
26
|
-
url =
|
|
27
|
+
outgoing = @request_preparer.prepare(request, request_id)
|
|
28
|
+
url = outgoing.url
|
|
29
|
+
headers = outgoing.headers.to_h
|
|
27
30
|
body = Protocol::HTTP::Body::Buffered.wrap([request.body.to_s]) if request.body
|
|
28
31
|
timeout = request.timeout || config.request_timeout
|
|
29
32
|
|
|
30
33
|
Async::Task.current.with_timeout(timeout) do
|
|
31
34
|
async_response = @client_pool.request(request.http_method, url, headers, body)
|
|
35
|
+
# Note: headers that appear multiple times (e.g. set-cookie) are
|
|
36
|
+
# flattened to a single joined string value.
|
|
32
37
|
headers_hash = async_response.headers.to_h.transform_values(&:to_s)
|
|
33
38
|
body = @response_reader.read_body(async_response, headers_hash)
|
|
34
39
|
|
|
@@ -64,19 +69,12 @@ module PatientHttp
|
|
|
64
69
|
|
|
65
70
|
def connection_error?(exception)
|
|
66
71
|
case exception
|
|
67
|
-
when Async::TimeoutError, Errno::ECONNRESET, Errno::
|
|
68
|
-
Errno::EHOSTUNREACH, SocketError, IOError
|
|
72
|
+
when Async::TimeoutError, Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE,
|
|
73
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError
|
|
69
74
|
true
|
|
70
75
|
else
|
|
71
76
|
false
|
|
72
77
|
end
|
|
73
78
|
end
|
|
74
|
-
|
|
75
|
-
def request_headers(request, request_id)
|
|
76
|
-
headers = config.secret_manager.resolve_headers(request.headers.to_h)
|
|
77
|
-
headers["x-request-id"] = request_id
|
|
78
|
-
headers["user-agent"] ||= config.user_agent if config.user_agent
|
|
79
|
-
headers
|
|
80
|
-
end
|
|
81
79
|
end
|
|
82
80
|
end
|
|
@@ -7,17 +7,30 @@ module PatientHttp
|
|
|
7
7
|
# is capped with an LRU algorithm - when a new client is needed and the
|
|
8
8
|
# pool is at capacity, the least recently used client is closed and removed.
|
|
9
9
|
class ClientPool
|
|
10
|
-
|
|
10
|
+
# Supported protocol names mapped to their async-http implementations. Forcing
|
|
11
|
+
# :http1 also limits the TLS ALPN advertisement to http/1.1, which avoids
|
|
12
|
+
# HTTP/2 negotiation with servers and middleboxes that mishandle it.
|
|
13
|
+
PROTOCOLS = {
|
|
14
|
+
http1: Async::HTTP::Protocol::HTTP11,
|
|
15
|
+
http2: Async::HTTP::Protocol::HTTP2
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3, protocol: nil)
|
|
19
|
+
if protocol && !PROTOCOLS.include?(protocol)
|
|
20
|
+
raise ArgumentError.new("protocol must be one of #{PROTOCOLS.keys.inspect}, got: #{protocol.inspect}")
|
|
21
|
+
end
|
|
22
|
+
|
|
11
23
|
@clients = {}
|
|
12
24
|
@max_size = max_size
|
|
13
25
|
@connection_timeout = connection_timeout
|
|
14
26
|
@proxy_url = proxy_url
|
|
15
27
|
@retries = retries
|
|
28
|
+
@protocol = protocol
|
|
16
29
|
@mutex = Mutex.new
|
|
17
30
|
@proxy_client = nil
|
|
18
31
|
end
|
|
19
32
|
|
|
20
|
-
attr_reader :max_size, :connection_timeout, :proxy_url, :retries
|
|
33
|
+
attr_reader :max_size, :connection_timeout, :proxy_url, :retries, :protocol
|
|
21
34
|
|
|
22
35
|
# Get or create a client for the given endpoint.
|
|
23
36
|
#
|
|
@@ -162,17 +175,19 @@ module PatientHttp
|
|
|
162
175
|
|
|
163
176
|
def create_proxy_client
|
|
164
177
|
proxy_endpoint = Async::HTTP::Endpoint.parse(@proxy_url)
|
|
165
|
-
|
|
178
|
+
if @connection_timeout
|
|
179
|
+
proxy_endpoint = Async::HTTP::Endpoint.new(proxy_endpoint.url, timeout: @connection_timeout)
|
|
180
|
+
end
|
|
166
181
|
Async::HTTP::Client.new(proxy_endpoint)
|
|
167
182
|
end
|
|
168
183
|
|
|
169
184
|
def configure_endpoint(endpoint)
|
|
170
|
-
|
|
185
|
+
options = {}
|
|
186
|
+
options[:timeout] = @connection_timeout if @connection_timeout
|
|
187
|
+
options[:protocol] = PROTOCOLS.fetch(@protocol) if @protocol
|
|
188
|
+
return endpoint if options.empty?
|
|
171
189
|
|
|
172
|
-
Async::HTTP::Endpoint.new(
|
|
173
|
-
endpoint.url,
|
|
174
|
-
timeout: @connection_timeout
|
|
175
|
-
)
|
|
190
|
+
Async::HTTP::Endpoint.new(endpoint.url, **options)
|
|
176
191
|
end
|
|
177
192
|
end
|
|
178
193
|
end
|
|
@@ -46,6 +46,10 @@ module PatientHttp
|
|
|
46
46
|
# @return [Integer] Number of retries for failed requests
|
|
47
47
|
attr_reader :retries
|
|
48
48
|
|
|
49
|
+
# @return [Symbol, nil] HTTP protocol to use (:http1 or :http2). When nil, the
|
|
50
|
+
# protocol is negotiated with the server (HTTP/2 preferred for HTTPS).
|
|
51
|
+
attr_reader :protocol
|
|
52
|
+
|
|
49
53
|
# @return [SecretManager] the secret manager instance
|
|
50
54
|
attr_reader :secret_manager
|
|
51
55
|
|
|
@@ -63,6 +67,7 @@ module PatientHttp
|
|
|
63
67
|
# @param connection_timeout [Numeric, nil] Connection timeout in seconds
|
|
64
68
|
# @param proxy_url [String, nil] HTTP/HTTPS proxy URL (supports authentication)
|
|
65
69
|
# @param retries [Integer] Number of retries for failed requests
|
|
70
|
+
# @param protocol [Symbol, nil] HTTP protocol to use (:http1 or :http2); nil to negotiate
|
|
66
71
|
def initialize(
|
|
67
72
|
max_connections: 256,
|
|
68
73
|
request_timeout: 60,
|
|
@@ -76,6 +81,7 @@ module PatientHttp
|
|
|
76
81
|
connection_timeout: nil,
|
|
77
82
|
proxy_url: nil,
|
|
78
83
|
retries: 3,
|
|
84
|
+
protocol: nil,
|
|
79
85
|
encryption_key: nil
|
|
80
86
|
)
|
|
81
87
|
@mutex = Mutex.new
|
|
@@ -88,6 +94,9 @@ module PatientHttp
|
|
|
88
94
|
@secrets = {}
|
|
89
95
|
@secret_manager = SecretManager.new
|
|
90
96
|
|
|
97
|
+
# Initialize preprocessor registry
|
|
98
|
+
@preprocessors = {}
|
|
99
|
+
|
|
91
100
|
@encryptor = nil
|
|
92
101
|
|
|
93
102
|
self.max_connections = max_connections
|
|
@@ -102,6 +111,7 @@ module PatientHttp
|
|
|
102
111
|
self.connection_timeout = connection_timeout
|
|
103
112
|
self.proxy_url = proxy_url
|
|
104
113
|
self.retries = retries
|
|
114
|
+
self.protocol = protocol
|
|
105
115
|
self.encryption_key = encryption_key
|
|
106
116
|
end
|
|
107
117
|
|
|
@@ -164,6 +174,20 @@ module PatientHttp
|
|
|
164
174
|
@retries = value
|
|
165
175
|
end
|
|
166
176
|
|
|
177
|
+
def protocol=(value)
|
|
178
|
+
if value.nil?
|
|
179
|
+
@protocol = nil
|
|
180
|
+
return
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
value = value.to_sym if value.is_a?(String)
|
|
184
|
+
unless ClientPool::PROTOCOLS.key?(value)
|
|
185
|
+
raise ArgumentError.new("protocol must be one of #{ClientPool::PROTOCOLS.keys.inspect}, got: #{value.inspect}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@protocol = value
|
|
189
|
+
end
|
|
190
|
+
|
|
167
191
|
# Set the encryption callable for encrypting payloads before serialization.
|
|
168
192
|
#
|
|
169
193
|
# @param callable [#call, nil] An object that responds to #call, taking data and returning encrypted data
|
|
@@ -250,6 +274,43 @@ module PatientHttp
|
|
|
250
274
|
end
|
|
251
275
|
end
|
|
252
276
|
|
|
277
|
+
# Register a named preprocessor that can be attached to requests to modify them
|
|
278
|
+
# just before they are sent -- for example, to sign requests.
|
|
279
|
+
#
|
|
280
|
+
# The preprocessor can be provided as a callable or a block taking a single
|
|
281
|
+
# argument. When a request that references the preprocessor is sent, it is
|
|
282
|
+
# invoked with an {OutgoingRequest} after secret references have been resolved
|
|
283
|
+
# and the x-request-id and default user-agent headers have been set. It can
|
|
284
|
+
# change the request headers and append query parameters.
|
|
285
|
+
#
|
|
286
|
+
# Requests reference preprocessors by name only, so the callable (and any
|
|
287
|
+
# credentials it uses) stays on the processor side and is never serialized.
|
|
288
|
+
#
|
|
289
|
+
# @param name [String, Symbol] the preprocessor name
|
|
290
|
+
# @param callable [#call, nil] object invoked with the outgoing request (omit when providing a block)
|
|
291
|
+
# @yield [outgoing_request] a block invoked with the outgoing request (omit when providing a callable)
|
|
292
|
+
# @raise [ArgumentError] if neither or both of callable and block are provided, or
|
|
293
|
+
# if the preprocessor cannot be called with a single argument
|
|
294
|
+
# @return [void]
|
|
295
|
+
def register_preprocessor(name, callable = nil, &block)
|
|
296
|
+
preprocessor = resolve_callable(:preprocessor, callable, &block)
|
|
297
|
+
raise ArgumentError.new("register_preprocessor requires a callable or a block") if preprocessor.nil?
|
|
298
|
+
|
|
299
|
+
validate_preprocessor_parameters!(preprocessor)
|
|
300
|
+
|
|
301
|
+
@mutex.synchronize do
|
|
302
|
+
@preprocessors = @preprocessors.merge(name.to_s => preprocessor)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Get a registered preprocessor by name.
|
|
307
|
+
#
|
|
308
|
+
# @param name [String, Symbol] the preprocessor name
|
|
309
|
+
# @return [#call, nil] the preprocessor or nil if not registered
|
|
310
|
+
def preprocessor(name)
|
|
311
|
+
@preprocessors[name.to_s]
|
|
312
|
+
end
|
|
313
|
+
|
|
253
314
|
# Register a payload store for external storage of large payloads.
|
|
254
315
|
#
|
|
255
316
|
# The name is included in the serialized references to the stored data.
|
|
@@ -326,9 +387,11 @@ module PatientHttp
|
|
|
326
387
|
"connection_timeout" => connection_timeout,
|
|
327
388
|
"proxy_url" => proxy_url,
|
|
328
389
|
"retries" => retries,
|
|
390
|
+
"protocol" => protocol,
|
|
329
391
|
"payload_stores" => payload_stores.keys,
|
|
330
392
|
"default_payload_store" => default_payload_store_name,
|
|
331
|
-
"secrets" => @mutex.synchronize { @secrets.keys }
|
|
393
|
+
"secrets" => @mutex.synchronize { @secrets.keys },
|
|
394
|
+
"preprocessors" => @mutex.synchronize { @preprocessors.keys }
|
|
332
395
|
}
|
|
333
396
|
end
|
|
334
397
|
|
|
@@ -346,6 +409,20 @@ module PatientHttp
|
|
|
346
409
|
callable || block
|
|
347
410
|
end
|
|
348
411
|
|
|
412
|
+
# Validate that a preprocessor can be invoked with a single positional argument.
|
|
413
|
+
def validate_preprocessor_parameters!(preprocessor)
|
|
414
|
+
method_obj = preprocessor.is_a?(Proc) ? preprocessor : preprocessor.method(:call)
|
|
415
|
+
parameters = method_obj.parameters
|
|
416
|
+
|
|
417
|
+
positional = parameters.count { |type, _| %i[req opt rest].include?(type) }
|
|
418
|
+
required = parameters.count { |type, _| type == :req }
|
|
419
|
+
required_keywords = parameters.count { |type, _| type == :keyreq }
|
|
420
|
+
|
|
421
|
+
if positional.zero? || required > 1 || required_keywords.positive?
|
|
422
|
+
raise ArgumentError.new("preprocessor must accept a single argument")
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
349
426
|
def validate_positive(attribute, value)
|
|
350
427
|
return if value.is_a?(Numeric) && value > 0
|
|
351
428
|
|
|
@@ -88,6 +88,13 @@ module PatientHttp
|
|
|
88
88
|
@headers.include?(name.to_s.downcase)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Ensure copies do not share the underlying storage so that mutating a
|
|
92
|
+
# copy (for example, via #merge) does not modify the original.
|
|
93
|
+
def initialize_copy(other)
|
|
94
|
+
super
|
|
95
|
+
@headers = @headers.dup
|
|
96
|
+
end
|
|
97
|
+
|
|
91
98
|
def eql?(other)
|
|
92
99
|
other.is_a?(HttpHeaders) && @headers.eql?(other.to_h)
|
|
93
100
|
end
|
|
@@ -12,7 +12,7 @@ module PatientHttp
|
|
|
12
12
|
STATES = %i[stopped starting running draining stopping].freeze
|
|
13
13
|
|
|
14
14
|
# Polling interval during wait operations
|
|
15
|
-
POLL_INTERVAL = 0.
|
|
15
|
+
POLL_INTERVAL = 0.01
|
|
16
16
|
|
|
17
17
|
# Initialize the lifecycle manager.
|
|
18
18
|
#
|
|
@@ -66,12 +66,14 @@ module PatientHttp
|
|
|
66
66
|
state == :stopping
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
# Transition to starting state.
|
|
69
|
+
# Transition to starting state. The processor can only be started from
|
|
70
|
+
# the stopped state; in particular, starting a draining processor would
|
|
71
|
+
# spawn a second reactor alongside the one still finishing its drain.
|
|
70
72
|
#
|
|
71
73
|
# @return [Boolean] true if transition was successful
|
|
72
74
|
def start!
|
|
73
75
|
@lock.synchronize do
|
|
74
|
-
return false
|
|
76
|
+
return false unless stopped?
|
|
75
77
|
|
|
76
78
|
@state.set(:starting)
|
|
77
79
|
@shutdown_barrier.reset
|
|
@@ -83,9 +85,18 @@ module PatientHttp
|
|
|
83
85
|
|
|
84
86
|
# Transition to running state.
|
|
85
87
|
#
|
|
86
|
-
#
|
|
88
|
+
# The transition only occurs from the starting state so that a reactor
|
|
89
|
+
# that already failed and transitioned to stopped is not overwritten.
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean] true if transition was successful
|
|
87
92
|
def running!
|
|
88
|
-
@
|
|
93
|
+
@lock.synchronize do
|
|
94
|
+
return false unless starting?
|
|
95
|
+
|
|
96
|
+
@state.set(:running)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
true
|
|
89
100
|
end
|
|
90
101
|
|
|
91
102
|
# Transition to draining state.
|
|
@@ -136,9 +147,10 @@ module PatientHttp
|
|
|
136
147
|
|
|
137
148
|
# Wait for the reactor to be ready.
|
|
138
149
|
#
|
|
139
|
-
# @
|
|
140
|
-
|
|
141
|
-
|
|
150
|
+
# @param timeout [Numeric, nil] maximum time to wait in seconds (nil waits forever)
|
|
151
|
+
# @return [Boolean] true if the reactor is ready, false if the timeout was reached
|
|
152
|
+
def wait_for_reactor(timeout: nil)
|
|
153
|
+
@reactor_ready.wait(timeout)
|
|
142
154
|
end
|
|
143
155
|
|
|
144
156
|
# Check if shutdown has been signaled.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientHttp
|
|
4
|
+
# A mutable view of a request as it is about to be sent, after secret references
|
|
5
|
+
# have been resolved and the send-time headers (x-request-id and the default
|
|
6
|
+
# user-agent) have been set.
|
|
7
|
+
#
|
|
8
|
+
# Preprocessors attached to a request receive this object and can modify the
|
|
9
|
+
# headers or append query parameters before the request goes out -- for example,
|
|
10
|
+
# to sign the request. The HTTP method, URL, and body are read-only; headers can
|
|
11
|
+
# be changed in place and query parameters appended with {#add_param}.
|
|
12
|
+
#
|
|
13
|
+
# @see Configuration#register_preprocessor
|
|
14
|
+
class OutgoingRequest
|
|
15
|
+
# @return [Symbol] HTTP method (:get, :post, :put, :patch, :delete)
|
|
16
|
+
attr_reader :http_method
|
|
17
|
+
|
|
18
|
+
# @return [String] the request URL with any secret query params already resolved
|
|
19
|
+
attr_reader :url
|
|
20
|
+
|
|
21
|
+
# @return [String, nil] the request body
|
|
22
|
+
attr_reader :body
|
|
23
|
+
|
|
24
|
+
# @return [HttpHeaders] mutable, case-insensitive request headers
|
|
25
|
+
attr_reader :headers
|
|
26
|
+
|
|
27
|
+
# Initialize a new OutgoingRequest.
|
|
28
|
+
#
|
|
29
|
+
# @param http_method [Symbol] the HTTP method
|
|
30
|
+
# @param url [String] the resolved request URL
|
|
31
|
+
# @param headers [HttpHeaders] the resolved request headers
|
|
32
|
+
# @param body [String, nil] the request body
|
|
33
|
+
def initialize(http_method:, url:, headers:, body:)
|
|
34
|
+
@http_method = http_method
|
|
35
|
+
@url = url.to_s
|
|
36
|
+
@headers = headers
|
|
37
|
+
@body = body
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Append a query parameter to the request URL.
|
|
41
|
+
#
|
|
42
|
+
# @param name [String, Symbol] the parameter name
|
|
43
|
+
# @param value [Object] the parameter value
|
|
44
|
+
# @return [String] the updated URL
|
|
45
|
+
def add_param(name, value)
|
|
46
|
+
serialized_param = URI.encode_www_form([[name.to_s, value]])
|
|
47
|
+
uri = URI(@url)
|
|
48
|
+
uri.query = [uri.query, serialized_param].compact.reject(&:empty?).join("&")
|
|
49
|
+
@url = uri.to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Inspect the outgoing request. Header values, the query string, and the body
|
|
53
|
+
# are not shown since they may contain resolved secrets.
|
|
54
|
+
#
|
|
55
|
+
# @return [String]
|
|
56
|
+
def inspect
|
|
57
|
+
"#<#{self.class.name} #{http_method.to_s.upcase} #{redacted_url} headers=#{headers.to_h.keys.inspect}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def redacted_url
|
|
63
|
+
uri = URI(@url)
|
|
64
|
+
uri.query = nil
|
|
65
|
+
uri.user = nil
|
|
66
|
+
uri.password = nil
|
|
67
|
+
uri.to_s
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
"<invalid url>"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/patient_http/payload.rb
CHANGED
|
@@ -10,8 +10,11 @@ module PatientHttp
|
|
|
10
10
|
#
|
|
11
11
|
# Thread-safe: Redis clients handle their own thread safety.
|
|
12
12
|
#
|
|
13
|
+
# The client must respond to `set`, `get`, `del`, and `exists` (the
|
|
14
|
+
# interface provided by the `redis` gem).
|
|
15
|
+
#
|
|
13
16
|
# @example Configuration with direct Redis client
|
|
14
|
-
# redis =
|
|
17
|
+
# redis = Redis.new(url: ENV["REDIS_URL"])
|
|
15
18
|
# config.register_payload_store(:redis, adapter: :redis, redis: redis, ttl: 86400)
|
|
16
19
|
class RedisStore < Base
|
|
17
20
|
Base.register :redis, self
|