Files

404 lines
15 KiB
V

// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
@[has_globals]
module http
import net
import net.ssl
import sync
import time
// This file implements connection reuse for the HTTP client: a Transport owns
// a pool of keep-alive connections, keyed by origin and TLS configuration, and
// fetch()/Request.do() route through a process-global default Transport unless
// the request opts out (`disable_connection_reuse`) or uses a proxy. The first
// cut pools HTTP/1.1 connections (plain TCP and TLS); HTTP/2 requests run on
// the existing one-shot driver until the multiplexed H2 connection lands.
// transport_err_unsafe_retry tags a failed exchange on a transport connection
// for a non-idempotent method: the request bytes may have (partially) reached
// the server — the write helpers cannot prove otherwise — so it must not be
// replayed, neither by round_trip's inner loop nor the caller's outer retry
// loop. is_no_need_retry_error recognizes this code so the outer loop honors it.
const transport_err_unsafe_retry = -20013
// H1PooledConn is one keep-alive HTTP/1.1 connection in a Transport pool:
// either a plain TCP connection or a TLS one (exactly one of the two is set).
@[heap]
struct H1PooledConn {
mut:
key string
tcp &net.TcpConn = unsafe { nil }
ssl &ssl.SSLConn = unsafe { nil }
idle_since time.Time
}
// close_conn shuts the underlying connection down and clears it.
fn (mut c H1PooledConn) close_conn() {
if c.ssl != unsafe { nil } {
c.ssl.shutdown() or {}
c.ssl = unsafe { nil }
}
if c.tcp != unsafe { nil } {
c.tcp.close() or {}
c.tcp = unsafe { nil }
}
}
// refresh_timeouts applies the current request's timeouts to a pooled
// connection, which may have been dialled by a request with different ones.
fn (mut c H1PooledConn) refresh_timeouts(req &Request) {
if c.tcp != unsafe { nil } {
c.tcp.set_read_timeout(req.read_timeout)
c.tcp.set_write_timeout(req.write_timeout)
} else if c.ssl != unsafe { nil } {
if req.read_timeout > 0 {
c.ssl.set_read_timeout(req.read_timeout)
}
}
}
// exchange runs one request/response over the pooled connection, leaving it
// open. The bool result reports whether the response was precisely framed, so
// the connection can safely carry another request.
fn (mut c H1PooledConn) exchange(req &Request, raw string) !(Response, bool) {
if c.ssl != unsafe { nil } {
return req.h1_exchange_ssl(mut c.ssl, raw)
}
return req.h1_exchange_tcp(mut c.tcp, raw)
}
// Transport holds the connection pools and reuse policy for HTTP requests. It
// is safe for concurrent use; fetch()/Request.do() share one process-global
// instance (see default_transport).
//
// Note: idle_timeout is enforced lazily on checkout of the same pool key; there
// is no background reaper, so a connection to an origin that is never revisited
// is reclaimed by the global max_idle_conns cap (or close_idle_connections())
// rather than on a timer. A time-based reaper is a planned follow-up.
@[heap]
pub struct Transport {
pub mut:
// max_idle_conns_per_host caps the idle keep-alive connections retained per
// pool key (origin + TLS configuration).
max_idle_conns_per_host int = 4
// max_idle_conns caps the total idle keep-alive connections kept across all
// pool keys, bounding file-descriptor use when many distinct origins are
// fetched. The least-recently-used idle connection is evicted on overflow.
// 0 disables the global cap.
max_idle_conns int = 100
// idle_timeout is how long an idle connection may sit in the pool before it
// is discarded instead of reused.
idle_timeout time.Duration = 90 * time.second
mut:
mu &sync.Mutex = sync.new_mutex()
h1_idle map[string][]&H1PooledConn
}
// new_transport creates an empty Transport with default limits.
pub fn new_transport() &Transport {
return &Transport{}
}
// Initialized eagerly in _vinit() (before main() and before any thread is
// spawned), so the shared default Transport is fully constructed without a
// concurrent-startup race. Lazy init via sync.Once is unsafe here: Once sets its
// done flag before running the callback, so a second caller can observe "done"
// and read this global while it is still nil (vlang/v#27456).
__global http_transport_default = new_transport()
// default_transport returns the process-global Transport that fetch() and
// Request.do() route through.
pub fn default_transport() &Transport {
return http_transport_default
}
// close_idle_connections closes every idle pooled connection held by the
// default Transport. Useful before fork()-style process handoffs, or in tests.
pub fn close_idle_connections() {
mut t := default_transport()
t.close_idle()
}
// close_idle closes every idle pooled connection held by this Transport.
// In-flight requests are unaffected.
pub fn (mut t Transport) close_idle() {
mut all := []&H1PooledConn{}
t.mu.lock()
for _, list in t.h1_idle {
for c in list {
all << c
}
}
t.h1_idle.clear()
t.mu.unlock()
for mut c in all {
c.close_conn()
}
}
// transport_pool_key builds the pool key for a request: connections are only
// shared between requests whose origin and TLS-relevant settings all match.
// enable_http2 is part of the key because it changes what the connection
// advertised via ALPN at dial time: a forced-HTTP/1.1 connection (no ALPN)
// must not satisfy an HTTP/2-enabled request, which would otherwise silently
// never negotiate h2 against that origin while the pooled connection lives.
fn transport_pool_key(req &Request, scheme string, host string, port int) string {
// enable_http2 only affects https dials (plain http ignores it), so keep
// the plain-http pool unsplit.
h2 := scheme == 'https' && req.enable_http2
// Length-prefix the free-form string fields (host and the TLS paths/PEM
// blobs) so a value containing the '|' separator cannot collide with a
// different field split — e.g. cert='a|b',cert_key='c' vs cert='a',
// cert_key='b|c' — which would let a request reuse a connection dialed with
// the wrong CA or client certificate. Bools and the int port cannot contain
// '|', and scheme is a fixed literal, so they need no prefixing.
return '${scheme}|${pk_part(host)}|${port}|${h2}|${req.validate}|${pk_part(req.verify)}|${pk_part(req.cert)}|${pk_part(req.cert_key)}|${req.in_memory_verification}'
}
// pk_part length-prefixes a string (`len:value`) so concatenated pool-key
// components stay unambiguous regardless of the bytes they contain.
fn pk_part(s string) string {
return '${s.len}:${s}'
}
// transport_is_idempotent reports whether a request with this method can be
// replayed safely after a failure on a reused connection (RFC 7231 4.2.2).
fn transport_is_idempotent(method Method) bool {
return method in [.get, .head, .options, .trace, .put, .delete]
}
// response_allows_reuse reports whether the server's response permits keeping
// the connection open for another request.
fn response_allows_reuse(resp &Response) bool {
if resp.status_code < 200 {
// 1xx handling stops at the headers; the connection state past them is
// not tracked, so never reuse.
return false
}
// A server may split Connection across repeated header lines; get() returns
// only the first, so join all values before scanning for the close token.
conn_tokens := resp.header.values(.connection).join(',').to_lower()
if conn_tokens.contains('close') {
return false
}
ver := resp.version()
if ver == .v1_0 {
// HTTP/1.0 defaults to close; reuse only with an explicit keep-alive.
return conn_tokens.contains('keep-alive')
}
return ver == .v1_1
}
// checkout pops the most recently used idle connection for `key`, discarding
// any that sat idle past the timeout. Returns nil when the pool has none.
fn (mut t Transport) checkout(key string) &H1PooledConn {
mut expired := []&H1PooledConn{}
mut found := &H1PooledConn(unsafe { nil })
t.mu.lock()
for {
mut list := t.h1_idle[key] or { break }
if list.len == 0 {
break
}
mut c := list.pop()
t.h1_idle[key] = list
if time.now() - c.idle_since > t.idle_timeout {
expired << c
continue
}
found = c
break
}
t.mu.unlock()
for mut c in expired {
c.close_conn()
}
return found
}
// checkin returns a healthy connection to the idle pool, or closes it when the
// per-host pool is already at capacity. Adding it may push the total idle count
// over max_idle_conns, in which case the least-recently-used idle connection
// across all pools is evicted and closed.
fn (mut t Transport) checkin(mut conn H1PooledConn) {
conn.idle_since = time.now()
mut evicted := &H1PooledConn(unsafe { nil })
t.mu.lock()
mut list := t.h1_idle[conn.key] or { []&H1PooledConn{} }
if list.len >= t.max_idle_conns_per_host {
t.mu.unlock()
conn.close_conn()
return
}
list << conn
t.h1_idle[conn.key] = list
if t.max_idle_conns > 0 && t.total_idle_locked() > t.max_idle_conns {
evicted = t.evict_oldest_locked()
}
t.mu.unlock()
if evicted != unsafe { nil } {
evicted.close_conn()
}
}
// total_idle_locked sums the idle connections across all pool keys. The caller
// must hold t.mu.
fn (t &Transport) total_idle_locked() int {
mut n := 0
for _, list in t.h1_idle {
n += list.len
}
return n
}
// evict_oldest_locked removes and returns the least-recently-used idle
// connection across all pool keys (nil if the pool is empty), pruning an
// emptied key. The caller must hold t.mu and close the returned connection.
fn (mut t Transport) evict_oldest_locked() &H1PooledConn {
mut oldest_key := ''
mut oldest_idx := -1
mut oldest_since := time.now()
for k, list in t.h1_idle {
for i, c in list {
if oldest_idx == -1 || c.idle_since < oldest_since {
oldest_key = k
oldest_idx = i
oldest_since = c.idle_since
}
}
}
if oldest_idx == -1 {
return &H1PooledConn(unsafe { nil })
}
mut list := t.h1_idle[oldest_key] or { return &H1PooledConn(unsafe { nil }) }
victim := list[oldest_idx]
list.delete(oldest_idx)
if list.len == 0 {
t.h1_idle.delete(oldest_key)
} else {
t.h1_idle[oldest_key] = list
}
return victim
}
// maybe_checkin pools the connection when both the read framing and the
// response (and request) headers allow reuse; otherwise it closes it.
fn (mut t Transport) maybe_checkin(mut conn H1PooledConn, header Header, reusable bool, resp &Response) {
if reusable && !header.contains(.connection) && response_allows_reuse(resp) {
t.checkin(mut conn)
return
}
conn.close_conn()
}
// round_trip performs one HTTP request through the connection pool: it reuses
// an idle connection for the request's pool key when one exists (transparently
// retrying once on a connection that turned out to be stale), dials otherwise,
// and returns healthy connections to the pool afterwards.
fn (mut t Transport) round_trip(req &Request, method Method, scheme string, host string, port int, path string, data string, header Header) !Response {
$if windows && !no_vschannel ? {
if scheme == 'https' {
// The SChannel backend keeps its proven one-shot path until SChannel
// pooling lands; plain-http pooling below already works on Windows.
return req.ssl_do(port, method, host, path, data, header)
}
}
raw := req.build_request_headers_opts(method, host, port, path, data, header, false)
$if trace_http_request ? {
eprint('> ')
eprint(raw)
eprintln('')
}
key := transport_pool_key(req, scheme, host, port)
// A stale pooled connection (closed by the server while idle) fails the
// exchange; drain through the pool, then dial fresh.
for _ in 0 .. t.max_idle_conns_per_host + 1 {
mut conn := t.checkout(key)
mut reused := true
if conn == unsafe { nil } {
reused = false
if scheme == 'https' {
return t.tls_fresh_round_trip(req, key, raw, method, host, port, path, data, header)
}
conn = t.dial_h1_tcp(req, key, host, port)!
} else {
conn.refresh_timeouts(req)
}
resp, reusable := conn.exchange(req, raw) or {
conn.close_conn()
// The write helpers do partial writes and cannot report how many
// bytes reached the server, so a failed exchange may have delivered
// part of the request. Only idempotent methods are safe to replay;
// a non-idempotent one is tagged so neither this loop nor the
// caller's outer retry loop re-sends it (avoiding duplicate side
// effects). A pre-write dial failure, by contrast, propagates before
// this point and stays freely retryable.
if !transport_is_idempotent(method) {
return error_with_code(err.msg(), transport_err_unsafe_retry)
}
if reused {
// Drain stale pooled connections, then fall through to a fresh dial.
continue
}
return err
}
t.maybe_checkin(mut conn, header, reusable, resp)
return resp
}
return error('http.transport: request failed after retrying on a fresh connection')
}
// dial_h1_tcp opens a fresh plain-TCP connection for `key`.
fn (mut t Transport) dial_h1_tcp(req &Request, key string, host string, port int) !&H1PooledConn {
mut client := net.dial_tcp('${host}:${port}')!
client.set_read_timeout(req.read_timeout)
client.set_write_timeout(req.write_timeout)
return &H1PooledConn{
key: key
tcp: client
}
}
// tls_fresh_round_trip dials a fresh TLS connection (advertising ALPN h2 when
// HTTP/2 is enabled) and completes the request on it. When the server selects
// h2, the request runs on the existing one-shot HTTP/2 driver and the
// connection is not pooled yet; an http/1.1 connection is pooled afterwards
// like any other.
fn (mut t Transport) tls_fresh_round_trip(req &Request, key string, raw string, method Method, host string, port int, path string, data string, header Header) !Response {
alpn := if req.enable_http2 { ['h2', 'http/1.1'] } else { []string{} }
mut ssl_conn := ssl.new_ssl_conn(
verify: req.verify
cert: req.cert
cert_key: req.cert_key
validate: req.validate
in_memory_verification: req.in_memory_verification
alpn_protocols: alpn
)!
ssl_conn.dial(host, port)!
if req.read_timeout > 0 {
ssl_conn.set_read_timeout(req.read_timeout)
}
if req.enable_http2 && ssl_conn.negotiated_alpn() == 'h2' {
return req.h2_do(mut ssl_conn, method, host, port, path, data, header)
}
mut conn := &H1PooledConn{
key: key
ssl: ssl_conn
}
resp, reusable := conn.exchange(req, raw) or {
conn.close_conn()
// Past the TLS handshake the request bytes may have been (partially)
// written; a non-idempotent method must not be replayed by the outer
// retry loop. (A dial/handshake failure above propagates before this and
// stays retryable, since no request byte was sent.)
if !transport_is_idempotent(method) {
return error_with_code(err.msg(), transport_err_unsafe_retry)
}
return err
}
t.maybe_checkin(mut conn, header, reusable, resp)
return resp
}