Skip to content

Commit 4b6c835

Browse files
quinnjclaude
andauthored
Improve high-concurrency client throughput (#1347)
* perf(client): improve concurrent throughput Cache origins that negotiate HTTP/1.1 so automatic protocol selection does not repeat failed HTTP/2 handshakes. Avoid redundant per-chunk write deadline work, use a 64 KiB response copy buffer, and replay buffered bodies without copying their backing storage. Add regression coverage for protocol fallback, deadline refresh decisions, and independent replay cursors. * test(client): cover fallback cleanup paths * test(server): allow loaded runners five seconds for first byte * perf(client): decouple h1-origin cache from h2_lock; right-size copy buffers Review fixes for the high-concurrency throughput changes: - Guard `h1_origins` with its own lock. `_use_h2` previously took the client-global `h2_lock`, which is held across full TCP+TLS dials in `_acquire_h2_conn!`, so one slow HTTP/2 dial to any origin could stall every automatic-protocol HTTPS request on the client. The cache-hit fail-fast in `_acquire_h2_conn!` now also runs before `h2_lock`. - Clear the cache in `close_idle_connections!` so long-lived clients can re-probe origins that enable HTTP/2 later; document the cache behavior on `Client` and `close_idle_connections!`. - Make `allow_h1_alpn`/`auto_protocol` keyword arguments (the call site passed two identical adjacent positional booleans). - Right-size response copy buffers: clamp preallocated-destination copies to the destination capacity so small responses stop paying a 64 KiB scratch allocation per request, use 64 KiB for the known-large (>1 MiB hint) accumulation path and the response_stream pump, and name the 8 KiB small-loop constant with the sizing policy. - Test that a transient connect failure does not populate the h1-origin cache (only a genuine ALPN h1 negotiation may), and that close_idle_connections! clears it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(client): recheck h1 cache after h2 contention --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 34882ba commit 4b6c835

7 files changed

Lines changed: 343 additions & 49 deletions

src/http_client.jl

Lines changed: 119 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# High-level HTTP client orchestration, HTTP/2 integration, cookies, response sinks, and convenience APIs.
22

3+
# Copy-loop buffer for response bodies that are known (or likely) to be large:
4+
# 64 KiB measured faster than 8 KiB and 256 KiB for high-throughput downloads.
5+
# Loops whose payload is typically small — unknown-size accumulation of
6+
# chunked responses and decompression output — keep 8 KiB buffers so a tiny
7+
# response doesn't pay a 64 KiB allocation per request.
8+
const _RESPONSE_COPY_BUFFER_BYTES = 64 * 1024
9+
const _RESPONSE_SMALL_COPY_BUFFER_BYTES = 8 * 1024
10+
311
"""
412
Client(; ...)
513
@@ -15,7 +23,11 @@ Keyword arguments:
1523
followed
1624
- `cookiejar`: cookie jar implementation, or `nothing` to disable cookies
1725
- `max_redirects`: maximum redirect hops before failing
18-
- `prefer_http2`: whether secure requests should try HTTP/2 when available
26+
- `prefer_http2`: whether secure requests should try HTTP/2 when available.
27+
When automatic negotiation (`protocol = :auto`) learns an origin only
28+
speaks HTTP/1.1, that result is cached and later automatic requests skip
29+
the HTTP/2 attempt; [`close_idle_connections!`](@ref) clears the cache.
30+
An explicit `protocol = :h2` always attempts HTTP/2.
1931
- `http2_settings`: an [`HTTP2Settings`](@ref) configuring HTTP/2 receive
2032
flow-control windows for connections this client opens
2133
- `default_headers`: headers applied to every request issued through this
@@ -48,6 +60,12 @@ mutable struct Client{CR}
4860
http2_settings::HTTP2Settings
4961
h2_lock::ReentrantLock
5062
h2_conns::Dict{String,Vector{H2Connection}}
63+
# Origins that negotiated HTTP/1.1 during `protocol = :auto`. Guarded by
64+
# its own lock (never `h2_lock`): `h2_lock` is held across full TCP+TLS
65+
# dials in `_acquire_h2_conn!`, and the hot-path membership check in
66+
# `_use_h2` must not serialize behind those dials.
67+
h1_origins_lock::ReentrantLock
68+
h1_origins::Set{String}
5169
default_headers::Headers
5270
default_query::Union{Nothing,Vector{Pair{String,String}}}
5371
default_basicauth::Any
@@ -342,6 +360,8 @@ function Client(;
342360
http2_settings,
343361
ReentrantLock(),
344362
Dict{String,Vector{H2Connection}}(),
363+
ReentrantLock(),
364+
Set{String}(),
345365
_normalize_headers_input(default_headers),
346366
_normalize_default_query(default_query),
347367
default_basicauth,
@@ -445,10 +465,16 @@ function _acquire_h2_conn!(
445465
address::String,
446466
secure::Bool,
447467
request::Union{Nothing,Request}=nothing,
448-
server_name::Union{Nothing,String}=nothing,
468+
server_name::Union{Nothing,String}=nothing;
449469
allow_h1_alpn::Bool=false,
470+
auto_protocol::Bool=false,
450471
)::H2Connection
451472
key = _h2_key(plan)
473+
# Fail fast (and without touching `h2_lock`) when another task cached this
474+
# origin as HTTP/1.1 after our caller's `_use_h2` check.
475+
if auto_protocol && _h1_origin_cached(client, key)
476+
throw(H2NegotiationError("http2: origin previously negotiated HTTP/1.1"))
477+
end
452478
base_host_resolver = client.transport.host_resolver
453479
connect_host_resolver = request === nothing ? base_host_resolver : _request_connect_host_resolver(base_host_resolver, request::Request)
454480
connect_deadline_ns = request === nothing ? _phase_deadline_ns(base_host_resolver.timeout_ns, base_host_resolver.deadline_ns) : _request_connect_phase_deadline_ns(base_host_resolver, request::Request)
@@ -457,8 +483,15 @@ function _acquire_h2_conn!(
457483
# closing an H2Connection waits for its read loop to exit, and that must
458484
# not happen while holding the pool lock.
459485
to_close = H2Connection[]
460-
lock(client.h2_lock)
486+
waited_for_h2_lock = !trylock(client.h2_lock)
487+
waited_for_h2_lock && lock(client.h2_lock)
461488
try
489+
# The origin can be cached while this task waits for `h2_lock`. Only
490+
# contended acquisitions need this second check, so the uncontended h2
491+
# fast path does not pay for another cache lookup.
492+
if waited_for_h2_lock && auto_protocol && _h1_origin_cached(client, key)
493+
throw(H2NegotiationError("http2: origin previously negotiated HTTP/1.1"))
494+
end
462495
conns = get(() -> H2Connection[], client.h2_conns, key)
463496
idle_timeout_ns = client.transport.idle_timeout_ns
464497
now_ns = Int64(time_ns())
@@ -504,36 +537,43 @@ function _acquire_h2_conn!(
504537
nothing
505538
end
506539
conn = nothing
507-
conn = if plan.mode == _ProxyPlanMode.DIRECT
508-
connect_h2!(
509-
address;
510-
secure=secure,
511-
host_resolver=connect_host_resolver,
512-
tls_config=tls_cfg,
513-
connect_deadline_ns=connect_deadline_ns,
514-
http2_settings=client.http2_settings,
515-
)
516-
elseif plan.mode == _ProxyPlanMode.HTTP_TUNNEL || _proxy_plan_is_socks(plan)
517-
proxy = plan.proxy
518-
proxy === nothing && throw(ProtocolError("proxy connection is missing proxy config"))
519-
tcp = _new_tcp_conn!(plan, address, connect_host_resolver, connect_deadline_ns)
520-
try
540+
conn = try
541+
if plan.mode == _ProxyPlanMode.DIRECT
521542
connect_h2!(
522-
tcp,
523543
address;
524544
secure=secure,
545+
host_resolver=connect_host_resolver,
525546
tls_config=tls_cfg,
526547
connect_deadline_ns=connect_deadline_ns,
527548
http2_settings=client.http2_settings,
528549
)
529-
catch
530-
@try_ignore begin
531-
TCP.close(tcp)
550+
elseif plan.mode == _ProxyPlanMode.HTTP_TUNNEL || _proxy_plan_is_socks(plan)
551+
proxy = plan.proxy
552+
proxy === nothing && throw(ProtocolError("proxy connection is missing proxy config"))
553+
tcp = _new_tcp_conn!(plan, address, connect_host_resolver, connect_deadline_ns)
554+
try
555+
connect_h2!(
556+
tcp,
557+
address;
558+
secure=secure,
559+
tls_config=tls_cfg,
560+
connect_deadline_ns=connect_deadline_ns,
561+
http2_settings=client.http2_settings,
562+
)
563+
catch
564+
@try_ignore begin
565+
TCP.close(tcp)
566+
end
567+
rethrow()
532568
end
533-
rethrow()
569+
else
570+
throw(ArgumentError("HTTP/2 is not supported for proxy plan mode $(plan.mode)"))
534571
end
535-
else
536-
throw(ArgumentError("HTTP/2 is not supported for proxy plan mode $(plan.mode)"))
572+
catch err
573+
if auto_protocol && _should_fallback_h2_to_h1(err)
574+
_cache_h1_origin!(client, key)
575+
end
576+
rethrow()
537577
end
538578
# Claim a slot on the freshly opened connection up front so subsequent
539579
# acquirers that race in see this caller's pending request.
@@ -581,7 +621,7 @@ function _drop_h2_conn!(client::Client, plan::_ProxyPlan, target::Union{Nothing,
581621
return nothing
582622
end
583623

584-
function _use_h2(client::Client, secure::Bool, protocol::Symbol)::Bool
624+
function _use_h2(client::Client, plan::_ProxyPlan, secure::Bool, protocol::Symbol)::Bool
585625
protocol == :h1 && return false
586626
protocol == :h2 && return true
587627
protocol == :auto || throw(ArgumentError("protocol must be :auto, :h1, or :h2"))
@@ -594,7 +634,26 @@ function _use_h2(client::Client, secure::Bool, protocol::Symbol)::Bool
594634
if cfg !== nothing && !isempty(cfg.alpn_protocols) && !in("h2", cfg.alpn_protocols)
595635
return false
596636
end
597-
return true
637+
return !_h1_origin_cached(client, _h2_key(plan))
638+
end
639+
640+
@inline function _h1_origin_cached(client::Client, key::String)::Bool
641+
lock(client.h1_origins_lock)
642+
try
643+
return key in client.h1_origins
644+
finally
645+
unlock(client.h1_origins_lock)
646+
end
647+
end
648+
649+
function _cache_h1_origin!(client::Client, key::String)::Nothing
650+
lock(client.h1_origins_lock)
651+
try
652+
push!(client.h1_origins, key)
653+
finally
654+
unlock(client.h1_origins_lock)
655+
end
656+
return nothing
598657
end
599658

600659
function _host_path_from_request(address::String, request::Request)::Tuple{String,String}
@@ -678,11 +737,10 @@ function _store_set_cookies!(
678737
end
679738

680739
function _clone_bytes_body(body::BytesBody)::BytesBody
681-
remaining = (length(body.data) - body.next_index) + 1
682-
remaining <= 0 && return BytesBody(UInt8[])
683-
copied = Vector{UInt8}(undef, remaining)
684-
copyto!(copied, 1, body.data, body.next_index, remaining)
685-
return BytesBody(copied)
740+
# BytesBody retains its backing bytes by contract. A replay only needs an
741+
# independent cursor and closed flag; copying the payload makes every
742+
# buffered upload allocate and copy the complete request body again.
743+
return BytesBody(body.data, body.next_index, false)
686744
end
687745

688746
function _clone_body(body::AbstractBody)::AbstractBody
@@ -792,7 +850,8 @@ function _do_incoming!(
792850
send_request = _copy_request_for_send(current_request, retry_attempt == 1)
793851
request_url = _request_url(current_secure, current_address, current_request.target)
794852
proxy_plan = _proxy_plan(proxy_config, current_secure, current_address)
795-
use_h2 = _use_h2(client, current_secure, protocol) && proxy_plan.mode != _ProxyPlanMode.HTTP_FORWARD
853+
use_h2 = proxy_plan.mode != _ProxyPlanMode.HTTP_FORWARD &&
854+
_use_h2(client, proxy_plan, current_secure, protocol)
796855
_emit_trace(trace, RequestEvent(send_request, request_url, retry_attempt, redirect_count, use_h2 ? :h2 : :h1))
797856
host, path = _host_path_from_request(current_address, current_request)
798857
manual_cookies = cookies === false ? Cookie[] : Cookies.readcookies(send_request.headers, "")
@@ -808,8 +867,9 @@ function _do_incoming!(
808867
current_address,
809868
current_secure,
810869
send_request,
811-
current_server_name,
812-
protocol == :auto,
870+
current_server_name;
871+
allow_h1_alpn=(protocol == :auto),
872+
auto_protocol=(protocol == :auto),
813873
)
814874
_h2_roundtrip_incoming!(conn::H2Connection, send_request; pending_slot_claimed=true)
815875
catch err
@@ -1186,6 +1246,14 @@ end
11861246

11871247
function close_idle_connections!(client::Client)
11881248
close_idle_connections!(client.transport)
1249+
# Drop cached HTTP/1.1 negotiation results so long-lived clients re-probe
1250+
# origins that may have enabled HTTP/2 since they were first contacted.
1251+
lock(client.h1_origins_lock)
1252+
try
1253+
empty!(client.h1_origins)
1254+
finally
1255+
unlock(client.h1_origins_lock)
1256+
end
11891257
# Also close pooled HTTP/2 connections with no in-flight streams — these
11901258
# live on the Client (not the Transport pool) and are equally subject to
11911259
# silent idle drops by NATs/load balancers (#1331). Connections carrying
@@ -1238,7 +1306,7 @@ end
12381306

12391307
function _read_all_response_bytes(io::IO, limit::Int=0)::Vector{UInt8}
12401308
out = UInt8[]
1241-
buf = Vector{UInt8}(undef, 8192)
1309+
buf = Vector{UInt8}(undef, _RESPONSE_SMALL_COPY_BUFFER_BYTES)
12421310
total = 0
12431311
while true
12441312
n = readbytes!(io, buf, length(buf))
@@ -1260,7 +1328,10 @@ function _read_all_response_bytes(body::AbstractBody, content_length_hint::Int64
12601328
end
12611329
out = UInt8[]
12621330
content_length_hint > 0 && sizehint!(out, Int(min(content_length_hint, _MAX_EAGER_RESPONSE_PREALLOC)))
1263-
buf = Vector{UInt8}(undef, 8192)
1331+
# A hint above the prealloc cap means a known-large download; without a
1332+
# hint (chunked/EOF-framed) the body is usually small, so stay at 8 KiB.
1333+
buf_bytes = content_length_hint > _MAX_EAGER_RESPONSE_PREALLOC ? _RESPONSE_COPY_BUFFER_BYTES : _RESPONSE_SMALL_COPY_BUFFER_BYTES
1334+
buf = Vector{UInt8}(undef, buf_bytes)
12641335
while true
12651336
n = body_read!(body, buf)
12661337
n == 0 && return out
@@ -1269,7 +1340,7 @@ function _read_all_response_bytes(body::AbstractBody, content_length_hint::Int64
12691340
end
12701341

12711342
function _copy_response_bytes!(dest::IO, io::IO, limit::Int=0)::Int64
1272-
buf = Vector{UInt8}(undef, 8192)
1343+
buf = Vector{UInt8}(undef, _RESPONSE_SMALL_COPY_BUFFER_BYTES)
12731344
total = Int64(0)
12741345
while true
12751346
n = readbytes!(io, buf, length(buf))
@@ -1281,9 +1352,12 @@ function _copy_response_bytes!(dest::IO, io::IO, limit::Int=0)::Int64
12811352
end
12821353

12831354
function _copy_response_bytes!(dest::AbstractVector{UInt8}, io::IO, limit::Int=0)::Int64
1284-
buf = Vector{UInt8}(undef, 8192)
1285-
total = 0
12861355
capacity = length(dest)
1356+
# `dest` is preallocated to the expected size; a small response should not
1357+
# pay a 64 KiB scratch allocation. The 1-byte floor keeps the capacity
1358+
# overflow check below reachable when `dest` is empty.
1359+
buf = Vector{UInt8}(undef, min(max(capacity, 1), _RESPONSE_COPY_BUFFER_BYTES))
1360+
total = 0
12871361
while true
12881362
n = readbytes!(io, buf, length(buf))
12891363
n == 0 && break
@@ -1298,7 +1372,7 @@ function _copy_response_bytes!(dest::AbstractVector{UInt8}, io::IO, limit::Int=0
12981372
end
12991373

13001374
function _copy_response_bytes!(dest::IO, body::AbstractBody)::Int64
1301-
buf = Vector{UInt8}(undef, 8192)
1375+
buf = Vector{UInt8}(undef, _RESPONSE_COPY_BUFFER_BYTES)
13021376
total = Int64(0)
13031377
while true
13041378
n = body_read!(body, buf)
@@ -1309,9 +1383,10 @@ function _copy_response_bytes!(dest::IO, body::AbstractBody)::Int64
13091383
end
13101384

13111385
function _copy_response_bytes!(dest::AbstractVector{UInt8}, body::AbstractBody)::Int64
1312-
buf = Vector{UInt8}(undef, 8192)
1313-
total = 0
13141386
capacity = length(dest)
1387+
# See the sizing rationale on the `io::IO` method above.
1388+
buf = Vector{UInt8}(undef, min(max(capacity, 1), _RESPONSE_COPY_BUFFER_BYTES))
1389+
total = 0
13151390
while true
13161391
n = body_read!(body, buf)
13171392
n == 0 && break
@@ -1356,7 +1431,7 @@ end
13561431
end
13571432

13581433
function _pump_response_body!(stream::Base.BufferStream, body::AbstractBody)::Nothing
1359-
buf = Vector{UInt8}(undef, 8192)
1434+
buf = Vector{UInt8}(undef, _RESPONSE_COPY_BUFFER_BYTES)
13601435
try
13611436
while true
13621437
n = body_read!(body, buf)
@@ -1466,7 +1541,7 @@ end
14661541

14671542
function Base.read(io::_BodyIO)::Vector{UInt8}
14681543
out = UInt8[]
1469-
buf = Vector{UInt8}(undef, 8192)
1544+
buf = Vector{UInt8}(undef, _RESPONSE_SMALL_COPY_BUFFER_BYTES)
14701545
while true
14711546
n = readbytes!(io, buf)
14721547
n == 0 && break

src/http_transport.jl

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,14 @@ end
6060
return reader.stop - reader.next + 1
6161
end
6262

63+
@inline function _request_write_deadline_needs_refresh(request::Request)::Bool
64+
return _request_write_idle_timeout_ns(request) > 0
65+
end
66+
6367
@inline function _apply_request_write_deadline!(io::_RequestDeadlineWriteIO)::Nothing
68+
# The overall request deadline is applied once when the connection is
69+
# acquired. Only an idle timeout moves after each write and needs refresh.
70+
_request_write_deadline_needs_refresh(io.request) || return nothing
6471
_set_conn_write_deadline!(io.conn::Conn, _request_write_deadline_ns(io.request))
6572
return nothing
6673
end
@@ -1192,7 +1199,9 @@ connection pool.
11921199
11931200
The `Client` and no-argument forms also close the client's pooled HTTP/2
11941201
connections that have no in-flight streams; the `Transport` form covers only
1195-
the HTTP/1 pool it owns.
1202+
the HTTP/1 pool it owns. They additionally clear the client's cache of origins
1203+
that negotiated HTTP/1.1 under `protocol = :auto`, so subsequent automatic
1204+
requests re-attempt HTTP/2 against origins that may have enabled it since.
11961205
"""
11971206
function close_idle_connections!(transport::Transport)
11981207
to_close = Conn[]

0 commit comments

Comments
 (0)