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 shipsWinHTTP 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 vcvars gem; if rake compile can't find the toolchain, run vcvars 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 as TimeoutError with code == 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 and TimeoutError is raised with code == 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 exceptionsget 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; set redirects: 0 if 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#body unless you pass a streaming block.
  • Response#headers reflects the wire headers, except that WinHTTP removes Content-Encoding/Content-Length once 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#text tags, 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.