butler-http 0.1.0 → 0.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: 38e1ee74faa75f318c5a904421c47d8066192b9f3287ecf975704e784d696f39
4
- data.tar.gz: 7d5806fd27b012e0a2bc418ba40575970483e718747df1d9de90de7a02fc43a0
3
+ metadata.gz: d18e43e5caae734bc0c8acbea07465f8711561bf805508a5938c3e41ce51dec0
4
+ data.tar.gz: 8dbebb55192cf5064bd511d410075cc55753cf73184690bd04ffa2cd66e431ea
5
5
  SHA512:
6
- metadata.gz: 279069875420ca4cf7f1387dbd78cae84a5ad68bc93620fe3371742656607d7607d3c1fed38450ffef7f5f88e14bdab1c6e9826e99dc805c87351035f867646a
7
- data.tar.gz: 832f16c1cbf8aef5dd43ce4fbaa0d5c9a4fee3b573a0d69eaaa84228f206114657d0cacc9872fb8595fedfbb9c1b5e27f2ed431aa2a61514aebbd2dc9f7588ea
6
+ metadata.gz: 4e4873585f752cb1982436aecf495ed837ab4ff753cf9f591bce3bf820cfd9bba9e545d80cd387a3fe671954926db5bc7dd93738855f87aac0c2ee37287a71be
7
+ data.tar.gz: 1852f5d0644b66ecce66d51f8bb0ed42386b94f8689dc551da03c9031ce621bac7c79673114b3abbedce377675317d1c24621093c25f29cf648fe2233be60e3d
data/CHANGELOG.md CHANGED
@@ -4,6 +4,216 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project doesn't yet follow strict semver
5
5
  (pre-1.0 — see [README's "What's not here yet"](README.md#whats-not-here-yet)).
6
6
 
7
+ ## 0.2.0
8
+
9
+ ### Added
10
+
11
+ - **`elastic-transport` integration** (`lib/butler/elasticsearch.rb`,
12
+ opt-in — `require "butler/elasticsearch"` after `elastic-transport`/
13
+ `elasticsearch` is already loaded). `Elastic::Transport::Transport::HTTP::Butler`
14
+ implements elastic-transport's own transport interface — the same one
15
+ `Transport::HTTP::Faraday`/`Transport::HTTP::Manticore` implement, not a
16
+ new extension point — so an `Elasticsearch::Client` can run entirely on
17
+ Butler:
18
+ ```ruby
19
+ Elasticsearch::Client.new(hosts: [...], transport_class: Elastic::Transport::Transport::HTTP::Butler)
20
+ ```
21
+ One `Butler::Client` per ES node, mirroring how the Faraday transport
22
+ builds one Faraday connection per node. Retry behavior is
23
+ host-count-aware: with more than one configured host, elastic-transport's
24
+ own cross-node failover is left in charge (Butler's own retry is set to
25
+ `max_attempts: 1`, avoiding double-retrying underneath that failover and
26
+ avoiding `RetryExhausted`, which elastic-transport doesn't recognize as
27
+ retryable). With exactly one configured host — no other node to fail
28
+ over to — `retry_on_failure`/`delay_on_retry` are translated into
29
+ Butler's own `retry:` config instead, adding real exponential
30
+ backoff+jitter in place of elastic-transport's own zero-backoff retry
31
+ loop, at the same attempt count as before. `host_unreachable_exceptions`
32
+ is mapped to Butler's own connection-level error classes so
33
+ dead-node detection/failover keeps working correctly. Covered by
34
+ `test/butler/elasticsearch_test.rb`, exercised through the real
35
+ `Elastic::Transport::Client` against a `FakeServer` standing in for an ES
36
+ node, not just the adapter class in isolation.
37
+
38
+ One caveat carried over from elastic-transport itself, not introduced
39
+ here: it declares `faraday` as a runtime dependency regardless of
40
+ `transport_class` (`Connections::Connection#full_url` uses
41
+ `Faraday::Utils::ParamsHash` for query-string encoding), so Faraday
42
+ stays in the dependency tree — it just performs no network I/O once
43
+ Butler is selected as the transport.
44
+
45
+ - **`security.ca_file` / `security.ca_path` / `security.client_cert` /
46
+ `security.client_key`** for a private/custom CA or mutual-TLS client
47
+ certificates — previously the only TLS knob was the all-or-nothing
48
+ `security.verify_tls`. `client_cert`/`client_key` accept an
49
+ already-built `OpenSSL::X509::Certificate`/`OpenSSL::PKey::PKey`, a PEM
50
+ string, or a file path.
51
+
52
+ - **`pool.max_connections_per_host`** (`nil`/unbounded by default,
53
+ matching prior behavior) to cap concurrent connections to a single
54
+ origin, using `async-pool`'s own `limit:` option. Bounds how many
55
+ connections a retry storm or a burst of concurrent `client.async` calls
56
+ can open against one struggling upstream. Included in `ConnectionPool`'s
57
+ cache key alongside the other connection-shaping settings (`proxy`,
58
+ `http_version`, `verify_tls`).
59
+
60
+ - **`pool.max_connection_age`** (`nil`/disabled by default) to recycle a
61
+ connection after a configurable age, regardless of how actively it's
62
+ being used. `idle_timeout` only catches a connection gone stale from
63
+ disuse; a connection kept continuously busy never ages out through it.
64
+ Against a host whose public endpoint is a load balancer or proxy in
65
+ front of several backends — true of most managed/cloud services,
66
+ including Elastic Cloud — that could otherwise pin one long-lived
67
+ connection to a single backend indefinitely. Verified separately that
68
+ this isn't needed for connection-death recovery: a connection that
69
+ actually dies mid-use is already detected and rebuilt automatically by
70
+ `async-http`'s own pool.
71
+
72
+ - **An `at_exit` hook** so a process exits normally after a synchronous
73
+ Butler call, without needing to be force-killed. `BackgroundReactor`'s
74
+ thread runs for the life of the process by design, with no exit
75
+ condition of its own; Ruby doesn't end a process while a non-daemon
76
+ thread is still alive, and this gem's target Ruby has removed
77
+ `Thread#daemon=`/`#daemon?` with no replacement. This hook reliably
78
+ covers the thread idling on its ordinary wait — i.e. every normal
79
+ shutdown — though, as tested directly, it can't recover a thread already
80
+ blocked in the Fiber scheduler's own I/O wait on a connection a bug left
81
+ open; that's the responsibility of closing connections correctly (see
82
+ the upload-streaming fix below), not a substitute for it.
83
+
84
+ - A **Butler vs. Faraday benchmark suite for the Elasticsearch adapter**
85
+ (`benchmarks/elasticsearch_comparison.rb`), covering throughput,
86
+ latency percentiles, and TCP connection counts under concurrency.
87
+
88
+ ### Fixed
89
+
90
+ - **`stream: true` (requesting a streamed response) raised `NoMethodError`
91
+ on every call that used it.** `Body.wrap` treated any `:stream` key as
92
+ "wrap this value as the request body," but `stream: true` is the
93
+ documented, separate flag for requesting a streamed *response*, carrying
94
+ no request body at all. The two options collided: `stream: true` was
95
+ always also read as an upload stream, building `StreamBody.new(true)`,
96
+ which raised as soon as anything tried to send it. Found while adding
97
+ the streaming test coverage below (there was none before this). Fixed
98
+ by only treating `:stream` as a request body when its value isn't
99
+ literally `true`; `stream: <enumerable>` for upload streaming is
100
+ unaffected.
101
+
102
+ - **Upload streaming (`stream: <enumerable>`) leaked its connection,
103
+ which could prevent a process from exiting normally.** The same key
104
+ collision above had a second half: `Request.build` read the enumerable
105
+ itself as a truthy `stream:` value, silently also marking the request
106
+ as wanting a streamed response. Nothing ever consumed or closed that
107
+ response, so its connection was never released. Confirmed end to end —
108
+ reliably reproduced (20/20 runs) before the fix, 0/20 after — and fixed
109
+ by applying the same `stream: true`-only disambiguation to
110
+ `Request#stream?`.
111
+
112
+ - **A per-attempt timeout covered dispatching the request, but not
113
+ reading the response body.** `Transport::Async.call` wrapped the
114
+ initial dispatch in `Resilience::Timeout.enforce`, but the body read
115
+ (`Security::Limits.read_body`) happened afterward, outside that
116
+ timeout. A server that sent headers promptly but stalled or drip-fed
117
+ the body could hang regardless of `deadline:`/`read_timeout`/
118
+ `request_timeout`. The full attempt — dispatch and body read — is now
119
+ wrapped in one timeout; the `stream: true` case is unaffected, since
120
+ actual chunk-by-chunk consumption is documented to happen outside the
121
+ attempt timeout, paced by the caller.
122
+
123
+ - **A retryable response cut short by deadline expiry was returned as-is
124
+ instead of raising `Butler::Errors::TimeoutError`.**
125
+ `RetryPolicy#retry_on_response?` treated "not retryable," "max attempts
126
+ reached," and "deadline expired" the same way, but only the deadline
127
+ case should surface as a timeout rather than a normal result. The
128
+ equivalent exception-retry path already handled this correctly; a new
129
+ `RetryPolicy#retryable_response?` (status/method eligibility only, no
130
+ deadline or attempt-count check) lets `RetryMiddleware` distinguish
131
+ "nothing left to retry" from "this would have been retried, but time
132
+ ran out."
133
+
134
+ - **`ConnectionPool`'s cache key omitted `security.ca_file`/`ca_path`/
135
+ `client_cert`/`client_key`.** Two configurations differing only in TLS
136
+ identity — for example, different mutual-TLS client certificates —
137
+ could hash to the same pool entry, so the second configuration's
138
+ requests would reuse a connection authenticated as the first.
139
+ Reproduced directly against `ConnectionPool#acquire` before fixing.
140
+ `client_cert`/`client_key` are fingerprinted by `#object_id` rather
141
+ than content, both to avoid re-parsing cost and so the fingerprint
142
+ never holds actual key material.
143
+
144
+ - **The `elastic-transport` integration raised `NoMethodError` against
145
+ elastic-transport versions predating OpenTelemetry support** — confirmed
146
+ against the published 8.1.0 release, which has no
147
+ `capture_otel_span_attributes` method on `Transport::Base` at all (added
148
+ later, present from 8.5.x). `perform_request` called it unconditionally,
149
+ mirroring `Transport::HTTP::Faraday`'s newer shape without its implicit
150
+ version guard. Found by diffing 8.1.0 against 8.5.3 before relying on
151
+ this integration against an older elastic-transport release, and
152
+ verified fixed by loading the real 8.1.0 gem directly and issuing
153
+ requests through it. Now only called when it actually exists.
154
+
155
+ - **`config.proxy` was accepted but never used** — `Transport::Async.build_endpoint`
156
+ never read it, so requests went straight to the target regardless of a
157
+ configured proxy. Now tunnels through the configured proxy via a real
158
+ HTTP CONNECT (`Async::HTTP::Proxy`), for both HTTP and HTTPS targets;
159
+ `http://user:pass@host:port` proxy URLs send credentials as
160
+ `Proxy-Authorization: Basic ...`. A failed CONNECT is classified as
161
+ `Butler::Errors::ConnectionError`, keeping `rescue Butler::Errors::Error`
162
+ a safe catch-all. Covered by `test/butler/transport_proxy_test.rb`,
163
+ which runs real traffic through a CONNECT-tunneling fake proxy.
164
+
165
+ - **A half-open circuit-breaker probe cancelled via task cancellation
166
+ could leave that host's circuit open permanently.** `CircuitBreaker#call`
167
+ only cleared its `probing` flag on `StandardError`; `Async::Stop` (raised
168
+ when a task is cancelled, e.g. by `Client#async`'s own cleanup after a
169
+ sibling task raised) is an `Exception`, not a `StandardError`, so it
170
+ bypassed that entirely, leaving every subsequent request to that host
171
+ raising `CircuitOpen` until the process restarted. `probing` now clears
172
+ on any non-`StandardError` exception too, without counting it as a
173
+ tracked failure.
174
+
175
+ - **An oversized response leaked its connection.**
176
+ `Security::Limits.check_headers!`/`.read_body` can raise
177
+ `LimitExceeded` before anything takes ownership of closing the
178
+ underlying response; `build_response` didn't account for that, so a
179
+ response tripping `max_header_size`/`max_response_size` leaked its
180
+ connection instead of returning it to the pool. `raw_response` is now
181
+ closed on any error raised before a `Response`/`Stream` exists to take
182
+ over that responsibility.
183
+
184
+ - **A relative path with no `base_url` configured raised a bare
185
+ `URI::InvalidURIError`** instead of a `Butler::Errors::Error`, breaking
186
+ the documented guarantee that `rescue Butler::Errors::Error` is a safe
187
+ catch-all. This and other unparseable URLs now raise
188
+ `Butler::Errors::ConfigurationError` with a message naming the actual
189
+ problem.
190
+
191
+ ### Changed
192
+
193
+ - **`circuit_breaker.enabled` now defaults to `false`.** Opt in explicitly
194
+ (`Butler::Client.new(circuit_breaker: { enabled: true })`, or globally
195
+ via `Butler.configure`) — a breaker tripping unexpectedly for a caller
196
+ who never requested one is a worse surprise than not having one by
197
+ default. This is a default-behavior change: anyone relying on it being
198
+ enabled by default needs to opt in explicitly after upgrading.
199
+
200
+ ### Verified
201
+
202
+ - **Extensive concurrency stress-testing against a real server** — threads
203
+ and `client.async` Fibers, up to 2000 concurrent, connection pool sizes
204
+ down to a single shared connection, mixed GET/POST with randomized
205
+ response/body sizes, and a real mid-body cancellation via `deadline:`.
206
+ Zero response/request mixing found across tens of thousands of
207
+ requests; `async-http` correctly isolates concurrent requests even
208
+ under maximal connection reuse.
209
+
210
+ ## 0.1.1
211
+
212
+ - Added `lib/butler/http.rb` (requiring `lib/butler.rb`) so `gem "butler-http"` works with Bundler's
213
+ default require-name inference. Without it, `Bundler.require` tries `require "butler/http"`
214
+ (hyphen → slash) and raises `LoadError`/`NameError: uninitialized constant ...::Butler` at runtime
215
+ for anyone who didn't know to add `require: "butler"` to their Gemfile line.
216
+
7
217
  ## 0.1.0
8
218
 
9
219
  Initial Fiber-native rearchitecture, replacing an earlier `Net::HTTP`-based implementation.
data/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  ### Modern HTTP for modern Ruby.
4
4
 
5
- [![Gem Version](https://img.shields.io/gem/v/butler-http)](https://rubygems.org/gems/butler-http)
6
- ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red)
7
- ![License](https://img.shields.io/badge/license-MIT-blue)
5
+ [![Gem Version](https://img.shields.io/gem/v/butler-http?style=flat-square)](https://rubygems.org/gems/butler-http)
6
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red?style=flat-square)
7
+ ![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)
8
8
 
9
9
  **Fiber-native concurrency, HTTP/1.1 + HTTP/2, and production-grade
10
10
  resilience in one Ruby HTTP client** — transparent ALPN negotiation,
@@ -47,6 +47,7 @@ end
47
47
  - [Middleware](#middleware)
48
48
  - [Testing — no WebMock/VCR needed](#testing--no-webmockvcr-needed)
49
49
  - [Rails](#rails)
50
+ - [Elasticsearch](#elasticsearch)
50
51
  - [Errors](#errors)
51
52
  - [Configuration reference](#configuration-reference)
52
53
  - [Benchmarks](#benchmarks)
@@ -380,6 +381,8 @@ client.put("/orders/42", json: { status: "shipped" }, idempotent: true) # opt in
380
381
 
381
382
  ### Circuit breaker
382
383
 
384
+ Off by default — opt in per client (or globally via `Butler.configure`):
385
+
383
386
  ```ruby
384
387
  client = Butler::Client.new(
385
388
  circuit_breaker: { enabled: true, scope: :host, failure_threshold: 5, recovery_timeout: 30 },
@@ -406,13 +409,20 @@ The exact rules for what counts, since they matter in production:
406
409
  429) never counts as a circuit-breaker failure this way — Butler
407
410
  doesn't raise on error responses unless `raise_on_error: true`, and the
408
411
  breaker's `failure:` check is specifically `response.server_error?`.
409
- - Any **raised exception** always counts as a failure, regardless of
410
- class — `ConnectionError`, `TimeoutError`, `TLSError` and its
411
- `CertificateVerificationError` subclass, `DNSFailure`,
412
- `ProtocolError`, `RetryExhausted`, all of it.
413
- - `Butler::Errors::CircuitOpen` itself is the one exception excluded —
414
- a short-circuited call (the breaker already open) never counts toward
415
- its own failure count.
412
+ - Any **raised `Butler::Errors::Error`** (or any other `StandardError`)
413
+ always counts as a failure, regardless of class — `ConnectionError`,
414
+ `TimeoutError`, `TLSError` and its `CertificateVerificationError`
415
+ subclass, `DNSFailure`, `ProtocolError`, `RetryExhausted`, all of it.
416
+ - `Butler::Errors::CircuitOpen` itself is excluded — a short-circuited
417
+ call (the breaker already open) never counts toward its own failure
418
+ count.
419
+ - A non-`StandardError` signal cutting a call short — chiefly
420
+ `Async::Stop` from task cancellation (e.g. a sibling task in the same
421
+ `client.async` block raising) — isn't counted as a failure either,
422
+ since it isn't evidence the *host* failed. A half-open probe cut short
423
+ this way still clears its own in-flight state so the next request can
424
+ probe again, rather than leaving the circuit wedged open until the
425
+ process restarts.
416
426
 
417
427
  ### Security
418
428
 
@@ -519,6 +529,44 @@ a service-object pattern, subscribing to `"butler.request"` correctly (and
519
529
  a real subtlety around *where* the event's duration actually lives) — in
520
530
  [docs/rails.md](docs/rails.md).
521
531
 
532
+ ### Elasticsearch
533
+
534
+ Opt-in (not loaded by `require "butler"`) — swaps the official
535
+ `elasticsearch`/`elastic-transport` client's HTTP engine from Faraday to
536
+ Butler, using the same `transport_class:` extension point that gem's own
537
+ `Transport::HTTP::Manticore`/`Transport::HTTP::Curb` are built on:
538
+
539
+ ```ruby
540
+ require "butler"
541
+ require "elasticsearch" # or just "elastic-transport"
542
+ require "butler/elasticsearch"
543
+
544
+ client = Elasticsearch::Client.new(
545
+ hosts: ["https://es.example.com:9200"],
546
+ transport_class: Elastic::Transport::Transport::HTTP::Butler,
547
+ )
548
+
549
+ client.search(index: "my-index", body: { query: { match_all: {} } })
550
+ ```
551
+
552
+ One `Butler::Client` per node — each keeps its own pooled connections,
553
+ independent of every other node in the cluster — with retries forced off
554
+ (`max_attempts: 1`): elastic-transport's own `Base#perform_request` already
555
+ retries and fails over across every node itself, so Butler only needs to
556
+ report a failed node's connection error back cleanly, not retry underneath
557
+ that layer too. `transport_options`/`ssl` (timeouts, a custom CA,
558
+ mutual-TLS client cert, extra headers — see
559
+ [Configuration reference](#configuration-reference)'s `security.*`
560
+ options) map onto the per-node `Butler::Client`'s config.
561
+
562
+ One dependency caveat carried over from elastic-transport itself, not
563
+ introduced by this integration: it declares `faraday` as a runtime
564
+ dependency regardless of `transport_class` (query-string encoding in its
565
+ `Connection#full_url` uses `Faraday::Utils::ParamsHash`), so Faraday stays
566
+ in the dependency tree — it just does zero network I/O once Butler is
567
+ selected. Every actual connection, retry, and TLS handshake goes through
568
+ Butler.
569
+
522
570
  ### Errors
523
571
 
524
572
  Every error Butler raises descends from `Butler::Errors::Error`:
@@ -578,7 +626,7 @@ end
578
626
  | `retry.retryable_status_codes` | `[408,425,429,500,502,503,504]` | |
579
627
  | `retry.base_delay` / `retry.max_delay` | `0.1` / `5.0` | Seconds |
580
628
  | `retry.jitter` | `true` | Equal-jitter (delay scaled by a random factor in `[0.5, 1.0)`) |
581
- | `circuit_breaker.enabled` | `true` | |
629
+ | `circuit_breaker.enabled` | `false` | Opt-in — see [Circuit breaker](#circuit-breaker) |
582
630
  | `circuit_breaker.scope` | `:host` | or `:client`, to share one breaker across every host |
583
631
  | `circuit_breaker.failure_threshold` | `5` | |
584
632
  | `circuit_breaker.recovery_timeout` | `30` | Seconds before a half-open probe is allowed |
@@ -590,6 +638,8 @@ end
590
638
  | `security.strip_credentials_on_redirect` | `true` | |
591
639
  | `pool.max_connections` | `100` | Distinct origins kept warm at once (LRU-evicted past this) |
592
640
  | `pool.idle_timeout` | `60` | Seconds a pooled connection may sit unused before it's rebuilt rather than reused |
641
+ | `pool.max_connections_per_host` | `nil` (unbounded) | Caps concurrent connections to one origin — a retry storm or a burst of concurrent `client.async` calls against one struggling upstream otherwise has nothing stopping it from opening one connection per in-flight request |
642
+ | `pool.max_connection_age` | `nil` (no forced recycling) | Forces a connection to close and rebuild once it's been open this many seconds, however actively it's being used — unlike `idle_timeout` (which only catches a connection gone stale from *disuse*), this bounds how long a *busy* connection stays pinned to whichever single backend it first connected to behind a load balancer/proxy (true of most managed/cloud services) |
593
643
  | `telemetry.enabled` | `true` | |
594
644
  | `telemetry.opentelemetry` | `:auto` | Spans are emitted automatically whenever `opentelemetry-api` is already loaded |
595
645
  | `telemetry.logger` | `nil` (falls back to `Logger.new($stdout)`, or `Rails.logger` under Rails) | |
data/lib/butler/async.rb CHANGED
@@ -83,6 +83,18 @@ class Butler
83
83
  @mutex.synchronize do
84
84
  @thread = nil
85
85
  @pid = nil
86
+ # A forked child inherits the parent's at_exit hooks (and
87
+ # @exit_hook_registered's already-true value) along with
88
+ # everything else in its copied memory — but the Thread object
89
+ # that hook's closure captured belongs to the *parent* and
90
+ # doesn't exist in this process (threads don't survive fork).
91
+ # Without resetting this too, the next ensure_running! in this
92
+ # child spawns its own new reactor thread but skips registering
93
+ # an at_exit for *that* one, since the flag already reads true —
94
+ # leaving exactly the forked-worker case this whole mechanism
95
+ # (and Butler.reset_connections!, which calls this on every
96
+ # Puma/preload_app! fork) exists for uncovered.
97
+ @exit_hook_registered = false
86
98
  end
87
99
  end
88
100
 
@@ -96,9 +108,45 @@ class Butler
96
108
 
97
109
  @pid = ::Process.pid
98
110
  @thread = ::Thread.new { run }
111
+ ensure_exit_hook_registered!
99
112
  end
100
113
  end
101
114
 
115
+ # This thread runs `loop { @jobs.pop; ... }` forever by design (so
116
+ # it's ready for the *next* bare call without respawning), with no
117
+ # exit condition of its own — so without this, a process that made
118
+ # even one bare synchronous Butler call would never exit on its own
119
+ # once the main thread/script finished, full stop, leak or no leak.
120
+ # Ruby removed Thread#daemon=/#daemon? (no replacement — confirmed
121
+ # against this gem's actual target Ruby, which has neither), so an
122
+ # at_exit hook killing this thread is the mechanism available.
123
+ #
124
+ # Scope, confirmed empirically rather than assumed: Thread#kill
125
+ # reliably ends this thread while it's idling on the ordinary
126
+ # `@jobs.pop` wait — covers every normal shutdown. It does *not*
127
+ # reliably end it if the thread is instead blocked inside the Fiber
128
+ # scheduler's own I/O wait on a connection some bug left open and
129
+ # unclosed (confirmed with the exact connection-leak bug fixed
130
+ # elsewhere in this version, before that fix existed: `kill` plus an
131
+ # explicit `join` left the thread still alive). This hook is real
132
+ # insurance against the ordinary case, not a substitute for closing
133
+ # connections correctly — a genuine future leak can still wedge a
134
+ # process open despite it. Abandoning any request this thread is
135
+ # still mid-flight on when the process actually exits is an
136
+ # acceptable, expected cost — any HTTP client loses in-flight
137
+ # work if its process is killed — and is categorically better than a
138
+ # client-library bug silently preventing a script, rake task, or
139
+ # test run from ever exiting on its own. Registered at most once
140
+ # per process (guarded by @exit_hook_registered, itself only ever
141
+ # read/written inside the @mutex-held section above/here).
142
+ def ensure_exit_hook_registered!
143
+ return if @exit_hook_registered
144
+
145
+ @exit_hook_registered = true
146
+ thread = @thread
147
+ at_exit { thread.kill }
148
+ end
149
+
102
150
  def running?
103
151
  @thread&.alive? && @pid == ::Process.pid
104
152
  end
data/lib/butler/body.rb CHANGED
@@ -9,7 +9,19 @@ module Butler::Body
9
9
  JSONBody.new(options[:json])
10
10
  elsif options.key?(:form)
11
11
  StringBody.new(::URI.encode_www_form(options[:form]), content_type: "application/x-www-form-urlencoded")
12
- elsif options.key?(:stream)
12
+ elsif options.key?(:stream) && options[:stream] != true
13
+ # The `stream:` key is overloaded: `stream: true` (a bare boolean)
14
+ # means "give me back a streamed *response*" — handled entirely
15
+ # separately, by Request#stream? / Transport::Async#build_response —
16
+ # and carries no request body at all. `stream: <enumerable>` means
17
+ # "stream *this* as the request body" (uploads). `!= true` is what
18
+ # tells them apart: nothing meant as an upload stream is ever
19
+ # literally the boolean true. Before this check, `stream: true` for
20
+ # a streamed *response* (the common, README-documented case) was
21
+ # always misread as an upload stream too, building
22
+ # StreamBody.new(true) — which blew up the moment anything tried to
23
+ # actually send it (`true` doesn't respond to #each) on *every*
24
+ # streamed-response request, GET included.
13
25
  StreamBody.new(options[:stream])
14
26
  elsif options.key?(:io)
15
27
  IOBody.new(options[:io])
@@ -25,7 +25,7 @@ class Butler::Configuration
25
25
  attr_accessor :enabled, :scope, :failure_threshold, :recovery_timeout, :max_tracked_hosts
26
26
 
27
27
  def initialize
28
- @enabled = true
28
+ @enabled = false
29
29
  @scope = :host # or :client, to share one breaker across every origin
30
30
  @failure_threshold = 5
31
31
  @recovery_timeout = 30
@@ -35,7 +35,8 @@ class Butler::Configuration
35
35
 
36
36
  class SecurityOptions
37
37
  attr_accessor :verify_tls, :allowed_hosts, :blocked_hosts,
38
- :max_response_size, :max_header_size, :strip_credentials_on_redirect
38
+ :max_response_size, :max_header_size, :strip_credentials_on_redirect,
39
+ :ca_file, :ca_path, :client_cert, :client_key
39
40
 
40
41
  def initialize
41
42
  @verify_tls = true
@@ -44,6 +45,19 @@ class Butler::Configuration
44
45
  @max_response_size = 50 * 1024 * 1024
45
46
  @max_header_size = 64 * 1024
46
47
  @strip_credentials_on_redirect = true
48
+ # A private/custom CA (e.g. a self-managed cluster's generated root
49
+ # cert — common for Elasticsearch, internal services, etc.) — nil
50
+ # (default) verifies against the system trust store only, same as
51
+ # before these existed. ca_file: a single PEM bundle; ca_path: a
52
+ # directory of hashed cert files (OpenSSL::SSL::SSLContext#ca_path
53
+ # semantics) — either or both may be set.
54
+ @ca_file = nil
55
+ @ca_path = nil
56
+ # Mutual TLS: an OpenSSL::X509::Certificate (or a PEM string/path —
57
+ # see Security::TLS.context_for) and matching OpenSSL::PKey private
58
+ # key. Both must be set together or neither has any effect.
59
+ @client_cert = nil
60
+ @client_key = nil
47
61
  end
48
62
 
49
63
  def initialize_copy(source)
@@ -54,11 +68,33 @@ class Butler::Configuration
54
68
  end
55
69
 
56
70
  class PoolOptions
57
- attr_accessor :max_connections, :idle_timeout
71
+ attr_accessor :max_connections, :idle_timeout, :max_connections_per_host, :max_connection_age
58
72
 
59
73
  def initialize
60
74
  @max_connections = 100
61
75
  @idle_timeout = 60
76
+ # nil (default) matches async-pool's own default: unbounded concurrent
77
+ # connections to one origin. Set to cap how many sockets Butler will
78
+ # ever hold open to a single host at once — a retry storm or a burst
79
+ # of concurrent `client.async` calls against one struggling upstream
80
+ # otherwise has nothing stopping it from opening one connection per
81
+ # in-flight request.
82
+ @max_connections_per_host = nil
83
+ # nil (default, no forced recycling) preserves the original
84
+ # behavior: a healthy, actively-used connection is kept forever,
85
+ # however long that is. A *busy* connection never ages out via
86
+ # idle_timeout (it's never idle), so against a host whose public
87
+ # endpoint is actually a load balancer or proxy in front of several
88
+ # backends (true of most managed/cloud services — Elastic Cloud
89
+ # included), one long-lived pooled connection stays pinned to
90
+ # whichever single backend it first connected to for as long as it
91
+ # keeps getting used, however unevenly that distributes load. Set
92
+ # this to force a connection to close and rebuild (fresh DNS
93
+ # resolution, a fresh chance at the LB's own distribution) once it's
94
+ # been open this many seconds, regardless of how actively it's being
95
+ # used — a pure availability/distribution knob, unrelated to
96
+ # idle_timeout's "has this connection gone stale from disuse" job.
97
+ @max_connection_age = nil
62
98
  end
63
99
  end
64
100
 
@@ -7,15 +7,25 @@
7
7
  class Butler::Connection
8
8
  attr_reader :origin_key, :async_client, :http_version
9
9
 
10
- def initialize(origin_key:, async_client:, http_version:)
10
+ def initialize(origin_key:, async_client:, http_version:, proxy_client: nil)
11
11
  @origin_key = origin_key
12
12
  @async_client = async_client
13
13
  @http_version = http_version
14
+ @proxy_client = proxy_client
14
15
  end
15
16
 
16
17
  def close
17
18
  @async_client&.close
18
19
  rescue StandardError
19
20
  nil
21
+ ensure
22
+ # Only set when config.proxy routed this connection through a CONNECT
23
+ # tunnel (see Transport::Async.apply_proxy) — the client holding the
24
+ # tunnel to the proxy server itself, distinct from @async_client above.
25
+ begin
26
+ @proxy_client&.close
27
+ rescue StandardError
28
+ nil
29
+ end
20
30
  end
21
31
  end
@@ -6,7 +6,7 @@
6
6
  # reuse the same underlying Async::HTTP::Client instead of rebuilding one
7
7
  # (and paying DNS/TCP/TLS setup again) on every call.
8
8
  class Butler::ConnectionPool
9
- Entry = Struct.new(:connection, :last_used_at)
9
+ Entry = Struct.new(:connection, :last_used_at, :created_at)
10
10
 
11
11
  def initialize(config)
12
12
  @config = config
@@ -20,17 +20,20 @@ class Butler::ConnectionPool
20
20
 
21
21
  @mutex.synchronize do
22
22
  if (entry = @entries.delete(key))
23
- if now - entry.last_used_at <= config.pool.idle_timeout
23
+ max_age = config.pool.max_connection_age
24
+ if now - entry.last_used_at > config.pool.idle_timeout
25
+ entry.connection.close # sat idle past pool.idle_timeout — rebuild fresh below rather than reuse
26
+ elsif max_age && now - entry.created_at > max_age
27
+ entry.connection.close # past pool.max_connection_age, however actively it's been used — see Configuration
28
+ else
24
29
  entry.last_used_at = now
25
30
  @entries[key] = entry # touch: move to the end
26
31
  return entry.connection
27
- else
28
- entry.connection.close # sat idle past pool.idle_timeout — rebuild fresh below rather than reuse
29
32
  end
30
33
  end
31
34
 
32
35
  connection = Butler::Transport.current.build_connection(uri, config)
33
- @entries[key] = Entry.new(connection, now)
36
+ @entries[key] = Entry.new(connection, now, now)
34
37
  evict_if_needed!
35
38
  connection
36
39
  end
@@ -47,7 +50,23 @@ class Butler::ConnectionPool
47
50
 
48
51
  def fingerprint(uri, config)
49
52
  transport_name = Butler::Transport.current.equal?(Butler::Testing::FakeTransport) ? "fake" : "real"
50
- "#{uri.origin_key}|proxy=#{config.proxy}|http_version=#{config.http_version}|verify_tls=#{config.security.verify_tls}|transport=#{transport_name}"
53
+ security = config.security
54
+ # client_cert/client_key by #object_id, never by content: the same
55
+ # Configuration's cert/key reference is reused for every call across a
56
+ # Client's lifetime (the realistic case, and the only one that matters
57
+ # for pooling today -- there's no per-call security override), so
58
+ # identity alone already distinguishes "same client" from "different
59
+ # client" correctly; embedding actual PEM/key content into this string
60
+ # would also risk leaking key material into anything that ever logs a
61
+ # fingerprint. Without ca_file/ca_path/client_cert/client_key here, two
62
+ # Configurations that differ *only* in TLS identity hashed to the same
63
+ # key, so the second call silently got back a connection built from the
64
+ # first's certificate/CA -- a real cross-identity mix-up, not just a
65
+ # missed-reuse inefficiency.
66
+ "#{uri.origin_key}|proxy=#{config.proxy}|http_version=#{config.http_version}|verify_tls=#{security.verify_tls}" \
67
+ "|ca_file=#{security.ca_file}|ca_path=#{security.ca_path}" \
68
+ "|client_cert=#{security.client_cert&.object_id}|client_key=#{security.client_key&.object_id}" \
69
+ "|pool_limit=#{config.pool.max_connections_per_host}|transport=#{transport_name}"
51
70
  end
52
71
 
53
72
  def evict_if_needed!
@@ -0,0 +1,205 @@
1
+ # An elastic-transport HTTP transport backed by Butler instead of Faraday.
2
+ # Not required by `require "butler"` — this is an opt-in integration for a
3
+ # host that already depends on the `elastic-transport` gem (directly, or via
4
+ # the `elasticsearch`/`elasticsearch-api` gems), loaded explicitly:
5
+ #
6
+ # require "butler"
7
+ # require "elastic-transport" # or "elasticsearch" — either defines Elastic::Transport
8
+ # require "butler/elasticsearch"
9
+ #
10
+ # client = Elasticsearch::Client.new(
11
+ # hosts: ["https://es.example.com:9200"],
12
+ # transport_class: Elastic::Transport::Transport::HTTP::Butler,
13
+ # )
14
+ #
15
+ # elastic-transport already supports swapping its HTTP engine this way —
16
+ # Transport::HTTP::Faraday is just its *default* transport_class, and it
17
+ # ships two others built the same way this one is (Transport::HTTP::Curb,
18
+ # Transport::HTTP::Manticore for JRuby) — this follows that exact pattern,
19
+ # not a new extension point invented for Butler.
20
+ #
21
+ # One real constraint carried over from elastic-transport itself, not
22
+ # introduced here: Transport::Connections::Connection#full_url always
23
+ # builds query strings via Faraday::Utils::ParamsHash, and elastic-transport
24
+ # declares `faraday` as one of its own runtime dependencies regardless of
25
+ # transport_class — so Faraday stays in the dependency tree even with this
26
+ # transport selected. It does zero network I/O at that point, though: the
27
+ # actual connections, TLS, retries, and connection pooling for every
28
+ # request all go through Butler once this file is loaded.
29
+ raise LoadError, "butler/elasticsearch requires the elastic-transport gem (or elasticsearch/elasticsearch-api, which depend on it) to be loaded first" unless defined?(::Elastic::Transport::Transport::Base)
30
+
31
+ require "base64"
32
+
33
+ module Elastic
34
+ module Transport
35
+ module Transport
36
+ module HTTP
37
+ # @see Transport::Base
38
+ class Butler
39
+ include Elastic::Transport::Transport::Base
40
+
41
+ # @return [Elastic::Transport::Transport::Response]
42
+ # @see Transport::Base#perform_request
43
+ def perform_request(method, path, params = {}, body = nil, headers = nil, opts = {})
44
+ super do |connection, url|
45
+ # capture_otel_span_attributes only exists on elastic-transport
46
+ # versions that added OpenTelemetry support (present in 8.5.x,
47
+ # absent in 8.1.x's Base module entirely — confirmed by diffing
48
+ # both) — calling it unconditionally would raise NoMethodError
49
+ # on literally every request against an older elastic-transport.
50
+ # Transport::HTTP::Faraday only gained this same call once that
51
+ # support landed too, so skipping it on older versions matches
52
+ # that transport's own behavior at the time, not a Butler-only
53
+ # gap.
54
+ capture_otel_span_attributes(connection, url) if respond_to?(:capture_otel_span_attributes, true)
55
+ body = body ? __convert_to_json(body) : nil
56
+ body, headers = compress_request(body, parse_headers(connection, headers))
57
+
58
+ # Base#perform_request already folded `params` into `url`
59
+ # (Connection#full_url) before handing it to this block, so
60
+ # it must not be passed again here — Butler would otherwise
61
+ # encode it a second time onto what's already a complete,
62
+ # absolute URL.
63
+ response = connection.connection.public_send(
64
+ http_method(method), url, body: body, headers: headers || {},
65
+ )
66
+ Response.new(response.status, decompress_response(response.body), response.headers)
67
+ end
68
+ end
69
+
70
+ # Builds and returns a connection: one Butler::Client per host,
71
+ # exactly like Transport::HTTP::Faraday builds one Faraday
72
+ # connection per host — each keeps its own pooled connections,
73
+ # retry/circuit-breaker state, independent of every other node.
74
+ #
75
+ # @return [Connections::Connection]
76
+ def __build_connection(host, options = {}, block = nil)
77
+ client = ::Butler::Client.new(**client_options_for(host, options))
78
+ Connections::Connection.new(host: host, connection: client)
79
+ end
80
+
81
+ # @return [Array]
82
+ def host_unreachable_exceptions
83
+ [
84
+ ::Butler::Errors::ConnectionError,
85
+ ::Butler::Errors::TimeoutError,
86
+ ::Butler::Errors::TLSError,
87
+ ::Butler::Errors::DNSFailure,
88
+ ::Butler::Errors::ProtocolError,
89
+ ]
90
+ end
91
+
92
+ private
93
+
94
+ def http_method(method)
95
+ name = method.to_s.downcase.to_sym
96
+ return name if ::Butler::Client::HTTP_METHODS.include?(name)
97
+
98
+ raise ArgumentError, "Method #{method} not supported"
99
+ end
100
+
101
+ # Per-request headers merged with whatever this connection's
102
+ # client already carries as default_headers (API-key/custom
103
+ # headers configured globally via transport_options above) —
104
+ # Butler itself also does per-request-overrides-default merging,
105
+ # but compress_request (called right after this) needs the final
106
+ # merged Content-Encoding decision up front.
107
+ def parse_headers(connection, headers)
108
+ defaults = connection.connection.configuration.default_headers
109
+ return defaults.to_h if headers.nil?
110
+ return headers if defaults.empty?
111
+
112
+ defaults.to_h.merge(headers)
113
+ end
114
+
115
+ def client_options_for(host, options)
116
+ transport_options = options[:transport_options] || {}
117
+ request_options = transport_options[:request] || {}
118
+ ssl_options = transport_options[:ssl] || options[:ssl] || {}
119
+
120
+ {
121
+ base_url: __full_url(host),
122
+ connect_timeout: request_options[:open_timeout],
123
+ read_timeout: request_options[:timeout],
124
+ write_timeout: request_options[:timeout],
125
+ headers: default_headers_for(host, transport_options),
126
+ security: security_options_for(ssl_options),
127
+ retry: retry_options_for(options),
128
+ }.compact
129
+ end
130
+
131
+ # With more than one configured host, elastic-transport's own
132
+ # Base#perform_request retry loop is what actually provides value:
133
+ # get_connection cycles to a *different* node on retry (see
134
+ # Connections::Collection/Selector + reload_on_failure), which a
135
+ # single Butler::Client has no way to do since it only ever talks
136
+ # to the one host it was built for in __build_connection above.
137
+ # Leaving Butler's own retry on too would compound underneath
138
+ # that — every Butler retry against this one node happening
139
+ # before elastic-transport ever got to try the next node — and
140
+ # wrap a connection failure as Butler::Errors::RetryExhausted,
141
+ # which isn't in host_unreachable_exceptions and so wouldn't be
142
+ # recognized as failover-worthy at all. So multi-host leaves
143
+ # cross-node failover entirely to elastic-transport.
144
+ #
145
+ # Exactly one configured host (e.g. a single-node ES install, or
146
+ # a load balancer URL in front of a real cluster) is a different
147
+ # story: there's no other node to fail over to, so
148
+ # retry_on_failure/delay_on_retry
149
+ # would just be retrying this same node — hand that fully to
150
+ # Butler instead, which adds real exponential backoff+jitter.
151
+ # elastic-transport's own retry loop has no backoff at all
152
+ # (delay_on_retry defaults to 0ms), so repeated *immediate*
153
+ # retries against an already-struggling single node can make
154
+ # things worse, not better. retry_on_failure's own count is
155
+ # preserved (true => its own DEFAULT_MAX_RETRIES of 3) so
156
+ # switching to Butler doesn't silently change how many attempts
157
+ # a request gets by default — only delay_on_retry, when left
158
+ # unset (0, same default), defers to Butler's own base_delay
159
+ # (0.1s) instead of literally meaning "no delay," since a literal
160
+ # 0 is the bug being fixed here, not a setting to preserve.
161
+ def retry_options_for(options)
162
+ return { max_attempts: 1 } if hosts.size > 1
163
+
164
+ retry_on_failure = options[:retry_on_failure]
165
+ retries =
166
+ case retry_on_failure
167
+ when true then Elastic::Transport::Transport::Base::DEFAULT_MAX_RETRIES
168
+ when Integer then retry_on_failure
169
+ else 0
170
+ end
171
+
172
+ retry_config = { max_attempts: retries + 1, jitter: true }
173
+ delay_on_retry = options[:delay_on_retry].to_i
174
+ retry_config[:base_delay] = delay_on_retry / 1000.0 if delay_on_retry.positive?
175
+ retry_config
176
+ end
177
+
178
+ def default_headers_for(host, transport_options)
179
+ headers = (transport_options[:headers] || {}).dup
180
+ headers["Content-Type"] ||= "application/json"
181
+ headers["Authorization"] ||= basic_auth_header(host) if host[:user]
182
+ headers
183
+ end
184
+
185
+ def basic_auth_header(host)
186
+ "Basic #{::Base64.strict_encode64("#{host[:user]}:#{host[:password]}")}"
187
+ end
188
+
189
+ # Maps elastic-transport's own `ssl:` option shape
190
+ # (verify/ca_file/ca_path/client_cert/client_key — see
191
+ # elastic-transport's README) onto Butler::Configuration::SecurityOptions.
192
+ def security_options_for(ssl_options)
193
+ {
194
+ verify_tls: ssl_options.key?(:verify) ? !!ssl_options[:verify] : nil,
195
+ ca_file: ssl_options[:ca_file],
196
+ ca_path: ssl_options[:ca_path],
197
+ client_cert: ssl_options[:client_cert],
198
+ client_key: ssl_options[:client_key],
199
+ }.compact
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -80,7 +80,14 @@ class Butler::Headers
80
80
  # per-request headers override client-level defaults, rather than
81
81
  # duplicating them).
82
82
  def merge(other)
83
- merged = Butler::Headers.new(@pairs)
83
+ # `dup` (→ #initialize_copy's `@pairs.map(&:dup)`) rather than
84
+ # `Butler::Headers.new(@pairs)`: every pair here is already
85
+ # downcased/stringified, so re-running `.new`'s own `add` (which
86
+ # redoes both of those per pair) on them is pure waste — this runs on
87
+ # every single request (Request.build merges config.default_headers
88
+ # with the per-call headers:, every time, even when there's nothing to
89
+ # actually merge in).
90
+ merged = dup
84
91
  Butler::Headers.coerce(other).each { |k, v| merged[k] = v }
85
92
  merged
86
93
  end
@@ -0,0 +1 @@
1
+ require_relative "../butler"
@@ -35,6 +35,24 @@ class Butler
35
35
  next
36
36
  end
37
37
 
38
+ # retry_on_response? above folds three different reasons into
39
+ # one false: this response was never retryable to begin with
40
+ # (a 200, a non-retryable 4xx — return it, nothing happened),
41
+ # max_attempts was reached (a real RetryExhausted-shaped
42
+ # outcome, but response-based retries don't raise on exhaustion
43
+ # the way the exception path does — returning the last response
44
+ # is the documented behavior there), or the *deadline* ran out
45
+ # before a response that genuinely would have been retried
46
+ # could get another attempt. Only that last case must not
47
+ # silently hand back a stale response as if the call completed
48
+ # normally within its budget — the whole point of deadline: is
49
+ # a caller can rely on either a real response *or*
50
+ # Butler::Errors::TimeoutError, never a response that arrived
51
+ # after they were told to stop waiting.
52
+ if context.deadline.expired? && @retry_policy.retryable_response?(request: context.request, response: response, retry_options: retry_options)
53
+ raise Butler::Errors::TimeoutError, "deadline exceeded after #{attempt} attempt(s), last response was #{response.status}"
54
+ end
55
+
38
56
  return response
39
57
  end
40
58
  end
@@ -21,7 +21,19 @@ class Butler::Request
21
21
  body: body,
22
22
  idempotent: options.fetch(:idempotent, false),
23
23
  basic_auth: options[:basic_auth],
24
- stream: options.fetch(:stream, false),
24
+ # The same collision Body.wrap guards against (see its comment):
25
+ # `stream:` means "give me a streamed response" only when it's
26
+ # literally `true`. `stream: <enumerable>` (upload streaming) must
27
+ # NOT also set this — options.fetch(:stream, false) used to return
28
+ # the enumerable itself (truthy) in that case, silently making
29
+ # Transport::Async#build_response wrap the *response* in a
30
+ # Butler::Stream too, one nobody asked for and nobody consumes or
31
+ # closes. An abandoned Butler::Stream never closes its underlying
32
+ # connection (that only happens via #each_chunk/#close), so every
33
+ # upload-streamed call leaked its connection — real enough to wedge
34
+ # a whole process open indefinitely rather than exit cleanly, since
35
+ # Butler's background reactor thread has live I/O to keep watching.
36
+ stream: options[:stream] == true,
25
37
  )
26
38
  end
27
39
 
@@ -49,11 +49,28 @@ class Butler
49
49
  rescue StandardError
50
50
  record_failure!(state, circuit_breaker_options)
51
51
  raise
52
+ rescue Exception # rubocop:disable Lint/RescueException
53
+ # A non-StandardError signal cutting the attempt short — chiefly
54
+ # Async::Stop, raised into a task when something cancels it (e.g.
55
+ # Client#async's `ensure` stopping sibling tasks after one of them
56
+ # raised). Not evidence the *host* itself is failing, so this
57
+ # shouldn't move failure_count/re-open the circuit the way a real
58
+ # StandardError does — but `probing` must still clear no matter
59
+ # how the attempt ends, or a half-open probe cut short this way
60
+ # leaves it `true` forever, wedging every future request to this
61
+ # host on "circuit half-open probe already in flight" with no way
62
+ # to recover short of restarting the process.
63
+ clear_probing!(state)
64
+ raise
52
65
  end
53
66
  end
54
67
 
55
68
  private
56
69
 
70
+ def clear_probing!(state)
71
+ @mutex.synchronize { state.probing = false }
72
+ end
73
+
57
74
  def fetch(scope_key, circuit_breaker_options)
58
75
  @mutex.synchronize do
59
76
  state = @states.delete(scope_key) || HostState.new(:closed, 0, nil, false)
@@ -45,6 +45,20 @@ class Butler
45
45
  def retry_on_response?(request:, response:, attempt:, deadline:, retry_options:)
46
46
  return false if deadline.expired?
47
47
  return false if attempt >= retry_options.max_attempts
48
+
49
+ retryable_response?(request: request, response: response, retry_options: retry_options)
50
+ end
51
+
52
+ # Whether +response+ would be retried on its own merits — status code
53
+ # + method/idempotency eligibility — ignoring the deadline and
54
+ # attempt-count budget entirely. Separated out from #retry_on_response?
55
+ # so RetryMiddleware can tell "this response was never going to be
56
+ # retried anyway" (a 200, or a 404 — just return it) apart from "this
57
+ # *would* have been retried, but the deadline ran out first" (the
58
+ # caller's deadline: budget was exhausted, which must surface as
59
+ # Butler::Errors::TimeoutError, not a stale response silently handed
60
+ # back as if the call completed normally within budget).
61
+ def retryable_response?(request:, response:, retry_options:)
48
62
  return false unless retry_options.retryable_status_codes.include?(response.status)
49
63
 
50
64
  request.retry_eligible?
@@ -8,17 +8,32 @@ class Butler
8
8
 
9
9
  def context_for(config)
10
10
  context = ::OpenSSL::SSL::SSLContext.new
11
- if config.security.verify_tls
11
+ security = config.security
12
+
13
+ if security.verify_tls
12
14
  # #set_params (not a bare verify_mode= assignment) is what loads
13
15
  # the system's trusted CA store — a plain SSLContext.new has none
14
16
  # at all, so every certificate would fail verification regardless
15
17
  # of how legitimate it is.
16
- context.set_params(verify_mode: ::OpenSSL::SSL::VERIFY_PEER)
18
+ params = { verify_mode: ::OpenSSL::SSL::VERIFY_PEER }
19
+ # A private/custom CA (e.g. a self-managed Elasticsearch cluster's
20
+ # generated root cert) *adds* to the system trust store set up by
21
+ # set_params above rather than replacing it — set_params applies
22
+ # ca_file/ca_path itself when given, so both can be trusted at
23
+ # once (the system roots, and this one extra CA).
24
+ params[:ca_file] = security.ca_file if security.ca_file
25
+ params[:ca_path] = security.ca_path if security.ca_path
26
+ context.set_params(params)
17
27
  else
18
28
  warn_once!
19
29
  context.set_params(verify_mode: ::OpenSSL::SSL::VERIFY_NONE)
20
30
  end
21
31
 
32
+ if security.client_cert && security.client_key
33
+ context.cert = coerce_cert(security.client_cert)
34
+ context.key = coerce_key(security.client_key)
35
+ end
36
+
22
37
  # Async::HTTP::Endpoint's own `alpn_protocols:` option only takes
23
38
  # effect on a context *it* builds internally — handing it a custom
24
39
  # ssl_context (as Butler always does, for verify_mode/CA control)
@@ -29,6 +44,30 @@ class Butler
29
44
  context
30
45
  end
31
46
 
47
+ # Accepts an already-built OpenSSL::X509::Certificate/PKey as-is (so a
48
+ # host that already parsed one, e.g. from a secrets manager, doesn't
49
+ # pay to re-parse it per connection), or a PEM string/file path.
50
+ def coerce_cert(value)
51
+ return value if value.is_a?(::OpenSSL::X509::Certificate)
52
+
53
+ ::OpenSSL::X509::Certificate.new(pem_source(value))
54
+ end
55
+
56
+ def coerce_key(value)
57
+ return value if value.is_a?(::OpenSSL::PKey::PKey)
58
+
59
+ ::OpenSSL::PKey.read(pem_source(value))
60
+ end
61
+
62
+ def pem_source(value)
63
+ ::File.exist?(value) ? ::File.read(value) : value
64
+ rescue ::ArgumentError, ::TypeError
65
+ # File.exist? raises ArgumentError on a string containing a NUL
66
+ # byte (a PEM blob never legitimately would) — treat it as literal
67
+ # PEM content rather than a path either way.
68
+ value
69
+ end
70
+
32
71
  def alpn_protocols_for(config)
33
72
  case config.http_version
34
73
  when :http1 then ["http/1.1"]
@@ -1,7 +1,10 @@
1
1
  require "async"
2
2
  require "async/http/client"
3
3
  require "async/http/endpoint"
4
+ require "async/http/proxy"
4
5
  require "protocol/http/body/buffered"
6
+ require "base64"
7
+ require "uri"
5
8
 
6
9
  class Butler
7
10
  module Transport
@@ -48,23 +51,54 @@ class Butler
48
51
 
49
52
  def build_connection(uri, config)
50
53
  endpoint = build_endpoint(uri, config)
51
- async_client = ::Async::HTTP::Client.new(endpoint, retries: 0)
52
- Butler::Connection.new(origin_key: uri.origin_key, async_client: async_client, http_version: config.http_version)
54
+ proxy_client = nil
55
+
56
+ if config.proxy
57
+ endpoint, proxy_client = apply_proxy(endpoint, config.proxy)
58
+ end
59
+
60
+ # async-pool's own `limit:` (nil => unbounded, its default) — the
61
+ # single knob that actually bounds concurrent *connections* to this
62
+ # origin; ConnectionPool's own pool.max_connections is a different
63
+ # axis entirely (how many distinct origins stay warm at once).
64
+ async_client = ::Async::HTTP::Client.new(endpoint, retries: 0, limit: config.pool.max_connections_per_host)
65
+ Butler::Connection.new(origin_key: uri.origin_key, async_client: async_client, http_version: config.http_version, proxy_client: proxy_client)
53
66
  end
54
67
 
55
68
  def call(connection, request, config, timeout:)
56
69
  headers = request.headers.to_a
57
70
  body = build_request_body(request.body)
58
71
 
59
- raw_response = Butler::Resilience::Timeout.enforce(timeout) do
60
- connection.async_client.public_send(http_method(request.method), request.uri.request_target, headers, body)
72
+ # The full per-attempt budget has to cover reading the response
73
+ # body too, not just getting dispatch()'d back a raw_response —
74
+ # async-http returns as soon as the status line/headers are in
75
+ # hand, with the body available as a separate, lazily-read object.
76
+ # Wrapping only the dispatch call here previously let a server that
77
+ # sent headers promptly but then stalled (or drip-fed) the body
78
+ # hang for however long that actually took, completely ignoring
79
+ # deadline:/read_timeout/request_timeout — the one thing a request
80
+ # timeout exists to bound. The streaming case (request.stream?)
81
+ # is unaffected: build_response just wraps raw_response.body in a
82
+ # Butler::Stream there without reading anything yet, so this still
83
+ # only bounds getting the Stream object itself, exactly as
84
+ # documented — actual stream consumption stays the caller's own
85
+ # pacing, outside the attempt timeout, same as before.
86
+ Butler::Resilience::Timeout.enforce(timeout) do
87
+ raw_response = connection.async_client.public_send(http_method(request.method), request.uri.request_target, headers, body)
88
+ build_response(request, config, raw_response)
61
89
  end
62
-
63
- build_response(request, config, raw_response)
64
90
  rescue ::Errno::ECONNREFUSED, ::Errno::ECONNRESET, ::Errno::EPIPE, ::EOFError, ::SocketError => e
65
91
  raise Butler::Errors::ConnectionError, "#{e.class}: #{e.message} requesting #{request.uri}"
66
92
  rescue ::OpenSSL::SSL::SSLError => e
67
93
  raise tls_error_class(e), "#{e.class}: #{e.message} requesting #{request.uri}"
94
+ rescue ::Async::HTTP::Proxy::ConnectFailure => e
95
+ # Raised by Async::HTTP::Proxy#connect when the proxy itself refuses
96
+ # the CONNECT tunnel (bad credentials, proxy down, target rejected) —
97
+ # only reachable once config.proxy is actually wired up (see
98
+ # apply_proxy above). Reclassified so `rescue Butler::Errors::Error`
99
+ # stays a safe top-level catch-all, same as every other transport
100
+ # failure here.
101
+ raise Butler::Errors::ConnectionError, "#{e.message} requesting #{request.uri} via proxy"
68
102
  end
69
103
 
70
104
  # OpenSSL's own verification-failure text is a stable, unified
@@ -88,6 +122,43 @@ class Butler
88
122
  ::Async::HTTP::Endpoint.parse(uri.origin_key, **options)
89
123
  end
90
124
 
125
+ # Wraps +endpoint+ so connecting to it tunnels through an HTTP(S)
126
+ # forward proxy via CONNECT (async-http's own Async::HTTP::Proxy,
127
+ # required above) instead of dialing the target directly — this is
128
+ # what actually makes config.proxy do anything; previously it was
129
+ # read into the connection-pool cache key but never passed to the
130
+ # transport at all. Works for both plain-HTTP and TLS targets: the
131
+ # CONNECT tunnel just carries bytes, and Endpoint itself layers TLS on
132
+ # top when the *target* URL is https, same as the non-proxied path.
133
+ #
134
+ # @returns [Array(Async::HTTP::Endpoint, Async::HTTP::Client)] the
135
+ # proxied endpoint to hand to the real Async::HTTP::Client, and the
136
+ # client holding the connection to the proxy server itself (kept
137
+ # only so Butler::Connection#close can close it too).
138
+ def apply_proxy(endpoint, proxy)
139
+ proxy_uri = ::URI.parse(proxy)
140
+ proxy_endpoint = ::Async::HTTP::Endpoint.parse(
141
+ "#{proxy_uri.scheme}://#{proxy_uri.host}:#{proxy_uri.port}",
142
+ )
143
+ proxy_client = ::Async::HTTP::Client.new(proxy_endpoint, retries: 0)
144
+
145
+ [proxy_client.proxied_endpoint(endpoint, proxy_auth_headers(proxy_uri)), proxy_client]
146
+ end
147
+
148
+ # QuotaGuard-style proxy URLs (http://user:pass@host:port, the
149
+ # standard shape every "static outbound IP" add-on uses) carry
150
+ # credentials the proxy itself expects as Proxy-Authorization, not as
151
+ # part of the CONNECT target — URI#user/#password strip them out of
152
+ # proxy_uri.host/.port above, so they have to be re-attached here
153
+ # explicitly or they're silently dropped.
154
+ def proxy_auth_headers(proxy_uri)
155
+ return nil unless proxy_uri.user
156
+
157
+ user = ::URI.decode_www_form_component(proxy_uri.user)
158
+ password = ::URI.decode_www_form_component(proxy_uri.password.to_s)
159
+ [["proxy-authorization", "Basic #{::Base64.strict_encode64("#{user}:#{password}")}"]]
160
+ end
161
+
91
162
  def http_method(method)
92
163
  method.to_s.downcase.to_sym
93
164
  end
@@ -100,19 +171,33 @@ class Butler
100
171
 
101
172
  def build_response(request, config, raw_response)
102
173
  headers = Butler::Headers.from_a(raw_response.headers.to_a)
103
- Butler::Security::Limits.check_headers!(headers, config)
174
+ close_on_error(raw_response) { Butler::Security::Limits.check_headers!(headers, config) }
104
175
 
105
176
  body =
106
177
  if request.stream?
107
178
  Butler::Stream.new { |&block| raw_response.body&.each(&block) }.on_close { raw_response.close }
108
179
  else
109
- buffered = Butler::Security::Limits.read_body(raw_response.body, config)
180
+ buffered = close_on_error(raw_response) { Butler::Security::Limits.read_body(raw_response.body, config) }
110
181
  raw_response.close
111
182
  buffered
112
183
  end
113
184
 
114
185
  Butler::Response.new(uri: request.uri, status: raw_response.status, headers: headers, body: body)
115
186
  end
187
+
188
+ # Limits.check_headers!/read_body can both raise LimitExceeded before
189
+ # anything else has taken ownership of closing raw_response (the
190
+ # Stream case above only needs this via its own on_close — building it
191
+ # never raises). Without this, an oversized response leaks the
192
+ # underlying connection every time the limit trips: repeated hits
193
+ # (a misbehaving or hostile upstream) would eventually exhaust the
194
+ # connection pool/file descriptors instead of just failing the request.
195
+ def close_on_error(raw_response)
196
+ yield
197
+ rescue StandardError
198
+ raw_response.close
199
+ raise
200
+ end
116
201
  end
117
202
  end
118
203
  end
data/lib/butler/uri.rb CHANGED
@@ -7,7 +7,20 @@ class Butler::URI
7
7
 
8
8
  def self.build(base_url, path = nil, params = nil)
9
9
  resolved = resolve(base_url, path)
10
- new(::URI.parse(resolved)).with_params(params)
10
+
11
+ begin
12
+ parsed = ::URI.parse(resolved)
13
+ rescue ::URI::InvalidURIError => e
14
+ raise Butler::Errors::ConfigurationError, "invalid request URL #{resolved.inspect}: #{e.message}"
15
+ end
16
+
17
+ unless parsed.host
18
+ raise Butler::Errors::ConfigurationError,
19
+ "#{path.inspect} is not an absolute URL and no base_url is configured " \
20
+ "(set base_url: on Butler::Client.new/Butler.configure, or pass an absolute URL)"
21
+ end
22
+
23
+ new(parsed).with_params(params)
11
24
  end
12
25
 
13
26
  def self.resolve(base_url, path)
@@ -1,3 +1,3 @@
1
1
  class Butler
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: butler-http
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ram Laxman Yadav
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '8.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: elastic-transport
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '8.5'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '8.5'
96
110
  description: |
97
111
  Butler is the modern Ruby HTTP client built for concurrent, resilient
98
112
  applications: Fiber-native concurrency, HTTP/1.1 + HTTP/2 with
@@ -116,8 +130,10 @@ files:
116
130
  - lib/butler/configuration.rb
117
131
  - lib/butler/connection.rb
118
132
  - lib/butler/connection_pool.rb
133
+ - lib/butler/elasticsearch.rb
119
134
  - lib/butler/errors.rb
120
135
  - lib/butler/headers.rb
136
+ - lib/butler/http.rb
121
137
  - lib/butler/pipeline/chain.rb
122
138
  - lib/butler/pipeline/context.rb
123
139
  - lib/butler/pipeline/middleware.rb