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 Gem Downloads 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, 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.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.

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_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.
  • HTTP/2 (1.0.4) only applies to direct https:// connections - there's no h2c (cleartext HTTP/2) support, and requests through a proxy always use HTTP/1.1. No automatic NTLM/Kerberos/OAuth either — bring your own auth: 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's Net::HTTP read-timeout does.
  • 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 (HTTP/1.1 and HTTP/2 alike) is per- Session/per-adapter 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.).

Support the project

If requests_ruby helped you, consider starring the repository ⭐

License

MIT