requests_ruby
A zero-dependency, stdlib-only HTTP client for Ruby, modeled closely on Python's beloved requests library. No Gemfile, no C extensions, no transitive dependency tree — just Ruby's own net/http wrapped in an API that doesn't make you want to throw your laptop.
Table of Contents
- Why this exists
- Installation
- Quick start
- Feature tour
- API reference
- Python parity cheat sheet
- Known limitations
- Development
- Contributing
- License
Why this exists
Ruby has plenty of great HTTP gems (Faraday, HTTParty, httpx...) but most either pull in dependencies or don't quite match the ergonomics people already know from Python's requests. This gem is for the specific case of: "I want requests.get(url).json() energy, in Ruby, using only what ships with Ruby itself."
If you're coming from Python, the parity cheat sheet below will feel like home.
Installation
Add it to your Gemfile:
gem 'requests_ruby'
Or install it directly:
gem install requests_ruby
Then, in your code — require 'requests' works out of the box (matching the ergonomics of the gem's underlying Requests module), and so does require 'requests_ruby' if you'd rather match the gem name exactly. Both point at the same code:
require 'requests'
# -- or --
require 'requests_ruby'
Quick start
require 'requests'
r = Requests.get('https://api.example.com/users', params: { page: 1 })
r.status_code #=> 200
r.ok? #=> true
r.json #=> { "users" => [...] }
r.text #=> "{\"users\":[...]}"
r.headers['content-type']
Feature tour
Query params
Requests.get('https://api.example.com/search', params: { q: 'ruby gems', page: 2 })
# => https://api.example.com/search?q=ruby+gems&page=2
# arrays become repeated params
Requests.get('https://api.example.com/search', params: { tag: ['ruby', 'http'] })
# => ...?tag=ruby&tag=http
Sending data (form / JSON / files)
# JSON body
Requests.post('https://api.example.com/login', json: { user: 'ali', pass: 'secret' })
# form-encoded body
Requests.post('https://api.example.com/login', data: { user: 'ali', pass: 'secret' })
# multipart file upload
Requests.post('https://api.example.com/upload',
data: { title: 'my photo' },
files: { avatar: ['pic.png', File.read('pic.png'), 'image/png'] })
Response object
r = Requests.get(url)
r.status_code # 200
r.status # alias for status_code
r.ok? # true if < 400
r.text # decoded body as a UTF-8 string
r.content # raw body
r.json # parsed JSON (raises Requests::JSONDecodeError if it isn't JSON)
r.headers['etag'] # case-insensitive header access
r. # cookie jar collected across the request
r.elapsed # Float seconds
r.url # final URL after any redirects
r.history # array of intermediate Response objects
r.links # parsed Link header, e.g. pagination
r.request # PreparedRequest - what was actually sent (method/url/headers/body)
r.redirect? # true for 3xx with a Location header
r.client_error? # 400-499
r.server_error? # 500+
r.save_to('file.bin') # stream raw_body straight to disk
Sessions
Sessions persist headers, cookies, and auth across multiple requests to the same service — and reuse is not just convenient, it avoids re-doing DNS/TLS handshakes on every call.
s = Requests::Session.new
s.headers['Authorization'] = 'Bearer xyz'
s.get('https://api.example.com/me')
s.get('https://api.example.com/orders')
# raise immediately instead of checking status_code yourself
s.get!('https://api.example.com/me')
Authentication
Requests.get(url, auth: Requests::BasicAuth.new('user', 'pass'))
Requests.get(url, auth: Requests::BearerAuth.new('token'))
Requests.get(url, auth: Requests::DigestAuth.new('user', 'pass'))
Write your own by implementing #call(headers) — anything that responds to it works as an auth: value.
Errors & exceptions
begin
r = Requests.get(url)
r.raise_for_status
rescue Requests::HTTPError => e
puts e. # "404 Client Error: Not Found for url: ..."
puts e.response.status_code
rescue Requests::Timeoutable
# catches BOTH Requests::ConnectTimeout and Requests::ReadTimeout
puts 'timed out'
rescue Requests::ConnectionError => e
puts e.
end
Exception hierarchy at a glance:
Requests::RequestException
├── Requests::HTTPError
├── Requests::ConnectionError
│ ├── Requests::ProxyError
│ ├── Requests::SSLError
│ └── Requests::ConnectTimeout (also includes Requests::Timeoutable)
├── Requests::Timeout (also includes Requests::Timeoutable)
│ └── Requests::ReadTimeout
├── Requests::URLRequired
├── Requests::TooManyRedirects
├── Requests::MissingSchema (no scheme at all, e.g. "example.com")
├── Requests::InvalidSchema (unsupported scheme, e.g. "ftp://")
├── Requests::InvalidURL
│ └── Requests::InvalidProxyURL
├── Requests::ChunkedEncodingError
├── Requests::ContentDecodingError
├── Requests::StreamConsumedError
├── Requests::InvalidHeader
├── Requests::RetryError
└── Requests::InvalidJSONError
└── Requests::JSONDecodeError
Redirects & history
r = Requests.get(url)
r.history # every intermediate 3xx Response, in order
r.url # the final URL you landed on
r = Requests.get(url, allow_redirects: false) # don't follow at all
Timeouts & retries
Requests.get(url, timeout: 5) # 5s for both connect and read
Requests.get(url, timeout: [3, 10]) # [connect, read]
# retry transient connection errors on idempotent methods (GET/HEAD/OPTIONS/PUT/DELETE)
s = Requests::Session.new
s.retries = 3
s.backoff_factor = 0.5 # sleeps 0.5s, 1s, 1.5s between attempts, unless...
s.status_forcelist = [429, 502, 503, 504] # ...also retry these response codes
s.get(url)
# or per-request
Requests.get(url, retries: 3)
A response in status_forcelist that comes back with a Retry-After
header (seconds or an HTTP date) is retried after that delay instead of
the backoff_factor formula.
Proxies & SSL
Requests.get(url, proxies: { 'http' => 'http://127.0.0.1:8080', 'https' => 'http://127.0.0.1:8080' })
Requests.get(url, verify: false) # skip cert verification (careful!)
Requests.get(url, verify: '/path/to/ca.pem') # custom CA bundle
By default (session.trust_env = true), a Session without an explicit
proxies: picks one up from the HTTP_PROXY/HTTPS_PROXY/NO_PROXY
environment variables (lowercase names also work), same as python
requests. Set session.trust_env = false to ignore the environment
entirely.
A bundled cacert.pem (Mozilla's CA bundle) ships with the gem and is used by default. Override it globally with the REQUESTS_CA_FILE environment variable.
Cookies
r = Requests.get(url)
r.['session_id']
s = Requests::Session.new
s..set('theme', 'dark')
s.get(url) # sent automatically on every request through this session
Requests::Jar is a real cookie jar as of 1.0.3 — it tracks domain, path,
secure, http_only and expiry per cookie, same as a browser would, so a
cookie a session picked up from api.example.com won't leak into a request
to some unrelated host through the same session:
s = Requests::Session.new
s.get('https://api.example.com/login') # server sets a cookie scoped to api.example.com
s.get('https://other-service.example') # that cookie is NOT sent here
# set one explicitly with full control
s..set('session_id', 'abc123', domain: 'api.example.com', path: '/', secure: true, http_only: true)
# save/restore a session's cookies across process runs
s..save('cookies.json')
s. = Requests::Jar.load('cookies.json')
Hooks
s = Requests::Session.new
s.hooks[:response] << ->(resp) { puts "#{resp.request[:method]} #{resp.url} -> #{resp.status_code}" }
s.get(url)
Streaming & downloads
r = Requests.get(url)
r.iter_content(chunk_size: 4096) { |chunk| ... }
r.iter_lines { |line| ... }
# convenience one-liner for "just save this to disk"
Requests.download('https://example.com/file.zip', to: 'file.zip')
ℹ️ Note:
iter_content/iter_lineson a regularResponsestill read the whole body first — this library stays stdlib-only, and true lazy streaming for arbitrary responses would need extra machinery to keep anet/httpconnection open across method calls. See Known limitations. For actual large files, usedownloadbelow — that one is real chunk-by-chunk streaming straight to disk.
Downloading big files in chunks
As of 1.0.3, download doesn't buffer the file in memory at all — it reads
from the socket in chunks and writes each one straight to disk, decoding
gzip/deflate on the fly. This is the one to reach for instead of
Requests.get(url).save_to(path) once files get past a few MB.
Requests.download('https://example.com/big-file.zip', to: 'big-file.zip')
# custom chunk size + a progress callback
Requests.download('https://example.com/big-file.zip', to: 'big-file.zip',
chunk_size: 65536,
progress: ->(downloaded, total) { print "\r#{downloaded}/#{total}" })
# resume an interrupted download (sends a Range header, appends to the file)
Requests.download('https://example.com/big-file.zip', to: 'big-file.zip', resume: true)
# same thing on a Session, so it reuses the pooled connection and session auth/headers
s = Requests::Session.new
s.headers['Authorization'] = 'Bearer xyz'
s.download('https://example.com/private-file.zip', to: 'private-file.zip')
Connection pooling
As of 1.0.3, HTTPAdapter keeps the underlying Net::HTTP connection open
and reuses it for further requests to the same host/port/proxy instead of
paying for a new TCP+TLS handshake on every single call — this is the same
idea as urllib3's connection pool under python's requests. It's
automatic and requires nothing from you:
s = Requests::Session.new
s.get('https://api.example.com/a') # opens + keeps the connection
s.get('https://api.example.com/b') # reuses it, no new handshake
s.close # closes every pooled connection when you're done
If a pooled connection goes stale (the server closed it, a timeout, whatever)
it's transparently dropped and reopened on the next request; combined with
retries/backoff_factor this makes long-lived sessions much more resilient
than opening a fresh connection per call.
HTTP/2
As of 1.0.4, Requests::Http2Adapter speaks HTTP/2 directly over
Socket/OpenSSL - ALPN negotiation, HPACK header compression, and
stream multiplexing/flow control, all stdlib, no external gem:
s = Requests::Session.new(http2: true)
s.get('https://api.example.com/a') # negotiates h2 over ALPN, reuses the connection
s.get('https://api.example.com/b') # same connection, new stream
# or opt in per call on a plain session
Requests.get('https://api.example.com', http2: true)
resp = Requests::Session.new.tap(&:http2!).get('https://api.example.com')
If the server doesn't advertise h2 during the TLS handshake, or the URL
is plain http://, or a proxy is configured, requests on that host
transparently fall back to the regular HTTPAdapter (HTTP/1.1) - you get
the same Response object either way and don't need to know which
transport actually served the request.
Custom transport adapters
Loosely inspired by Python's Session.mount() / HTTPAdapter:
class LoggingAdapter < Requests::HTTPAdapter
def send_once(*args)
puts 'sending a request...'
super
end
end
s = Requests::Session.new
s.mount('https://internal.example.com/', LoggingAdapter.new)
Status codes
Requests.codes.ok # 200
Requests.codes.not_found # 404
Requests.codes.im_a_teapot # 418, yes really
API reference
| Module-level | Session | Response |
|---|---|---|
Requests.get/post/put/patch/delete/head/options |
session.get/post/put/patch/delete/head/options |
status_code, status |
Requests.request(method, url, **opts) |
session.request(method, url, **opts) |
ok?, redirect? |
Requests.get!/post!/put!/patch!/delete!/head!/options! (raise on error) |
session.get!/post!/put!/patch!/delete!/head!/options! (raise on error) |
text, content, json |
Requests.session(**opts) → new Session |
session.mount(prefix, adapter), session.close |
headers, cookies |
Requests.download(url, to:, chunk_size:, resume:, progress:) |
session.download(url, to:, ...) (same, but reuses the session's pool/auth/headers) |
elapsed, history, url |
Requests.codes |
session.http2!, Session.new(http2: true), or http2: true per request |
raise_for_status, save_to, links |
session.hooks[:response] |
||
session.headers, .cookies, .auth, .proxies, .trust_env, .retries, .backoff_factor, .status_forcelist |
||
Requests::Jar#save(path) / Requests::Jar.load(path) |
||
HTTPAdapter.new(max_retries:, backoff_factor:, status_forcelist:), Http2Adapter.new(...) |
Every request-level method accepts: params, data, json, headers, cookies, files, auth, timeout, allow_redirects, proxies, verify, cert, hooks, retries, http2.
Python parity cheat sheet
Python requests |
requests_ruby |
|---|---|
requests.get(url, params={...}) |
Requests.get(url, params: {...}) |
requests.post(url, json={...}) |
Requests.post(url, json: {...}) |
r.status_code |
r.status_code |
r.ok |
r.ok? |
r.text / r.content |
r.text / r.content |
r.json() |
r.json |
r.raise_for_status() |
r.raise_for_status |
r.headers['x'] |
r.headers['x'] (case-insensitive both ways) |
s = requests.Session() |
s = Requests::Session.new |
s.mount('https://', adapter) |
s.mount('https://', adapter) |
requests.auth.HTTPBasicAuth(u, p) |
Requests::BasicAuth.new(u, p) |
requests.exceptions.Timeout |
Requests::Timeoutable (module, catches both) |
r.iter_content(chunk_size=...) |
r.iter_content(chunk_size: ...) |
s.cookies.get_dict() |
s.cookies.to_h |
| n/a | Requests.download(url, to:, resume:, progress:) — no python equivalent, this one's ours |
Known limitations
iter_content/iter_lineson a regular response read the whole body up front rather than lazily streaming it off the socket —download(added in 1.0.3) is the one that streams for real, use that for big files.- HTTP/2 (1.0.4) only applies to direct
https://connections - there's noh2c(cleartext HTTP/2) support, and requests through a proxy always use HTTP/1.1. No automatic NTLM/Kerberos/OAuth either — bring your ownauth:object (anything with#call(headers)works) if you need those. - The HTTP/2 adapter applies
timeout:as one coarse deadline around the whole request/response exchange rather than resetting it on every individual socket read the way the HTTP/1.1 adapter'sNet::HTTPread-timeout does. verify: '/path/to/ca.pem'accepts a single bundle file, not a directory of certs (Net::HTTPitself only takesca_fileorca_path, and this gem only wires up the former today).- The connection pool added in 1.0.3 (HTTP/1.1 and HTTP/2 alike) is per-
Session/per-adapter instance and isn't thread-safe for concurrent requests on the sameSessionobject — use oneSessionper thread, or one connection per thread, same advice as most connection-pooling HTTP clients.
🔧 Development
git clone https://github.com/requests-ruby/requests_ruby.git
cd requests_ruby
make test # run the test suite
make console # irb with the lib pre-loaded
make build # build the .gem into pkg/
See the Makefile for the full list of targets.
Contributing
Bug reports and pull requests are welcome. A few ground rules:
- No new runtime dependencies — this stays stdlib-only, on purpose.
- Add a test in
spec/requests_spec.rbfor anything you fix or add. - Keep the Python-parity naming where it makes sense, but don't force it where it doesn't fit Ruby idiom (
?-suffixed predicates, etc.).
Support the project
If requests_ruby helped you, consider starring the repository ⭐