butler-http 0.1.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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +22 -0
- data/README.md +659 -0
- data/Rakefile +10 -0
- data/lib/butler/async.rb +130 -0
- data/lib/butler/body.rb +130 -0
- data/lib/butler/client.rb +206 -0
- data/lib/butler/configuration.rb +161 -0
- data/lib/butler/connection.rb +21 -0
- data/lib/butler/connection_pool.rb +64 -0
- data/lib/butler/errors.rb +78 -0
- data/lib/butler/headers.rb +106 -0
- data/lib/butler/pipeline/chain.rb +18 -0
- data/lib/butler/pipeline/context.rb +21 -0
- data/lib/butler/pipeline/middleware.rb +16 -0
- data/lib/butler/pipeline/middlewares/circuit_breaker_middleware.rb +21 -0
- data/lib/butler/pipeline/middlewares/retry_middleware.rb +67 -0
- data/lib/butler/pipeline/middlewares/security_middleware.rb +14 -0
- data/lib/butler/pipeline/middlewares/telemetry_middleware.rb +37 -0
- data/lib/butler/pipeline/middlewares/timeout_middleware.rb +17 -0
- data/lib/butler/quic/crypto/aead.rb +69 -0
- data/lib/butler/quic/crypto/header_protection.rb +51 -0
- data/lib/butler/quic/crypto/hkdf.rb +44 -0
- data/lib/butler/quic/crypto/key_schedule.rb +50 -0
- data/lib/butler/quic/packet.rb +113 -0
- data/lib/butler/quic/varint.rb +56 -0
- data/lib/butler/rails/notifications.rb +10 -0
- data/lib/butler/rails/railtie.rb +24 -0
- data/lib/butler/request.rb +92 -0
- data/lib/butler/resilience/backoff.rb +17 -0
- data/lib/butler/resilience/circuit_breaker.rb +120 -0
- data/lib/butler/resilience/deadline.rb +55 -0
- data/lib/butler/resilience/retry_policy.rb +76 -0
- data/lib/butler/resilience/timeout.rb +22 -0
- data/lib/butler/response.rb +81 -0
- data/lib/butler/security/host_policy.rb +29 -0
- data/lib/butler/security/limits.rb +39 -0
- data/lib/butler/security/redirect_policy.rb +28 -0
- data/lib/butler/security/tls.rb +51 -0
- data/lib/butler/stream.rb +44 -0
- data/lib/butler/telemetry/instrumentation.rb +59 -0
- data/lib/butler/telemetry/logger.rb +37 -0
- data/lib/butler/telemetry/open_telemetry_bridge.rb +46 -0
- data/lib/butler/testing/fake_transport.rb +29 -0
- data/lib/butler/testing/stub.rb +68 -0
- data/lib/butler/testing/stub_registry.rb +32 -0
- data/lib/butler/testing.rb +33 -0
- data/lib/butler/transport.rb +118 -0
- data/lib/butler/uri.rb +77 -0
- data/lib/butler/version.rb +3 -0
- data/lib/butler.rb +176 -0
- data/sig/butler/client.rbs +41 -0
- data/sig/butler/configuration.rbs +63 -0
- data/sig/butler/errors.rbs +67 -0
- data/sig/butler/headers.rbs +22 -0
- data/sig/butler/request.rbs +21 -0
- data/sig/butler/response.rbs +28 -0
- data/sig/butler.rbs +22 -0
- metadata +191 -0
data/README.md
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
# Butler
|
|
2
|
+
|
|
3
|
+
### Modern HTTP for modern Ruby.
|
|
4
|
+
|
|
5
|
+
[](https://rubygems.org/gems/butler-http)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
**Fiber-native concurrency, HTTP/1.1 + HTTP/2, and production-grade
|
|
10
|
+
resilience in one Ruby HTTP client** — transparent ALPN negotiation,
|
|
11
|
+
structured concurrency, built-in resilience, and security-conscious
|
|
12
|
+
defaults, without ever exposing its transport in the public API.
|
|
13
|
+
|
|
14
|
+
One client. Modern concurrency. Production-ready HTTP.
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
# HTTParty-style, zero setup:
|
|
18
|
+
Butler.get("https://api.example.com/users").json
|
|
19
|
+
|
|
20
|
+
# Or a configured Client, for connection pooling/retries/middleware tuned per-API:
|
|
21
|
+
client = Butler::Client.new(base_url: "https://api.example.com")
|
|
22
|
+
|
|
23
|
+
client.async do |tasks|
|
|
24
|
+
users = tasks.async { client.get("/users") }
|
|
25
|
+
orders = tasks.async { client.get("/orders") }
|
|
26
|
+
{ users: users.wait.json, orders: orders.wait.json }
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Table of contents
|
|
31
|
+
|
|
32
|
+
- [Why Butler](#why-butler)
|
|
33
|
+
- [Installation](#installation)
|
|
34
|
+
- [Quick start](#quick-start)
|
|
35
|
+
- [Architecture, in one picture](#architecture-in-one-picture)
|
|
36
|
+
- [Usage](#usage)
|
|
37
|
+
- [Module-level shortcuts](#module-level-shortcuts)
|
|
38
|
+
- [Requests](#requests)
|
|
39
|
+
- [Bodies](#bodies)
|
|
40
|
+
- [Streaming](#streaming)
|
|
41
|
+
- [Structured concurrency](#structured-concurrency)
|
|
42
|
+
- [Deadlines and timeouts](#deadlines-and-timeouts)
|
|
43
|
+
- [Retries](#retries)
|
|
44
|
+
- [Circuit breaker](#circuit-breaker)
|
|
45
|
+
- [Security](#security)
|
|
46
|
+
- [Telemetry](#telemetry)
|
|
47
|
+
- [Middleware](#middleware)
|
|
48
|
+
- [Testing — no WebMock/VCR needed](#testing--no-webmockvcr-needed)
|
|
49
|
+
- [Rails](#rails)
|
|
50
|
+
- [Errors](#errors)
|
|
51
|
+
- [Configuration reference](#configuration-reference)
|
|
52
|
+
- [Benchmarks](#benchmarks)
|
|
53
|
+
- [What's not here yet](#whats-not-here-yet)
|
|
54
|
+
- [Development](#development)
|
|
55
|
+
- [Contributing](#contributing)
|
|
56
|
+
- [License](#license)
|
|
57
|
+
|
|
58
|
+
## Why Butler
|
|
59
|
+
|
|
60
|
+
Butler is the modern Ruby HTTP client built for concurrent, resilient
|
|
61
|
+
applications. Ruby's HTTP client landscape is a set of trade-offs, not a
|
|
62
|
+
clear winner:
|
|
63
|
+
|
|
64
|
+
| Client | Strength | Limitation |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `Net::HTTP` | Standard library | Low-level, no HTTP/2, easy to misuse (no default timeouts) |
|
|
67
|
+
| Faraday | Excellent ecosystem/middleware | The adapter/middleware abstraction itself adds a layer of indirection |
|
|
68
|
+
| Excon | Performance-focused | Less opinionated about resilience/DX out of the box |
|
|
69
|
+
| HTTP.rb | Ruby-friendly API | Different concurrency/transport model than Fiber-based servers |
|
|
70
|
+
| HTTParty | The simplest possible `ClassName.get(url)` call | No connection reuse across calls, no HTTP/2, no built-in resilience |
|
|
71
|
+
| Async::HTTP | Excellent async foundation | Lower-level; you build the client-facing API and resilience yourself |
|
|
72
|
+
|
|
73
|
+
Faraday gives you an ecosystem. Butler gives you a modern HTTP runtime —
|
|
74
|
+
a small, deliberately-scoped public API (`Client`, `Request`, `Response`,
|
|
75
|
+
`Headers`) in front of real HTTP/1.1 **and** HTTP/2, Fiber-native
|
|
76
|
+
concurrency, and resilience/security/observability that don't require four
|
|
77
|
+
extra gems to get.
|
|
78
|
+
|
|
79
|
+
| Capability | Butler | Faraday | Excon | Net::HTTP |
|
|
80
|
+
| --- | --- | --- | --- | --- |
|
|
81
|
+
| Fiber-native concurrency | ✓ | — | — | limited |
|
|
82
|
+
| HTTP/2 (ALPN, multiplexed) | ✓ (first-class) | adapter-dependent | limited | depends |
|
|
83
|
+
| Connection pooling | ✓ | adapter | ✓ | manual |
|
|
84
|
+
| Retries + backoff/jitter | built-in | middleware | limited | manual |
|
|
85
|
+
| Circuit breaker | built-in | external gem | external gem | external gem |
|
|
86
|
+
| Deadlines (total budget across retries) | ✓ | — | — | — |
|
|
87
|
+
| OpenTelemetry | first-class, optional | external | external | external |
|
|
88
|
+
| Native request stubbing | ✓ | via WebMock | via WebMock | via WebMock |
|
|
89
|
+
| Rails integration | optional, not required | good | good | basic |
|
|
90
|
+
|
|
91
|
+
**Async::HTTP gives you the foundation. Butler gives you the
|
|
92
|
+
application-facing client.** That's the one architectural rule that keeps
|
|
93
|
+
this from rotting into "Faraday but slower": Butler owns its public
|
|
94
|
+
abstractions. `async`/`async-http` implement the real transport
|
|
95
|
+
underneath, but nothing outside `lib/butler/transport.rb` ever touches
|
|
96
|
+
those types — the public API (`Client`/`Request`/`Response`/`Headers`)
|
|
97
|
+
doesn't change if the transport underneath it ever does. See
|
|
98
|
+
[docs/architecture.md](docs/architecture.md).
|
|
99
|
+
|
|
100
|
+
## Installation
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
# Gemfile
|
|
104
|
+
gem "butler-http"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
bundle install
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Or without Bundler:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
gem install butler-http
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Requires Ruby >= 3.3 — matching the floor both runtime dependencies
|
|
118
|
+
declare themselves (`async 2.45.1`/`async-http 0.103.0`, checked
|
|
119
|
+
2026-09-02; a lower claim here would just mean `bundle install` fails on
|
|
120
|
+
whatever Ruby actually can't satisfy them). Runtime dependencies are
|
|
121
|
+
`async` and `async-http` (both from the
|
|
122
|
+
[socketry](https://github.com/socketry) ecosystem) — Butler keeps its own
|
|
123
|
+
dependency footprint to just those two, so it stays a
|
|
124
|
+
reasonable choice for non-Rails Ruby projects too, not just Rails apps that
|
|
125
|
+
already pull in a large dependency tree.
|
|
126
|
+
|
|
127
|
+
## Quick start
|
|
128
|
+
|
|
129
|
+
The fastest way in — module-level calls, HTTParty-style, no `Client` to set
|
|
130
|
+
up first:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
require "butler"
|
|
134
|
+
|
|
135
|
+
response = Butler.get("https://api.example.com/users")
|
|
136
|
+
response.status # => 200
|
|
137
|
+
response.headers # => Butler::Headers
|
|
138
|
+
response.body # => raw body String
|
|
139
|
+
response.json # => parsed JSON (Hash/Array), or nil if the body isn't valid JSON
|
|
140
|
+
|
|
141
|
+
response = Butler.post("https://api.example.com/users", json: { name: "Ram", email: "ram@example.com" })
|
|
142
|
+
Butler.get("https://api.example.com/users", headers: { "Authorization" => "Bearer token" })
|
|
143
|
+
Butler.get("https://api.example.com/users", params: { page: 2, limit: 50 })
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
`Butler.get`/`.post`/`.put`/`.patch`/`.delete`/`.head`/`.options` all run
|
|
147
|
+
against `Butler.default_client` — a real, connection-pooled
|
|
148
|
+
`Butler::Client` built lazily on first use and shared for the life of the
|
|
149
|
+
process, so repeated module-level calls still get pooling/retries/a
|
|
150
|
+
circuit breaker rather than reconnecting from scratch every time. Reach
|
|
151
|
+
for `Butler::Client.new` instead once you want configuration scoped to one
|
|
152
|
+
API — a fixed `base_url`, its own retry policy, middleware — rather than
|
|
153
|
+
sharing the one global default:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
client = Butler::Client.new(base_url: "https://api.example.com")
|
|
157
|
+
|
|
158
|
+
response = client.get("/users") # same Response API as Butler.get above
|
|
159
|
+
response = client.post("/users", json: { name: "Ram", email: "ram@example.com" })
|
|
160
|
+
client.get("/users", headers: { "Authorization" => "Bearer token" })
|
|
161
|
+
client.get("/users", params: { page: 2, limit: 50 })
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Architecture, in one picture
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
Client
|
|
168
|
+
└─ Pipeline::Chain
|
|
169
|
+
├─ SecurityMiddleware (host allow/block list — before any socket is touched)
|
|
170
|
+
├─ TelemetryMiddleware (Instrumentation + optional OpenTelemetry span)
|
|
171
|
+
├─ [any middleware you add via Client#use]
|
|
172
|
+
├─ TimeoutMiddleware (total-deadline checkpoint)
|
|
173
|
+
├─ CircuitBreakerMiddleware
|
|
174
|
+
└─ RetryMiddleware (the only middleware with a loop)
|
|
175
|
+
└─ ConnectionPool#acquire
|
|
176
|
+
└─ Transport.current (Transport::Async, or Testing::FakeTransport when stubbing)
|
|
177
|
+
└─ Async::HTTP::Client (HTTP/1.1 or HTTP/2 — chosen via ALPN)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Only `lib/butler/transport.rb` ever imports `Async::HTTP`/`Protocol::HTTP`.
|
|
181
|
+
Everything above it is pure `Butler::Request`/`Butler::Response`/
|
|
182
|
+
`Butler::Headers` — swap the transport later (a real HTTP/3 implementation,
|
|
183
|
+
say) and nothing above this line changes. `ConnectionPool` itself owns
|
|
184
|
+
less than it might look like: it's a thin registry mapping one
|
|
185
|
+
origin+protocol fingerprint to one memoized `Async::HTTP::Client` —
|
|
186
|
+
HTTP/1.1 connection-level pooling and HTTP/2 stream multiplexing are
|
|
187
|
+
`Async::HTTP::Client`'s own job underneath that, not reimplemented by
|
|
188
|
+
Butler. Full request lifecycle, the deadline model, the redirect-handling
|
|
189
|
+
design, and exactly what the pool does and doesn't own are in
|
|
190
|
+
[docs/architecture.md](docs/architecture.md#why-connectionpool-isnt-a-socket-pool).
|
|
191
|
+
|
|
192
|
+
## Usage
|
|
193
|
+
|
|
194
|
+
### Module-level shortcuts
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
Butler.get("https://api.example.com/users")
|
|
198
|
+
Butler.post("https://api.example.com/users", json: { name: "Ram" })
|
|
199
|
+
# put/patch/delete/head/options all work the same way
|
|
200
|
+
|
|
201
|
+
Butler.async do |tasks|
|
|
202
|
+
a = tasks.async { Butler.get("https://api.example.com/a") }
|
|
203
|
+
b = tasks.async { Butler.get("https://api.example.com/b") }
|
|
204
|
+
[a.wait, b.wait]
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Every `Butler.<method>` call is `Butler.default_client.<method>` —
|
|
209
|
+
`Butler.default_client` is a real `Butler::Client`, built lazily the first
|
|
210
|
+
time any module-level call is made and memoized for the life of the
|
|
211
|
+
process, so it keeps its own connection pool and circuit-breaker state
|
|
212
|
+
across calls exactly like a `Client` you built yourself would, rather than
|
|
213
|
+
reconnecting from scratch on every call the way a purely stateless
|
|
214
|
+
`ClassName.get` API would have to.
|
|
215
|
+
|
|
216
|
+
It snapshots `Butler.configuration` as of whenever it's first built — the
|
|
217
|
+
same way `Butler::Client.new` always has:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
Butler.configure { |config| config.base_url = "https://api.example.com" }
|
|
221
|
+
Butler.get("/users") # relative paths now work against that base_url
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Call `Butler.configure` before your first module-level call. Reconfiguring
|
|
225
|
+
afterward doesn't retroactively change the already-built default client —
|
|
226
|
+
call `Butler.reset_default_client!` (closes its connections first) if you
|
|
227
|
+
need a later configuration change to take effect, or just switch to
|
|
228
|
+
`Butler::Client.new` once you're reaching for more than one or two
|
|
229
|
+
settings.
|
|
230
|
+
|
|
231
|
+
### Requests
|
|
232
|
+
|
|
233
|
+
All of `get`, `post`, `put`, `patch`, `delete`, `head`, and `options` share
|
|
234
|
+
the same signature: `client.method(path = nil, **options)`.
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
client.get("/users")
|
|
238
|
+
client.get("/users", params: { page: 2, limit: 50 })
|
|
239
|
+
client.get("/users", headers: { "Authorization" => "Bearer token" })
|
|
240
|
+
client.get("https://other-host.example.com/anything") # an absolute URL overrides base_url for one call
|
|
241
|
+
client.get("/users", basic_auth: ["alice", "secret"])
|
|
242
|
+
client.get("/users", basic_auth: { username: "alice", password: "secret" })
|
|
243
|
+
client.get("/users", http_version: :http1) # force this one call onto HTTP/1.1
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Option | Description |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| `params:` | Hash merged into the URL's query string |
|
|
249
|
+
| `headers:` | Hash of request headers (per-request headers override client-level defaults) |
|
|
250
|
+
| `basic_auth:` | `[user, password]` or `{ username:, password: }` |
|
|
251
|
+
| `idempotent:` | Marks a PUT/DELETE as safe to retry on a retryable status (POST is never auto-retried regardless) |
|
|
252
|
+
| `deadline:` | Per-call total wall-clock budget in seconds, overriding the client default |
|
|
253
|
+
| `stream:` | `true` to get back a `Butler::Stream` (see [Streaming](#streaming)) instead of a fully-buffered body |
|
|
254
|
+
| `http_version:` | `:auto`, `:http1`, or `:http2` for this call only, overriding the client's `http_version` (see [Configuration reference](#configuration-reference)) — gets its own pooled connection per origin, kept separate from calls using the client default |
|
|
255
|
+
|
|
256
|
+
### Bodies
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
client.post("/users", json: { name: "Ram" }) # application/json
|
|
260
|
+
client.post("/users", form: { name: "Ram", role: "admin" }) # application/x-www-form-urlencoded
|
|
261
|
+
client.post("/upload", body: File.open("report.csv")) # streamed from the IO, chunk by chunk
|
|
262
|
+
client.post("/upload", io: File.open("report.csv")) # equivalent, more explicit
|
|
263
|
+
client.post("/webhook", body: "raw string")
|
|
264
|
+
client.post("/webhook", stream: some_enumerator_of_chunks) # a caller-driven upload stream
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Streaming
|
|
268
|
+
|
|
269
|
+
Request uploads and response bodies can both be streamed rather than fully
|
|
270
|
+
buffered into memory:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
response = client.get("/export.csv", stream: true)
|
|
274
|
+
response.stream.each_chunk { |chunk| output.write(chunk) }
|
|
275
|
+
# stream releases the underlying connection once consumption finishes —
|
|
276
|
+
# implicitly, via #each_chunk's own ensure, or explicitly via response.stream.close
|
|
277
|
+
# or, if you do want it all in memory after all:
|
|
278
|
+
response.body # reads the whole stream and buffers it
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`response.stream` always releases when you're done with it — either
|
|
282
|
+
implicitly (`#each_chunk`'s own `ensure` calls `#close` once iteration
|
|
283
|
+
finishes or the block raises) or explicitly via `response.stream.close`, and
|
|
284
|
+
it's safe to call `#close` more than once. What "released" means depends on
|
|
285
|
+
how much you actually read: a fully-consumed stream lets the underlying
|
|
286
|
+
HTTP/1.1 keep-alive connection (or HTTP/2 stream) go back to the pool for
|
|
287
|
+
reuse; abandoning a stream early — breaking out of `#each_chunk` partway
|
|
288
|
+
through — can force a real connection close instead, since the transport
|
|
289
|
+
can no longer guarantee it knows where the next response would start on
|
|
290
|
+
that same connection.
|
|
291
|
+
|
|
292
|
+
### Structured concurrency
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
client.async do |tasks|
|
|
296
|
+
a = tasks.async { client.get("/a") }
|
|
297
|
+
b = tasks.async { client.get("/b") }
|
|
298
|
+
[a.wait, b.wait]
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`client.async` gives you structured concurrency for callers that want
|
|
303
|
+
several HTTP calls to overlap: exiting the block always resolves any
|
|
304
|
+
child task you forgot to `.wait`, and cancels anything still running
|
|
305
|
+
after an exception — an error raised inside one child task propagates out
|
|
306
|
+
through `.wait` exactly like a normal exception would. Inside an existing
|
|
307
|
+
Fiber scheduler or `Async` reactor (a nested `client.async`, or an
|
|
308
|
+
app server like Falcon), Butler participates in that current execution
|
|
309
|
+
context rather than starting a competing one; from ordinary synchronous
|
|
310
|
+
Ruby, including a bare `client.get(...)` with no surrounding `async`
|
|
311
|
+
block at all, Butler manages the async execution for you — a single
|
|
312
|
+
reactor shared for the process, not spun up and torn down per call. See
|
|
313
|
+
[docs/architecture.md](docs/architecture.md#why-bare-calls-dont-pay-reactor-setup-per-call)
|
|
314
|
+
for exactly how that's implemented, if you're curious.
|
|
315
|
+
|
|
316
|
+
Even with that shared background reactor, Butler's own request pipeline
|
|
317
|
+
(security checks, retry/circuit-breaker bookkeeping, telemetry, building
|
|
318
|
+
`Request`/`Response` objects) is real per-call **CPU** work beyond what a
|
|
319
|
+
bare `Net::HTTP.get` does. Against any upstream with real network latency
|
|
320
|
+
— the normal case — that extra work overlaps with I/O wait and a tight
|
|
321
|
+
sequential loop of `client.get` calls lands close to `Net::HTTP`'s own
|
|
322
|
+
loop in wall-clock time; it only shows up clearly against a near-zero-
|
|
323
|
+
latency upstream or a CPU-bound host. See
|
|
324
|
+
[benchmarks/README.md](benchmarks/README.md#comparisonrb) for real
|
|
325
|
+
numbers either way.
|
|
326
|
+
|
|
327
|
+
### Deadlines and timeouts
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
client = Butler::Client.new(
|
|
331
|
+
base_url: "https://api.example.com",
|
|
332
|
+
connect_timeout: 2, read_timeout: 5, write_timeout: 5, # per-attempt budgets
|
|
333
|
+
deadline: 5, # total wall-clock budget for one call, across every retry/redirect
|
|
334
|
+
)
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`deadline:` is the total budget for one `client.get`/`client.post`/etc call
|
|
338
|
+
— DNS, connect, TLS, every retry attempt, every redirect hop, all count
|
|
339
|
+
against it, and **retries never reset it**. `connect_timeout` bounds
|
|
340
|
+
establishing a connection; `read_timeout`/`write_timeout` (or an explicit
|
|
341
|
+
`request_timeout` override) bound the request/response round-trip on an
|
|
342
|
+
already-open connection — the larger of read/write becomes that per-attempt
|
|
343
|
+
ceiling, since the underlying transport performs a request's write and its
|
|
344
|
+
response's read as one call rather than timing each phase separately.
|
|
345
|
+
Whichever of these is smaller wins for any given attempt: a generous
|
|
346
|
+
`read_timeout` still gets cut short once `deadline:` is nearly exhausted.
|
|
347
|
+
Exceeding either raises `Butler::Errors::TimeoutError`.
|
|
348
|
+
|
|
349
|
+
### Retries
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
client = Butler::Client.new(
|
|
353
|
+
retry: { max_attempts: 3, base_delay: 0.1, max_delay: 5.0, jitter: true },
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
client.put("/orders/42", json: { status: "shipped" }, idempotent: true) # opt in explicitly
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
- **GET/HEAD/OPTIONS** are retried automatically on a retryable status
|
|
360
|
+
(`408, 425, 429, 500, 502, 503, 504` by default) or a connection-level
|
|
361
|
+
failure (refused/reset connection, TLS failure, timeout) — nothing that
|
|
362
|
+
reached the server can be confirmed either way for those, so retrying a
|
|
363
|
+
network-level failure doesn't make the request any less safe. The one
|
|
364
|
+
deliberate exception: a **certificate verification failure**
|
|
365
|
+
(`Butler::Errors::CertificateVerificationError` — self-signed, expired,
|
|
366
|
+
hostname mismatch, untrusted root) is never retried, even though it's a
|
|
367
|
+
`TLSError` like the retried ones — a bad certificate won't become valid
|
|
368
|
+
a few hundred milliseconds later, so retrying just delays surfacing a
|
|
369
|
+
real problem instead of fixing anything.
|
|
370
|
+
- **PUT/DELETE** are only retried on a retryable status when explicitly
|
|
371
|
+
marked `idempotent: true`.
|
|
372
|
+
- **POST is never auto-retried on a 5xx status**, regardless of
|
|
373
|
+
`idempotent:` — matching how most systems reason about "did my write
|
|
374
|
+
actually happen." (Connection-level failures are still retried for POST
|
|
375
|
+
too, since the request demonstrably never reached the server.)
|
|
376
|
+
- `Retry-After` (seconds or an HTTP-date) is honored ahead of the computed
|
|
377
|
+
exponential-backoff-with-jitter delay when the server sends one.
|
|
378
|
+
- Exhausting every attempt raises `Butler::Errors::RetryExhausted`; running
|
|
379
|
+
out of `deadline:` instead raises `Butler::Errors::TimeoutError`.
|
|
380
|
+
|
|
381
|
+
### Circuit breaker
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
client = Butler::Client.new(
|
|
385
|
+
circuit_breaker: { enabled: true, scope: :host, failure_threshold: 5, recovery_timeout: 30 },
|
|
386
|
+
)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
A `CLOSED -> OPEN -> HALF_OPEN -> CLOSED/OPEN` state machine, scoped
|
|
390
|
+
per-host by default (`scope: :client` shares one breaker across every host
|
|
391
|
+
a client talks to instead). Opens after `failure_threshold` consecutive
|
|
392
|
+
failures — a raised connection-level error, *or* a 5xx response returned
|
|
393
|
+
normally (Butler doesn't raise on error responses unless
|
|
394
|
+
`raise_on_error: true`, but the breaker still counts them) — stays open
|
|
395
|
+
for `recovery_timeout` seconds, then allows exactly one probe request
|
|
396
|
+
through; a successful probe closes it, a failed one reopens it. An open
|
|
397
|
+
circuit raises `Butler::Errors::CircuitOpen` immediately, without
|
|
398
|
+
attempting the network call at all.
|
|
399
|
+
|
|
400
|
+
The exact rules for what counts, since they matter in production:
|
|
401
|
+
|
|
402
|
+
- A **successful** call (any raised-nothing result the `failure:` check
|
|
403
|
+
doesn't flag) resets `failure_count` to `0` — the counter is consecutive
|
|
404
|
+
failures, not a rolling total.
|
|
405
|
+
- From a **normally-returned response**, only a 5xx counts. 4xx (including
|
|
406
|
+
429) never counts as a circuit-breaker failure this way — Butler
|
|
407
|
+
doesn't raise on error responses unless `raise_on_error: true`, and the
|
|
408
|
+
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.
|
|
416
|
+
|
|
417
|
+
### Security
|
|
418
|
+
|
|
419
|
+
```ruby
|
|
420
|
+
client = Butler::Client.new(
|
|
421
|
+
security: {
|
|
422
|
+
verify_tls: true, # on by default — turning it off logs a loud warning
|
|
423
|
+
allowed_hosts: nil, # e.g. ["api.example.com"] to restrict to only those hosts
|
|
424
|
+
blocked_hosts: ["*.internal"], # glob patterns
|
|
425
|
+
max_response_size: 50 * 1024 * 1024, # bytes
|
|
426
|
+
max_header_size: 64 * 1024, # bytes
|
|
427
|
+
strip_credentials_on_redirect: true, # drop Authorization/Cookie crossing origin on a redirect
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
`allowed_hosts`/`blocked_hosts` is an **outbound host policy** — a
|
|
433
|
+
defense-in-depth allow/block list, not a solution to SSRF on its own. It
|
|
434
|
+
does not attempt to fully solve SSRF, DNS rebinding, cloud metadata
|
|
435
|
+
endpoint protection (`169.254.169.254` and friends), or
|
|
436
|
+
application-level authorization — those need their own, separate
|
|
437
|
+
mitigations regardless of what HTTP client you use.
|
|
438
|
+
|
|
439
|
+
### Telemetry
|
|
440
|
+
|
|
441
|
+
Standalone by default — no ActiveSupport required:
|
|
442
|
+
|
|
443
|
+
```ruby
|
|
444
|
+
Butler::Telemetry::Instrumentation.subscribe(:request) do |payload|
|
|
445
|
+
# payload is built from an explicit allow-list: method, host, port,
|
|
446
|
+
# status, duration, attempt, error — never request/response bodies or
|
|
447
|
+
# Authorization/Cookie/Set-Cookie headers.
|
|
448
|
+
StatsD.timing("http.request", payload[:duration] * 1000, tags: ["host:#{payload[:host]}"])
|
|
449
|
+
end
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
If `opentelemetry-api` is already loaded by your application, Butler wraps
|
|
453
|
+
each request in a real span automatically — nothing to configure —
|
|
454
|
+
following the HTTP semantic-convention attribute names implemented by
|
|
455
|
+
`Telemetry::OpenTelemetryBridge` (`http.request.method`,
|
|
456
|
+
`server.address`, `server.port`, `http.response.status_code`,
|
|
457
|
+
`network.protocol.name/version`, `error.type` as of this version;
|
|
458
|
+
semantic conventions do change over time upstream, so treat that list as
|
|
459
|
+
this version's implementation, not a permanent guarantee — see
|
|
460
|
+
`lib/butler/telemetry/open_telemetry_bridge.rb` for the exact current
|
|
461
|
+
set). If ActiveSupport is loaded, the same
|
|
462
|
+
`instrument(:request, ...)` call also fires as an
|
|
463
|
+
`ActiveSupport::Notifications` event (`"butler.request"`), so a Rails app's
|
|
464
|
+
existing log subscribers see it too.
|
|
465
|
+
|
|
466
|
+
### Middleware
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
class RequestIdMiddleware
|
|
470
|
+
def call(context, next_middleware)
|
|
471
|
+
context.request.headers["X-Request-Id"] = SecureRandom.uuid
|
|
472
|
+
next_middleware.call(context)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
client.use(RequestIdMiddleware.new)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Runs between `TelemetryMiddleware` and the built-in
|
|
480
|
+
`Timeout`/`CircuitBreaker`/`Retry` middlewares — third-party extensions can
|
|
481
|
+
inspect, modify, short-circuit, observe, or transform a request without
|
|
482
|
+
core resilience/security behavior ever needing to be expressed as
|
|
483
|
+
middleware itself.
|
|
484
|
+
|
|
485
|
+
### Testing — no WebMock/VCR needed
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
Butler::Testing.stub("GET", "https://api.example.com/users/1", status: 200, json: { id: 1 })
|
|
489
|
+
|
|
490
|
+
# or the builder form, path-relative to whatever base_url the client under test uses:
|
|
491
|
+
Butler::Testing.stub_request(:get, "/users/1").to_return(status: 200, json: { id: 1 })
|
|
492
|
+
Butler::Testing.stub_request(:get, "/flaky").to_raise(Butler::Errors::ConnectionError)
|
|
493
|
+
|
|
494
|
+
# reset between tests, e.g. in an after(:each)/teardown hook — stubs are
|
|
495
|
+
# process-wide, not scoped to one Client instance:
|
|
496
|
+
Butler::Testing.reset!
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
Stubs are matched at the transport boundary (`Butler::Testing::FakeTransport`
|
|
500
|
+
implements the exact same two-method contract as the real transport), so a
|
|
501
|
+
stubbed response or a stubbed exception still flows through
|
|
502
|
+
retry/circuit-breaker/telemetry exactly as a real one would — a stubbed 503
|
|
503
|
+
genuinely exercises `RetryMiddleware`, not a shortcut around it.
|
|
504
|
+
|
|
505
|
+
### Rails
|
|
506
|
+
|
|
507
|
+
Butler works without Rails and does not depend on it — `require "butler"`
|
|
508
|
+
is standalone; `Butler::Rails::Railtie` only loads if
|
|
509
|
+
`defined?(Rails::Railtie)` is already true. When Rails is present, Butler
|
|
510
|
+
integrates with `Rails.logger`, publishes a `"butler.request"`
|
|
511
|
+
`ActiveSupport::Notifications` event automatically, and resets pooled
|
|
512
|
+
connections after a Puma (or any `preload_app!`) fork so a forked worker
|
|
513
|
+
never inherits the parent's live reactor state.
|
|
514
|
+
|
|
515
|
+
The usual shape: configure global defaults once in an initializer, then
|
|
516
|
+
build one scoped `Butler::Client` per external API rather than sharing a
|
|
517
|
+
single client across every integration. Full worked example — initializer,
|
|
518
|
+
a service-object pattern, subscribing to `"butler.request"` correctly (and
|
|
519
|
+
a real subtlety around *where* the event's duration actually lives) — in
|
|
520
|
+
[docs/rails.md](docs/rails.md).
|
|
521
|
+
|
|
522
|
+
### Errors
|
|
523
|
+
|
|
524
|
+
Every error Butler raises descends from `Butler::Errors::Error`:
|
|
525
|
+
|
|
526
|
+
```
|
|
527
|
+
Error
|
|
528
|
+
├── ConfigurationError
|
|
529
|
+
├── RequestError
|
|
530
|
+
│ ├── TooManyRedirectsError
|
|
531
|
+
│ └── HostNotAllowed
|
|
532
|
+
├── TransportError
|
|
533
|
+
│ ├── ConnectionError
|
|
534
|
+
│ ├── TimeoutError
|
|
535
|
+
│ ├── TLSError
|
|
536
|
+
│ │ └── CertificateVerificationError (never retried — see Retries above)
|
|
537
|
+
│ ├── DNSFailure
|
|
538
|
+
│ └── ProtocolError
|
|
539
|
+
├── HTTPError (only raised with raise_on_error: true; carries .response)
|
|
540
|
+
│ ├── ClientError (4xx)
|
|
541
|
+
│ └── ServerError (5xx)
|
|
542
|
+
├── RetryExhausted
|
|
543
|
+
├── CircuitOpen
|
|
544
|
+
├── Cancelled
|
|
545
|
+
└── LimitExceeded
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
`rescue Butler::Errors::Error` is always a safe top-level catch-all for
|
|
549
|
+
"something about this HTTP call failed."
|
|
550
|
+
|
|
551
|
+
## Configuration reference
|
|
552
|
+
|
|
553
|
+
Set per-client (`Butler::Client.new(**options)`) or as a process-wide
|
|
554
|
+
default every new client starts from (`Butler.configure { |c| ... }`):
|
|
555
|
+
|
|
556
|
+
```ruby
|
|
557
|
+
Butler.configure do |config|
|
|
558
|
+
config.connect_timeout = 5
|
|
559
|
+
config.retry.max_attempts = 3
|
|
560
|
+
end
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
| Option | Default | Description |
|
|
564
|
+
| --- | --- | --- |
|
|
565
|
+
| `base_url` | `nil` | Prefixed onto every relative path |
|
|
566
|
+
| `default_headers` / `headers:` | `{}` | Sent on every request; per-request `headers:` override matching keys |
|
|
567
|
+
| `user_agent` | `"Butler/<version>"` | Sent unless a request already sets its own `User-Agent` |
|
|
568
|
+
| `connect_timeout` | `5` | Seconds; passed straight through to the connection endpoint |
|
|
569
|
+
| `read_timeout` / `write_timeout` | `10` / `10` | Seconds; the larger of the two becomes the per-attempt round-trip budget (see `request_timeout`) |
|
|
570
|
+
| `request_timeout` | `nil` | Seconds; an explicit override of the per-attempt round-trip budget, taking priority over `read_timeout`/`write_timeout` |
|
|
571
|
+
| `deadline` | `nil` (unbounded) | Total wall-clock seconds for one call, across every retry/redirect — always the final cap, however the per-attempt budget above was derived |
|
|
572
|
+
| `follow_redirects` | `true` | |
|
|
573
|
+
| `max_redirects` | `5` | |
|
|
574
|
+
| `http_version` | `:auto` | `:auto` automatically negotiates the best protocol for the origin — HTTP/2 where the server supports it, HTTP/1.1 otherwise — by offering both via ALPN and letting the server choose (`Security::TLS.alpn_protocols_for`, verified live against real HTTP/2 servers). `:http1`/`:http2` force one specifically. Also settable per call: `client.get(path, http_version: :http1)` — see [Requests](#requests) |
|
|
575
|
+
| `proxy` | `nil` | Proxy URL |
|
|
576
|
+
| `raise_on_error` | `false` | Raise `ClientError`/`ServerError` on 4xx/5xx instead of returning the response |
|
|
577
|
+
| `retry.max_attempts` | `2` | |
|
|
578
|
+
| `retry.retryable_status_codes` | `[408,425,429,500,502,503,504]` | |
|
|
579
|
+
| `retry.base_delay` / `retry.max_delay` | `0.1` / `5.0` | Seconds |
|
|
580
|
+
| `retry.jitter` | `true` | Equal-jitter (delay scaled by a random factor in `[0.5, 1.0)`) |
|
|
581
|
+
| `circuit_breaker.enabled` | `true` | |
|
|
582
|
+
| `circuit_breaker.scope` | `:host` | or `:client`, to share one breaker across every host |
|
|
583
|
+
| `circuit_breaker.failure_threshold` | `5` | |
|
|
584
|
+
| `circuit_breaker.recovery_timeout` | `30` | Seconds before a half-open probe is allowed |
|
|
585
|
+
| `circuit_breaker.max_tracked_hosts` | `256` | LRU-bounded so fanning out to many hosts can't grow this unboundedly |
|
|
586
|
+
| `security.verify_tls` | `true` | |
|
|
587
|
+
| `security.allowed_hosts` / `security.blocked_hosts` | `nil` / `[]` | Glob patterns |
|
|
588
|
+
| `security.max_response_size` | `50 MiB` | Bytes |
|
|
589
|
+
| `security.max_header_size` | `64 KiB` | Bytes |
|
|
590
|
+
| `security.strip_credentials_on_redirect` | `true` | |
|
|
591
|
+
| `pool.max_connections` | `100` | Distinct origins kept warm at once (LRU-evicted past this) |
|
|
592
|
+
| `pool.idle_timeout` | `60` | Seconds a pooled connection may sit unused before it's rebuilt rather than reused |
|
|
593
|
+
| `telemetry.enabled` | `true` | |
|
|
594
|
+
| `telemetry.opentelemetry` | `:auto` | Spans are emitted automatically whenever `opentelemetry-api` is already loaded |
|
|
595
|
+
| `telemetry.logger` | `nil` (falls back to `Logger.new($stdout)`, or `Rails.logger` under Rails) | |
|
|
596
|
+
|
|
597
|
+
## Benchmarks
|
|
598
|
+
|
|
599
|
+
```
|
|
600
|
+
ruby benchmarks/sequential_vs_concurrent.rb # Net::HTTP vs Butler, sequential vs concurrent
|
|
601
|
+
ruby benchmarks/concurrency.rb # how wall-clock time scales from 10 to 250 (configurable) concurrent requests
|
|
602
|
+
ruby benchmarks/allocations.rb # objects allocated per request
|
|
603
|
+
ruby benchmarks/memory.rb # RSS growth over sustained use
|
|
604
|
+
ruby benchmarks/comparison.rb # client × sequential/concurrent matrix, plus Faraday/Excon/HTTParty if installed
|
|
605
|
+
SERVER_URL=https://localhost:9292 ruby benchmarks/http1_vs_http2.rb # HTTP/1.1 pool vs HTTP/2 multiplexing
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
Full methodology, expected shapes, and how to read each script's output are
|
|
609
|
+
in [benchmarks/README.md](benchmarks/README.md). Short version: every script
|
|
610
|
+
except `http1_vs_http2.rb` runs against a local server with simulated,
|
|
611
|
+
fixed per-request latency, specifically so what's measured is Butler's own
|
|
612
|
+
overhead — not a particular network's variance on a particular day.
|
|
613
|
+
**Run them yourself** before quoting any number externally; nothing here
|
|
614
|
+
substitutes for load-testing your own upstream. Benchmarks are
|
|
615
|
+
informational, not guarantees — results depend on Ruby version,
|
|
616
|
+
scheduler, TLS/protocol version, concurrency, payload size, upstream
|
|
617
|
+
latency, CPU, and network conditions, all of which will differ from
|
|
618
|
+
whatever machine produced the numbers in
|
|
619
|
+
[benchmarks/README.md](benchmarks/README.md).
|
|
620
|
+
|
|
621
|
+
## What's not here yet
|
|
622
|
+
|
|
623
|
+
This is a substantial rearchitecture (see
|
|
624
|
+
[docs/architecture.md](docs/architecture.md)), not the full roadmap from
|
|
625
|
+
the design doc it's based on. Deliberately deferred: a full OpenTelemetry
|
|
626
|
+
semantic-convention compliance audit, Sorbet RBI (RBS type signatures are
|
|
627
|
+
included), a dedicated external security audit, and long-run
|
|
628
|
+
(24h/1M-request) soak testing.
|
|
629
|
+
|
|
630
|
+
**HTTP/1.1 + HTTP/2 today. HTTP/3 architecture-ready.** HTTP/3/QUIC is
|
|
631
|
+
early, internal-only groundwork — packet-level QUIC crypto
|
|
632
|
+
(`lib/butler/quic/`), not a usable transport. There is no
|
|
633
|
+
`http_version: :http3`, no `Transport::QUIC`, nothing reachable from
|
|
634
|
+
`Butler::Client` at all yet; requesting HTTP/3 today still just gets you
|
|
635
|
+
`:auto`'s existing HTTP/2-vs-HTTP/1.1 ALPN choice. See
|
|
636
|
+
[docs/architecture.md](docs/architecture.md#http3-quic)
|
|
637
|
+
for what exists, what doesn't, and the security posture of what's there
|
|
638
|
+
(short version: hand-rolled, **not** security-audited, and — once it is
|
|
639
|
+
wired up — never silently reachable via `:auto`, only via an explicit
|
|
640
|
+
`http_version: :http3`).
|
|
641
|
+
|
|
642
|
+
## Development
|
|
643
|
+
|
|
644
|
+
After checking out the repo, run `bin/setup` to install dependencies (or
|
|
645
|
+
just `bundle install`). Run `rake test` to run the test suite — most of it
|
|
646
|
+
spins up a real local TCP server rather than mocking anything, so pooling,
|
|
647
|
+
retries, redirects, and timeouts are exercised against actual socket
|
|
648
|
+
behavior, not stubbed-out doubles. `bin/console` starts an IRB session with
|
|
649
|
+
Butler already loaded.
|
|
650
|
+
|
|
651
|
+
## Contributing
|
|
652
|
+
|
|
653
|
+
Bug reports and pull requests are welcome at
|
|
654
|
+
https://github.com/ramlaxmanyadav/butler-http.
|
|
655
|
+
|
|
656
|
+
## License
|
|
657
|
+
|
|
658
|
+
The gem is available as open source under the terms of the
|
|
659
|
+
[MIT License](https://opensource.org/licenses/MIT).
|