winhttp
HTTP for Ruby on the stack Windows already ships: WinHTTP async with system TLS, the OS certificate store, and the user's proxy settings — fiber-friendly under winloop, plain blocking without it.
On native Windows Ruby, the usual HTTP stack means net/http plus the openssl
gem: you bundle and patch a second TLS implementation, you ignore the OS
certificate store, and you get nothing from the corporate proxy / PAC settings
the machine is already configured for. The openssl gem also has to be built
and kept current independently of Windows Update.
winhttp is a thin binding to the HTTP client Windows already ships — WinHTTP in asynchronous mode. Certificate validation, revocation policy, redirects with safe defaults, proxy auto-detection, HTTP/2 negotiation, and transparent gzip/deflate are the OS's, serviced by Windows Update — not ours. Same suite logic as phylax ("binds the crypto Windows already ships, implements none of its own"): winhttp binds the HTTP stack Windows already ships and implements none of it. It is not a full HTTP framework — see Scope and limitations.
| What | API | ||
|---|---|---|---|
| One-liners | Winhttp.get(url) · Winhttp.post(url, body:) · Winhttp.request(verb, url) |
||
| Sessions | Winhttp::Session.new(...) · `Session.open(...) { \ |
s\ | ... }·s.get/head/post/put/patch/delete/request` |
| Streaming | `s.get(url) { \ | chunk\ | ... }` (each chunk ≤ 64 KiB, on your thread/fiber) |
| Responses | r.status · r.headers · r.raw_headers · r[name] · r.body · r.text · r.final_url · r.http2? · r.success? |
||
| Errors | Winhttp::TlsError#details, ResolveError, ConnectError, TimeoutError, RedirectError, ProtocolError, Canceled, Closed |
Requirements
- Windows 10 1607+ recommended (HTTP/2 needs 1607; earlier Win10 degrades to HTTP/1.1 silently). TLS 1.3 needs Windows 11 / Server 2022; older Windows uses TLS 1.2 (winhttp enforces a TLS 1.2 floor and never enables anything older).
- A native MSVC (mswin) Ruby, 3.2+ (
Thread::Queue#pop(timeout:)is the mailbox deadline primitive). Not supported on MinGW/UCRT Ruby. - Visual Studio 2017+ / Build Tools with the "Desktop development with C++"
workload. No Developer Command Prompt is needed — the build uses the
vcvarsgem; ifrake compilecan't find the toolchain, runvcvars doctor. - Supported platform: x64 (
x64-mswin64). arm64-mswin is expected to work (the code is_WIN64/uintptr_t-clean, no arch-specific anything) but is untested and unsupported until an arm64-mswin Ruby distribution exists.
Install
gem install winhttp
Quick start
require "winhttp"
# One-liners on the shared default session
r = Winhttp.get("https://example.com/")
r.status # => 200
r.headers["content-type"] # => "text/html; charset=UTF-8"
r.text[0, 15] # => "<!doctype html>"
# POST JSON
r = Winhttp.post("https://httpbin.org/post",
body: '{"hello":"world"}',
headers: { "Content-Type" => "application/json" })
r.success? # => true
Sessions and options
WinHTTP pools TCP/TLS connections at the session, so reuse one Session for
many requests. A Session is thread-safe and fiber-safe — any number of threads
and/or fibers may issue requests on it concurrently.
s = Winhttp::Session.new(
user_agent: "myapp/1.0",
proxy: :system, # :system | :none | "proxy.corp:8080"
proxy_bypass: nil, # only with a String proxy: "<local>;*.internal.corp"
http2: true, # enable HTTP/2 if the OS supports it (else HTTP/1.1)
decompress: true, # transparent gzip/deflate
revocation: :best_effort, # :best_effort | :strict | :none (see below)
redirects: 10, # 0..65535 max auto-redirects; 0 = return 3xx to you
connect_timeout: nil, # seconds | nil = WinHTTP default
send_timeout: nil,
receive_timeout: nil
)
Winhttp::Session.open(proxy: :none, redirects: 0) do |session| # ensure-closed
session.get("https://example.com/")
end
TLS / revocation / certificate policy is fixed at construction (WinHTTP caches certificate validation per session), so there are deliberately no per-request TLS knobs.
revocation: |
Behavior |
|---|---|
:best_effort (default) |
Revocation on, but offline CRLs are forgiven (IGNORE_CERT_REVOCATION_OFFLINE). On a pre-2004 build that lacks the ignore-offline option, it degrades to :none at construction (availability over false-strictness). Session#revocation then returns :none. |
:strict |
Revocation on with no offline forgiveness — offline CRLs raise TlsError (:cert_rev_failed). If the OS cannot enable revocation, Session.new raises OSError (never silently weaker than asked). |
:none |
WinHTTP default (revocation off). |
Session#revocation reports the effective policy settled at construction —
security-minded users on old builds should check it.
Streaming downloads
Pass a block and each received body chunk is yielded as a fresh ASCII-8BIT
String (≤ 64 KiB each) on your thread/fiber, in order; Response#body is then
nil. If the block raises, the request is aborted and the exception propagates.
Winhttp::Session.open(receive_timeout: 30) do |s|
File.open("big.bin", "wb") do |f|
resp = s.get("https://example.com/big.bin", timeout: 300) { |chunk| f.write(chunk) }
resp.body # => nil (streamed)
resp.final_url # => "https://example.com/big.bin"
end
end
Concurrency
Multiple plain threads sharing one Session get true concurrency — WinHTTP's
thread pool does the I/O; the GVL is only held for the tiny Ruby state-machine
steps.
s = Winhttp::Session.new
16.times.map { |i| Thread.new { s.get("https://example.com/?#{i}") } }.each(&:join)
Under winloop (the suite's IOCP
Fiber::Scheduler) the same code parks fibers instead of blocking threads:
require "winloop"
Winloop.run do
10.times.map { |i| Fiber.schedule { Winhttp.get("https://example.com/?#{i}") } }
# ten requests in flight concurrently on one OS thread; the loop keeps running
end
There are no Fiber.scheduler branches anywhere in winhttp — one code path
serves both modes (see How it works).
Timeouts and cancellation
Two layers, both available:
- Session socket timeouts (
connect_timeout/send_timeout/receive_timeout) are WinHTTP's own and surface asTimeoutErrorwithcode == 12002. - A per-request
timeout:is a Ruby-level wall-clock deadline for the entire call (resolve → connect → TLS → send → headers → full body). On expiry the request is aborted andTimeoutErroris raised withcode == nil.
Winhttp.get("https://example.com/", timeout: 0.000001)
# raises Winhttp::TimeoutError (code nil — Ruby-side deadline)
Timeout.timeout and Thread#kill also unwind cleanly: the request handle is
aborted in the ensure path and the session stays fully usable.
Library API
# --- Module functions (delegate to Winhttp.default_session) ---
Winhttp.default_session # => Winhttp::Session (lazy, immortal)
Winhttp.get(url, headers: nil, timeout: nil, &chunk) # => Winhttp::Response
Winhttp.post(url, body: "", headers: nil, timeout: nil, &chunk) # => Winhttp::Response
Winhttp.request(method, url, body: nil, headers: nil, timeout: nil, &chunk) # => Winhttp::Response
# --- Session ---
Winhttp::Session.new(user_agent:, proxy:, proxy_bypass:, http2:, decompress:,
revocation:, redirects:, connect_timeout:, send_timeout:,
receive_timeout:) # => Session
Winhttp::Session.open(**opts) { |s| ... } # => block value (ensure-closes)
session.get(url, headers: nil, timeout: nil, &chunk) # => Response
session.head(url, headers: nil, timeout: nil) # => Response
session.post(url, body: "", headers: nil, timeout: nil, &chunk) # => Response
session.put(url, body:, headers: nil, timeout: nil, &chunk) # => Response
session.patch(url, body:, headers: nil, timeout: nil, &chunk) # => Response
session.delete(url, headers: nil, timeout: nil, &chunk) # => Response
session.request(method, url, body: nil, headers: nil, timeout: nil, &chunk) # => Response
session.close # => nil (idempotent)
session.closed? # => true | false
session.revocation # => :best_effort | :strict | :none (effective)
# --- Response (immutable) ---
response.status # => Integer (200, 404, ...)
response.reason # => String ("OK"; may be "" under HTTP/2)
response.headers # => Hash{String=>String} (keys lowercased, duplicates joined ", "; frozen)
response.raw_headers # => Array[[String, String]] (wire order, original case, duplicates kept; frozen)
response[name] # => String | nil (case-insensitive single-header lookup)
response.body # => String (ASCII-8BIT) | nil (nil when a &chunk block was used)
response.text(enc=nil) # => String (a copy tagged with enc / charset= / UTF-8; never transcodes)
response.final_url # => String (the URL actually fetched, after redirects)
response.http2? # => true | false
response.success? # => true | false ((200..299).cover?(status))
Errors
4xx/5xx are responses, not exceptions — get returns a Response for any
HTTP status. Exceptions are raised only for transport/TLS/protocol failures and
misuse. Plain argument misuse raises Ruby's own ArgumentError/TypeError.
Winhttp::Error < StandardError # all winhttp failures
Winhttp::OSError < Error # carries #code (Integer | nil)
Winhttp::TimeoutError # 12002, or code nil for Ruby-side deadlines
Winhttp::ResolveError # 12007 (DNS)
Winhttp::ConnectError # 12029 / 12030 (TCP)
Winhttp::TlsError # 12175 + cert failures — see #details
Winhttp::RedirectError # 12156
Winhttp::ProtocolError # 12152 (malformed response)
Winhttp::Canceled # 12017 / 995 (aborted)
Winhttp::Closed < Error # misuse: session already closed
Winhttp::TlsError#details decodes the SECURE_FAILURE flag bits captured just
before the failure:
| Symbol | Meaning |
|---|---|
:cert_rev_failed |
revocation check could not complete |
:invalid_cert |
certificate is invalid |
:cert_revoked |
certificate was revoked |
:invalid_ca |
unknown / untrusted CA |
:cert_cn_invalid |
hostname does not match the certificate |
:cert_date_invalid |
certificate is expired or not yet valid |
:security_channel_error |
other Schannel error |
Winhttp.get("https://expired.badssl.com/")
# raises Winhttp::TlsError, e.code => 12175, e.details => [:cert_date_invalid]
How it works
WinHTTP runs in async mode (WINHTTP_FLAG_ASYNC): the OS does resolve /
connect / TLS / send / receive on its own thread pool and delivers completions
to a status callback. That callback runs on a foreign thread (or synchronously
inside one of our own calls) — where it may not touch Ruby or the GVL — so it
only appends a plain-C event node to a critical-section-guarded list and signals
an event. One lazily-started pump thread waits on that event (GVL released),
drains the list (GVL held), and routes each event to its request's
Thread::Queue mailbox. The per-request state machine pops that mailbox.
That mailbox pop is the only place a caller blocks — and it is exactly the
primitive a Fiber::Scheduler hooks: standalone it blocks the thread natively;
under winloop the pump's cross-thread Queue#push wakes the parked fiber. One
code path, one extra thread total (the pump), zero winloop coupling — fibers
park for free.
Per-request native memory (the body copy and the 64 KiB receive buffer) is freed
only on HANDLE_CLOSING, the guaranteed-last callback — never on a Ruby-side
path the kernel might still write into. Request identity is a never-reused uint64
id (never a pointer), so late or duplicate callbacks for a dead request are
dropped harmlessly. Session#close marks the session closed, aborts every
in-flight request, and waits (bounded) for the OS to finish teardown before
closing the session handle.
Scope and limitations
Honest bullets — winhttp is a thin OS binding, not a framework:
- User-set headers cross redirects unchanged — including
Authorization, which can therefore leak to a redirect target. v1 adds no header-stripping magic; setredirects: 0if you need to inspect 3xx yourself. https→http redirects are always refused (OS default). - Cookies are per-session and automatic (WinHTTP's handling) — there is no cookie-jar API or inspection.
- The body is buffered into
Response#bodyunless you pass a streaming block. Response#headersreflects the wire headers, except that WinHTTP removesContent-Encoding/Content-Lengthonce it transparently decompresses a gzip/deflate body (so the decoded body and the headers stay consistent).- Not in v1: authentication of any kind (Basic/Digest/NTLM/Negotiate/Kerberos,
proxy auth, credentials in URLs — actively rejected), WebSockets, HTTP/3,
client certificates (mutual TLS), upload streaming (whole-string bodies only),
per-request proxies/PAC, TLS escape hatches (no
insecure_skip_verify, no custom CA stores, no peer-cert accessor), response caps / retry / backoff / caching, charset transcoding (Response#texttags, never transcodes). - Well-known non-HTTP ports (21, 25, 110, …) are blocked by WinHTTP — the
original OS error surfaces as an
OSError. - One Windows identity per session — winhttp does no impersonation.
License
MIT.