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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +659 -0
  5. data/Rakefile +10 -0
  6. data/lib/butler/async.rb +130 -0
  7. data/lib/butler/body.rb +130 -0
  8. data/lib/butler/client.rb +206 -0
  9. data/lib/butler/configuration.rb +161 -0
  10. data/lib/butler/connection.rb +21 -0
  11. data/lib/butler/connection_pool.rb +64 -0
  12. data/lib/butler/errors.rb +78 -0
  13. data/lib/butler/headers.rb +106 -0
  14. data/lib/butler/pipeline/chain.rb +18 -0
  15. data/lib/butler/pipeline/context.rb +21 -0
  16. data/lib/butler/pipeline/middleware.rb +16 -0
  17. data/lib/butler/pipeline/middlewares/circuit_breaker_middleware.rb +21 -0
  18. data/lib/butler/pipeline/middlewares/retry_middleware.rb +67 -0
  19. data/lib/butler/pipeline/middlewares/security_middleware.rb +14 -0
  20. data/lib/butler/pipeline/middlewares/telemetry_middleware.rb +37 -0
  21. data/lib/butler/pipeline/middlewares/timeout_middleware.rb +17 -0
  22. data/lib/butler/quic/crypto/aead.rb +69 -0
  23. data/lib/butler/quic/crypto/header_protection.rb +51 -0
  24. data/lib/butler/quic/crypto/hkdf.rb +44 -0
  25. data/lib/butler/quic/crypto/key_schedule.rb +50 -0
  26. data/lib/butler/quic/packet.rb +113 -0
  27. data/lib/butler/quic/varint.rb +56 -0
  28. data/lib/butler/rails/notifications.rb +10 -0
  29. data/lib/butler/rails/railtie.rb +24 -0
  30. data/lib/butler/request.rb +92 -0
  31. data/lib/butler/resilience/backoff.rb +17 -0
  32. data/lib/butler/resilience/circuit_breaker.rb +120 -0
  33. data/lib/butler/resilience/deadline.rb +55 -0
  34. data/lib/butler/resilience/retry_policy.rb +76 -0
  35. data/lib/butler/resilience/timeout.rb +22 -0
  36. data/lib/butler/response.rb +81 -0
  37. data/lib/butler/security/host_policy.rb +29 -0
  38. data/lib/butler/security/limits.rb +39 -0
  39. data/lib/butler/security/redirect_policy.rb +28 -0
  40. data/lib/butler/security/tls.rb +51 -0
  41. data/lib/butler/stream.rb +44 -0
  42. data/lib/butler/telemetry/instrumentation.rb +59 -0
  43. data/lib/butler/telemetry/logger.rb +37 -0
  44. data/lib/butler/telemetry/open_telemetry_bridge.rb +46 -0
  45. data/lib/butler/testing/fake_transport.rb +29 -0
  46. data/lib/butler/testing/stub.rb +68 -0
  47. data/lib/butler/testing/stub_registry.rb +32 -0
  48. data/lib/butler/testing.rb +33 -0
  49. data/lib/butler/transport.rb +118 -0
  50. data/lib/butler/uri.rb +77 -0
  51. data/lib/butler/version.rb +3 -0
  52. data/lib/butler.rb +176 -0
  53. data/sig/butler/client.rbs +41 -0
  54. data/sig/butler/configuration.rbs +63 -0
  55. data/sig/butler/errors.rbs +67 -0
  56. data/sig/butler/headers.rbs +22 -0
  57. data/sig/butler/request.rbs +21 -0
  58. data/sig/butler/response.rbs +28 -0
  59. data/sig/butler.rbs +22 -0
  60. metadata +191 -0
data/README.md ADDED
@@ -0,0 +1,659 @@
1
+ # Butler
2
+
3
+ ### Modern HTTP for modern Ruby.
4
+
5
+ [![Gem Version](https://img.shields.io/gem/v/butler-http)](https://rubygems.org/gems/butler-http)
6
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-red)
7
+ ![License](https://img.shields.io/badge/license-MIT-blue)
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).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+
4
+ Rake::TestTask.new(:test) do |t|
5
+ t.libs << "test"
6
+ t.libs << "lib"
7
+ t.test_files = FileList["test/**/*_test.rb"]
8
+ end
9
+
10
+ task default: :test