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.

Gem Version Ruby License: MIT Zero Dependencies


Table of Contents


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.cookies             # 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.message           # "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.message
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.cookies['session_id']

s = Requests::Session.new
s.cookies.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.cookies.set('session_id', 'abc123', domain: 'api.example.com', path: '/', secure: true, http_only: true)

# save/restore a session's cookies across process runs
s.cookies.save('cookies.json')
s.cookies = 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_lines on a regular Response still read the whole body first — this library stays stdlib-only, and true lazy streaming for arbitrary responses would need extra machinery to keep a net/http connection open across method calls. See Known limitations. For actual large files, use download below — 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_lines on 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::HTTP itself only takes ca_file or ca_path, and this gem only wires up the former today).
  • The connection pool added in 1.0.3 is per-Session/per-HTTPAdapter instance and isn't thread-safe for concurrent requests on the same Session object — use one Session per 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:

  1. No new runtime dependencies — this stays stdlib-only, on purpose.
  2. Add a test in spec/requests_spec.rb for anything you fix or add.
  3. Keep the Python-parity naming where it makes sense, but don't force it where it doesn't fit Ruby idiom (?-suffixed predicates, etc.).

License

MIT