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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbb8a32f746745abcfd6728c31f6460793440abc3b5bf42f4e0702b7cdd2987e
4
- data.tar.gz: 482823627d13df33a1a262341c2dd2f8512d466f595edbb2414c573d96d7cd8d
3
+ metadata.gz: 393cbf361701d9e1dc190b80f571d78059a21dae85f225750198a1ab173e8bb8
4
+ data.tar.gz: 3d5349e55335d4cd262699481517d3c44825c27449a2b3b11d9559a7b4f468b0
5
5
  SHA512:
6
- metadata.gz: 1e31bfe3ec727df7c534a93b74c4bb66ee36e45b5ba587fb11751198f360d82d9d0ca01ac43c186244bf5b125f73cfb7588e1e438508124f0c25000bdb7b3e42
7
- data.tar.gz: 573b8de414ef3ca0251b7cbb946c33f7e9a4a24eeefa1f54823f8c86454243998a3517935f8dc9612c4f61bcc4a1d7d778d7df5ab74c33cbaad47614d26cf96a
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 = RedisClient.new(url: ENV["REDIS_URL"])
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
- I a secret is not found when resolving a request, a `PatientHttp::SecretManager::SecretNotFoundError` is raised, which surfaces through the normal request error path.
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
1
+ 1.2.0
@@ -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
- headers = request_headers(request, request_id)
26
- url = config.secret_manager.resolve_url(request.url, request.secret_params)
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::EPIPE, Errno::ECONNREFUSED,
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
- def initialize(max_size:, connection_timeout: nil, proxy_url: nil, retries: 3)
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
- proxy_endpoint = configure_endpoint(proxy_endpoint) if @connection_timeout
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
- return endpoint unless @connection_timeout
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.001
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 if starting? || running? || stopping?
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
- # @return [void]
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
- @state.set(:running)
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
- # @return [void]
140
- def wait_for_reactor
141
- @reactor_ready.wait
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
@@ -89,7 +89,7 @@ module PatientHttp
89
89
  return Encoding::ASCII_8BIT.name unless match
90
90
 
91
91
  begin
92
- Encoding.find(match[1])
92
+ Encoding.find(match[1]).name
93
93
  rescue
94
94
  Encoding::ASCII_8BIT.name
95
95
  end
@@ -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 = RedisClient.new(url: ENV["REDIS_URL"])
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