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
s.get(url)
# or per-request
Requests.get(url, retries: 3)
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
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.
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.session → new Session |
session.get!/post!/put!/patch!/delete! (raise on error) |
text, content, json |
Requests.download(url, to:, chunk_size:, resume:, progress:) |
session.download(url, to:, ...) (same, but reuses the session's pool/auth/headers) |
headers, cookies |
Requests.codes |
session.mount(prefix, adapter), session.close |
elapsed, history, url |
session.hooks[:response] |
raise_for_status, save_to, links |
|
session.headers, .cookies, .auth, .proxies, .retries, .backoff_factor, .status_forcelist |
||
Requests::Jar#save(path) / Requests::Jar.load(path) |
||
HTTPAdapter.new(max_retries:, backoff_factor:, status_forcelist:) |
Every request-level method accepts: params, data, json, headers, cookies, files, auth, timeout, allow_redirects, proxies, verify, cert, hooks, retries.
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.- No HTTP/2, no automatic NTLM/Kerberos/OAuth — bring your own
auth:object (anything with#call(headers)works) if you need those. 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 is per-
Session/per-HTTPAdapterinstance 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.).