butler-http 0.1.1 → 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 +4 -4
- data/CHANGELOG.md +203 -0
- data/README.md +58 -8
- data/lib/butler/async.rb +48 -0
- data/lib/butler/body.rb +13 -1
- data/lib/butler/configuration.rb +39 -3
- data/lib/butler/connection.rb +11 -1
- data/lib/butler/connection_pool.rb +25 -6
- data/lib/butler/elasticsearch.rb +205 -0
- data/lib/butler/headers.rb +8 -1
- data/lib/butler/pipeline/middlewares/retry_middleware.rb +18 -0
- data/lib/butler/request.rb +13 -1
- data/lib/butler/resilience/circuit_breaker.rb +17 -0
- data/lib/butler/resilience/retry_policy.rb +14 -0
- data/lib/butler/security/tls.rb +41 -2
- data/lib/butler/transport.rb +93 -8
- data/lib/butler/uri.rb +14 -1
- data/lib/butler/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d18e43e5caae734bc0c8acbea07465f8711561bf805508a5938c3e41ce51dec0
|
|
4
|
+
data.tar.gz: 8dbebb55192cf5064bd511d410075cc55753cf73184690bd04ffa2cd66e431ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4e4873585f752cb1982436aecf495ed837ab4ff753cf9f591bce3bf820cfd9bba9e545d80cd387a3fe671954926db5bc7dd93738855f87aac0c2ee37287a71be
|
|
7
|
+
data.tar.gz: 1852f5d0644b66ecce66d51f8bb0ed42386b94f8689dc551da03c9031ce621bac7c79673114b3abbedce377675317d1c24621093c25f29cf648fe2233be60e3d
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,209 @@ 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
|
+
|
|
7
210
|
## 0.1.1
|
|
8
211
|
|
|
9
212
|
- Added `lib/butler/http.rb` (requiring `lib/butler.rb`) so `gem "butler-http"` works with Bundler's
|
data/README.md
CHANGED
|
@@ -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
|
|
410
|
-
class — `ConnectionError`,
|
|
411
|
-
`
|
|
412
|
-
`ProtocolError`, `RetryExhausted`, all of it.
|
|
413
|
-
- `Butler::Errors::CircuitOpen` itself is
|
|
414
|
-
|
|
415
|
-
|
|
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` | `
|
|
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])
|
data/lib/butler/configuration.rb
CHANGED
|
@@ -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 =
|
|
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
|
|
data/lib/butler/connection.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/butler/headers.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
data/lib/butler/request.rb
CHANGED
|
@@ -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
|
-
|
|
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?
|
data/lib/butler/security/tls.rb
CHANGED
|
@@ -8,17 +8,32 @@ class Butler
|
|
|
8
8
|
|
|
9
9
|
def context_for(config)
|
|
10
10
|
context = ::OpenSSL::SSL::SSLContext.new
|
|
11
|
-
|
|
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
|
-
|
|
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"]
|
data/lib/butler/transport.rb
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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)
|
data/lib/butler/version.rb
CHANGED
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.
|
|
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,6 +130,7 @@ 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
|
|
121
136
|
- lib/butler/http.rb
|