Ruby’s HTTP client landscape has a gap. Net::HTTP is reliable but blocking by default. HTTParty and Faraday make single requests pleasant but leave concurrency to you. Getting real parallelism usually means reaching for a thread pool, a Typhoeus/libcurl dependency, or wiring up Async yourself. Butler is what I built instead: an HTTP client designed around Ruby’s Fiber::Scheduler from the start, so concurrent requests are the normal path, not an add-on.
The one-line case
gem install butler-httpButler.get("https://api.example.com/users").jsonThat’s the HTTParty-shaped entry point, and it works exactly like you’d expect. The actual reason to reach for Butler shows up one level deeper:
client = Butler::Client.new(base_url: "https://api.example.com")
client.async do |tasks| users = tasks.async { client.get("/users") } orders = tasks.async { client.get("/orders") } { users: users.wait.json, orders: orders.wait.json }endBoth requests run concurrently without spinning up a thread for either of them. Under Ruby’s Fiber scheduler, tasks.async { ... } yields control back to the scheduler the moment the request is waiting on I/O, and resumes when a response arrives — structured concurrency with parent/child cancellation, not a thread pool with its own queueing and context-switch overhead.
Why fibers instead of threads
A thread-per-request model means every concurrent outbound call holds a full OS thread (and its stack) for the duration of the request, even though the thread spends nearly all of that time blocked on network I/O rather than doing CPU work. Fiber-based concurrency inverts that: a fiber that’s waiting on a socket yields the actual Ruby thread back to the scheduler, which can run other fibers in the meantime. The practical effect is that a single Ruby process can hold open far more concurrent in-flight requests than a thread-per-request design would tolerate, without any of the thread-pool-sizing tuning that comes with it.
Note
This only works because Ruby 3.0+ ships a real Fiber::Scheduler hook that I/O operations can
yield into. Butler doesn’t reimplement cooperative scheduling — it’s built directly on that
primitive.
HTTP/2 and connection pooling
Butler negotiates HTTP/2 or HTTP/1.1 per host transparently through ALPN during the TLS handshake, and pools connections per origin — so repeated calls to the same host reuse a warm connection instead of renegotiating TLS every time. HTTP/2’s real multiplexing means multiple concurrent requests to the same host can share a single connection rather than each needing its own, which matters more as request concurrency goes up. Need to force a specific version for one call rather than relying on negotiation? That’s a per-call override, not a client-wide setting:
client.get("/legacy-endpoint", http_version: "1.1")Resilience as a built-in, not an add-on library
This is the part I think matters most in practice. Most Ruby HTTP clients leave retries, backoff, and circuit breaking to whatever gem you bolt on afterward, and every team ends up with a slightly different, inconsistently-applied version of the same logic. Butler builds it into the client directly:
- A deadline is a total time budget for a logical operation that survives retries and redirects — not a per-attempt timeout that resets every time something retries.
- Exponential backoff with jitter on retryable failures, so a thundering herd of clients doesn’t retry in lockstep against a struggling upstream.
- A CLOSED → OPEN → HALF_OPEN circuit breaker, so a downstream outage gets contained instead of every caller independently hammering a dead service and queuing behind its own timeout.
- HTTP-semantics-aware retry rules — a
POSTthat returns a 5xx is never automatically retried, because Butler has no way to know whether the server’s effect already happened. Idempotent methods get retried; non-idempotent ones with ambiguous outcomes don’t.
Warning
Circuit breakers and retry policies are easy to get subtly wrong — retrying a non-idempotent request on a timeout can double-charge a customer or double-send a notification. Butler’s retry rules are deliberately conservative about what’s safe to retry automatically.
Security defaults that are actually defaults
TLS and hostname verification are on by default, not opt-in. Certificate failures are explicitly never retried — a cert error is not a transient condition, and retrying it just delays surfacing a real problem. Host allow/block lists and response/header size limits are available for services that need to constrain what they’ll talk to. Credentials (auth headers, cookies) are stripped automatically on cross-origin redirects, which is the kind of thing that’s easy to miss when hand-rolling redirect handling and has a real security cost when it’s missed.
Observability without forcing a dependency
Butler instruments requests on its own, with OpenTelemetry support available but optional, and bridges into ActiveSupport::Notifications for Rails apps that already have their logging and metrics pipeline wired to that. For testing, it ships native request stubbing — you can intercept and stub outbound calls in specs without reaching for WebMock or VCR as a separate dependency.
Where it fits
Butler isn’t trying to replace Net::HTTP for a script that makes one request and exits — Butler.get(url).json covers that case about as tersely as HTTParty does. It’s aimed at the case most Ruby HTTP clients handle poorly: a service making dozens of concurrent outbound calls per request, where connection reuse, sane retry semantics, and a circuit breaker aren’t nice-to-haves, they’re the difference between a degraded upstream being contained and the same outage cascading through every caller.
It’s MIT-licensed, requires Ruby 3.3+, and the full API reference — Client, Request, Response, Headers — along with deeper guides on the fiber concurrency model and HTTP/2 multiplexing is up on the detailed Butler documentation if you want to go further than this post does.