Skip to content

Commit f8de24e

Browse files
quinnjclaude
andauthored
Keep caller spelling of header names and match names case-insensitively (#1378)
* Keep caller spelling of header names and match names case-insensitively HTTP.jl 2.0 through 2.7 canonicalized every header name on insertion (`providerId` became `Providerid`) because lookups compared stored names with `==` against a canonicalized key. HTTP.jl 1.x kept the caller's spelling and only canonicalized when `canonicalize_headers=true`, which was off by default. Servers that treat header names as case-sensitive data, such as Azure Service Bus custom properties, received the rewritten names, and 2.x offered no opt-out: `canonicalize_headers=false` only logged a "has no effect" warning. Fix the cause instead of the spelling: every `Headers` lookup and update (`header`, `headers`, `haskey`, `hasheader`, `setheader`, `appendheader`, `removeheader`, `headercontains`, `header_keys`, `merge!`) now matches names with an ASCII case fold, so stored names no longer need to be canonical and insertion keeps them as given. HTTP/1 sends them as spelled; HTTP/2 still lowercases. The HTTP/1 parser already canonicalized received names; the HTTP/2 client and server decoders now do so explicitly, so received messages look the same as before. The few exact-name checks outside the helpers (default Accept-Encoding, HTTP/2 Host-vs-:authority, request display) fold case too. `canonicalize_headers=true` rewrites request header names into `canonical_header_key` form again, as in 1.x, and the keyword no longer warns. Fixes #1377. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep case-insensitive header enumeration linear --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fe0bf17 commit f8de24e

14 files changed

Lines changed: 224 additions & 69 deletions

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5252
`max_line_bytes` may not exceed `max_header_bytes`. ([#1362])
5353

5454
### Changed
55+
- `HTTP.Headers` keeps each header name as the caller spelled it and matches
56+
names case-insensitively, as HTTP.jl 1.x did. HTTP.jl 2.0 through 2.7
57+
rewrote every name into `Content-Type` form on insertion, so a request header
58+
such as `providerId` went out as `Providerid`. That breaks servers that treat
59+
header names as case-sensitive data, such as Azure Service Bus custom
60+
message properties. HTTP/1 now sends names as spelled, HTTP/2 still sends
61+
them in lowercase, and headers received from the network still use
62+
`Content-Type` form. `canonicalize_headers=true` works again: it sends
63+
request header names in `Content-Type` form, and no longer logs a
64+
"has no effect" warning. ([#1377])
5565
- Raised the default HTTP/1 per-line limit (request/status lines and single
5666
header lines) from 8 KiB to 64 KiB, matching Python's `http.client`. Real
5767
origins send single header lines longer than 8 KiB — a 9,695-byte
@@ -969,3 +979,4 @@ See changes for 0.9.15: this release is equivalent to 0.9.15 with [#752] reverte
969979
[#1361]: https://github.com/JuliaWeb/HTTP.jl/issues/1361
970980
[#1362]: https://github.com/JuliaWeb/HTTP.jl/issues/1362
971981
[#1371]: https://github.com/JuliaWeb/HTTP.jl/issues/1371
982+
[#1377]: https://github.com/JuliaWeb/HTTP.jl/issues/1377

‎docs/src/guides/client.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,9 +549,10 @@ as temporary compatibility, not as the preferred API:
549549
- `retry_delays` and `retry_check` should become `retry_if`, `retries`, and
550550
`retry_bucket`
551551
- `sslconfig` and `socket_type_tls` should move to transport/TLS configuration
552-
- `canonicalize_headers`, `detect_content_type`,
553-
`observelayers`, `logerrors`, and `logtag` are accepted for compatibility
554-
where possible
552+
- `detect_content_type`, `observelayers`, `logerrors`, and `logtag` are
553+
accepted for compatibility where possible
554+
- `canonicalize_headers=true` sends request header names in `Content-Type`
555+
form, as in 1.x; by default names are sent as spelled
555556

556557
See the [migration guide](migration-1x.md) for before/after examples.
557558

‎docs/src/guides/migration-1x.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,12 @@ req = HTTP.Request(
168168
## Headers
169169

170170
`HTTP.Headers` is the canonical mutable header container. It preserves pair
171-
order and canonicalizes header keys on insertion.
171+
order and each header name as you spell it, as 1.x did. Lookups such as
172+
`HTTP.header(headers, "Content-Type")` match names in any case. Unlike 1.x,
173+
headers received from the network use `Content-Type` form. To send every
174+
request header name in that form, pass `canonicalize_headers=true`, as in 1.x.
175+
HTTP/2, which 2.0 uses for HTTPS when the server supports it, always sends
176+
names in lowercase; pass `protocol=:h1` to a server that needs exact spelling.
172177

173178
Before:
174179

@@ -676,9 +681,11 @@ abruptly:
676681
- `retry_delays` and `retry_check`: accepted, but use `retry_if`,
677682
`retries`, and `retry_bucket`
678683
- `sslconfig` and `socket_type_tls`: accepted, but configure the transport
679-
- `canonicalize_headers`, `detect_content_type`,
680-
`observelayers`, `logerrors`, and `logtag`: accepted for compatibility, but
681-
not the preferred 2.0 observation/configuration surface
684+
- `detect_content_type`, `observelayers`, `logerrors`, and `logtag`:
685+
accepted for compatibility, but not the preferred 2.0
686+
observation/configuration surface
687+
- `canonicalize_headers`: supported with its 1.x meaning. `true` sends request
688+
header names in `Content-Type` form. The default sends them as spelled.
682689

683690
Treat these as temporary migration aids. New code should use the documented
684691
2.0 API names.

‎src/http2_client.jl‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,7 +1651,7 @@ function _decode_response_headers(headers::Vector{HeaderField})::Tuple{Int,Heade
16511651
if _is_h2_connection_specific_header(name) || name == "transfer-encoding" || name == "te"
16521652
throw(ProtocolError("forbidden HTTP/2 response header $(repr(name))"))
16531653
end
1654-
appendheader(out, name, normalized)
1654+
appendheader(out, canonical_header_key(name), normalized)
16551655
end
16561656
status === nothing && throw(ProtocolError("missing HTTP/2 :status pseudo-header"))
16571657
return status::Int, out
@@ -1667,7 +1667,7 @@ function _decode_h2_trailer_headers(headers::Vector{HeaderField})::Headers
16671667
_valid_trailer_header_name(name) || throw(ProtocolError("invalid HTTP/2 trailer header $(repr(name))"))
16681668
normalized = _normalize_strict_header_field_value(value)
16691669
normalized === nothing && throw(ProtocolError("invalid HTTP/2 trailer field value for $(repr(name))"))
1670-
appendheader(out, name, normalized)
1670+
appendheader(out, canonical_header_key(name), normalized)
16711671
end
16721672
return out
16731673
end

‎src/http2_server.jl‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -982,7 +982,7 @@ function _validate_h2_request_headers!(headers::Vector{HeaderField})::Tuple{Stri
982982
end
983983
continue
984984
end
985-
appendheader(out_headers, name, normalized)
985+
appendheader(out_headers, canonical_header_key(name), normalized)
986986
end
987987
method === nothing && throw(ProtocolError("missing HTTP/2 :method pseudo-header"))
988988
# RFC 9113 8.3.1: "If the :authority pseudo-header field is present, the
@@ -998,7 +998,7 @@ function _validate_h2_request_headers!(headers::Vector{HeaderField})::Tuple{Stri
998998
# directly rather than calling the `headers(::Headers, key)` accessor.)
999999
if authority !== nothing
10001000
for (entry_name, entry_value) in out_headers.entries
1001-
entry_name == "Host" || continue
1001+
_ascii_equal_fold(entry_name, "Host") || continue
10021002
entry_value == authority || throw(ProtocolError("HTTP/2 Host header does not match :authority"))
10031003
end
10041004
end

‎src/http_client.jl‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,6 @@ end
224224
function _handle_client_compat_kwargs(;
225225
copyheaders=nothing,
226226
pool=nothing,
227-
canonicalize_headers=nothing,
228227
detect_content_type=nothing,
229228
observelayers=nothing,
230229
retry_delays=nothing,
@@ -235,7 +234,6 @@ function _handle_client_compat_kwargs(;
235234
logtag=nothing,
236235
)::Nothing
237236
pool === nothing || _warn_ignored_client_compat_kw("pool")
238-
canonicalize_headers === nothing || _warn_ignored_client_compat_kw("canonicalize_headers")
239237
detect_content_type === nothing || _warn_ignored_client_compat_kw("detect_content_type")
240238
observelayers === nothing || _warn_ignored_client_compat_kw("observelayers")
241239
retry_delays === nothing || _warn_ignored_client_compat_kw("retry_delays")
@@ -1855,9 +1853,20 @@ function _normalize_headers_input(headers_input, copyheaders::Bool=true)::Header
18551853
throw(ArgumentError("unsupported headers input type $(typeof(headers_input))"))
18561854
end
18571855

1856+
# `canonicalize_headers=true`: rewrite caller-spelled names in place, e.g.
1857+
# `x-request-id` becomes `X-Request-Id`.
1858+
function _canonicalize_header_names!(headers::Headers)::Nothing
1859+
entries = headers.entries
1860+
@inbounds for i in eachindex(entries)
1861+
key, value = entries[i]
1862+
entries[i] = canonical_header_key(key) => value
1863+
end
1864+
return nothing
1865+
end
1866+
18581867
function _apply_default_accept_encoding!(headers::Headers, decompress::Union{Nothing,Bool})::Nothing
18591868
decompress === false && return nothing
1860-
any(h -> h[1] == "Accept-Encoding", headers.entries) && return nothing
1869+
haskey(headers, "Accept-Encoding") && return nothing
18611870
setheader(headers, "Accept-Encoding", "gzip, deflate")
18621871
return nothing
18631872
end
@@ -2152,7 +2161,6 @@ function request(
21522161
_handle_client_compat_kwargs(
21532162
copyheaders=copyheaders,
21542163
pool=pool,
2155-
canonicalize_headers=canonicalize_headers,
21562164
detect_content_type=detect_content_type,
21572165
observelayers=observelayers,
21582166
retry_delays=retry_delays,
@@ -2188,6 +2196,7 @@ function request(
21882196
request_url = parsed.url
21892197
req_headers = _normalize_headers_input(headers, copyheaders)
21902198
_apply_client_default_headers!(req_headers, client)
2199+
canonicalize_headers === true && _canonicalize_header_names!(req_headers)
21912200
normalized_cookies = _normalize_cookies_input(cookies)
21922201
sink = _resolve_response_sink(response_stream)
21932202
sse_callback === nothing || sink === nothing || throw(ArgumentError("sse_callback cannot be combined with response_stream"))
@@ -2323,6 +2332,9 @@ Keyword arguments:
23232332
mutate it until the call completes; final contents are unspecified. Other
23242333
header inputs become a new collection either way. Retry attempts still
23252334
receive isolated headers.
2335+
- `canonicalize_headers`: header names are sent as the caller spelled them
2336+
(HTTP/2 sends them in lowercase). `true` rewrites every request header name
2337+
into [`canonical_header_key`](@ref) form first, as in HTTP.jl 1.x
23262338
- `decompress`: `nothing`/`true` auto-decompress gzip and deflate responses, `false` leaves wire bytes untouched
23272339
- `max_decompressed_size`: cap, in bytes, on an auto-decompressed response body; reading past it throws `DecompressionLimitError`, guarding against decompression bombs. Defaults to 64 MiB; `0` disables the limit
23282340
- `sse_callback`: callback receiving `(event)` or `(stream, event)` for
@@ -2354,7 +2366,7 @@ Keyword arguments:
23542366
HTTP.jl 2.0 accepts several HTTP.jl 1.x keywords as migration shims:
23552367
`readtimeout` maps to `read_idle_timeout`; `pool`, `retry_delays`,
23562368
`retry_check`, `sslconfig`, `socket_type_tls`,
2357-
`canonicalize_headers`, `detect_content_type`, `logerrors`, `logtag`, and
2369+
`detect_content_type`, `logerrors`, `logtag`, and
23582370
`observelayers` are accepted so older call sites fail less abruptly. Prefer the
23592371
2.0 forms listed above for new code: `client` / `transport` for pooling,
23602372
`retry_if` / `retry_bucket` for retries, Reseau `Transport` TLS configuration

‎src/http_core.jl‎

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -638,22 +638,30 @@ end
638638
"""
639639
Headers
640640
641-
Ordered, case-canonicalized collection of header pairs.
641+
Ordered collection of header pairs.
642642
643643
`Headers` deliberately behaves like `Vector{Pair{String, String}}` so code
644644
written against the long-standing pair-vector header representation can reuse
645-
the same helper functions. Keys are canonicalized on insertion, but pair order
646-
is preserved. Constructing from a vector, dictionary, or tuple goes through
647-
`mkheaders`: the source is left unchanged, keys are canonicalized, and adjacent
648-
duplicate keys are joined the way `appendheader` joins them.
645+
the same helper functions. Pair order is preserved.
646+
647+
Header names are case-insensitive (RFC 9110 §5.1). Every lookup and update
648+
(`header`, `setheader`, `removeheader`, `haskey`, ...) matches a name in any
649+
case, but a name is stored exactly as it was given. HTTP/1 sends it in that
650+
spelling; HTTP/2 sends every name in lowercase. Headers read from the network
651+
are stored in [`canonical_header_key`](@ref) form (`Content-Type`), so a
652+
response looks the same over HTTP/1 and HTTP/2.
653+
654+
Constructing from a vector, dictionary, or tuple goes through `mkheaders`: the
655+
source is left unchanged, and adjacent duplicate keys are joined the way
656+
`appendheader` joins them.
649657
"""
650658
mutable struct Headers <: AbstractVector{Pair{String,String}}
651659
entries::Vector{Pair{String,String}}
652660

653661
"""Create and return an empty `Headers` collection."""
654662
Headers() = new(Pair{String,String}[])
655663

656-
"""Copy pair storage without re-canonicalizing keys in an existing `Headers`."""
664+
"""Copy the pair storage of an existing `Headers`."""
657665
Headers(headers::Headers) = new(copy(headers.entries))
658666
end
659667

@@ -677,7 +685,7 @@ Headers(items::Tuple) = mkheaders(items)
677685
"""
678686
Headers(items...; kwargs...) -> Headers
679687
680-
Construct a canonicalized Headers from items and/or kwargs
688+
Construct a Headers from items and/or kwargs.
681689
"""
682690
Headers(items::Union{Pair,Tuple}...; kwargs...) = mkheaders(items...; kwargs...)
683691

@@ -702,7 +710,7 @@ Base.iterate(headers::Headers, state...) = iterate(headers.entries, state...)
702710
Base.getindex(headers::Headers, i::Int) = headers.entries[i]
703711

704712
@inline function _header_pair(key, value)::Pair{String,String}
705-
return canonical_header_key(String(key)) => String(value)
713+
return String(key) => String(value)
706714
end
707715

708716
function Base.setindex!(headers::Headers, item, i::Int)
@@ -716,7 +724,7 @@ end
716724

717725
function Base.merge!(headers::Headers, items)
718726
for (key, value) in Headers(items)
719-
if key == "Set-Cookie"
727+
if _ascii_equal_fold(key, "Set-Cookie")
720728
push!(headers.entries, key => value)
721729
else
722730
setheader(headers, key => value)
@@ -796,23 +804,26 @@ mkheaders(items::Tuple{Vararg{Union{Pair,Tuple}}}; kwargs...) =
796804

797805
mkheaders(items::Union{Pair,Tuple}...; kwargs...) = mkheaders(Base.Iterators.flatten((items, kwargs)))
798806

799-
"""Return a newly allocated `Vector{String}` of header keys in insertion order."""
807+
"""
808+
Return a newly allocated `Vector{String}` of header keys in insertion order.
809+
Names that differ only in case are listed once, in their first spelling.
810+
"""
800811
function header_keys(headers::Headers)::Vector{String}
801812
out = String[]
802813
seen = Set{String}()
803814
for (key, _) in headers
804-
key in seen && continue
805-
push!(seen, key)
815+
folded = _ascii_lowercase_string(key)
816+
folded in seen && continue
817+
push!(seen, folded)
806818
push!(out, key)
807819
end
808820
return out
809821
end
810822

811823
"""Return the first value for `key`, or `default` if the header is absent."""
812824
function header(headers::Headers, key::AbstractString, default="")
813-
canon = canonical_header_key(key)
814825
for (name, value) in headers
815-
name == canon && return value
826+
_ascii_equal_fold(name, key) && return value
816827
end
817828
return default
818829
end
@@ -824,10 +835,9 @@ Return a freshly allocated vector containing all values for `key` in stored
824835
order. Returns `String[]` when the header is absent.
825836
"""
826837
function headers(headers::Headers, key::AbstractString)::Vector{String}
827-
canon = canonical_header_key(key)
828838
out = String[]
829839
for (name, value) in headers
830-
name == canon && push!(out, value)
840+
_ascii_equal_fold(name, key) && push!(out, value)
831841
end
832842
return out
833843
end
@@ -840,10 +850,10 @@ end
840850
"""
841851
headers[key] -> String
842852
843-
Dict-style indexing on `Headers`. Returns the canonical first value for
844-
`key`, throwing `KeyError(key)` if the header is absent or has empty value.
845-
Use [`HTTP.header`](@ref) when you want a string default instead of an
846-
exception.
853+
Dict-style indexing on `Headers`. Returns the first value for `key`, matched
854+
case-insensitively, throwing `KeyError(key)` if the header is absent or has
855+
empty value. Use [`HTTP.header`](@ref) when you want a string default instead
856+
of an exception.
847857
"""
848858
function Base.getindex(headers::Headers, key::AbstractString)::String
849859
v = header(headers, key)
@@ -867,9 +877,8 @@ Dict-style `haskey` on `Headers`. Returns `true` when `key` is present
867877
(case-insensitive), regardless of whether its value is empty.
868878
"""
869879
function Base.haskey(headers::Headers, key::AbstractString)::Bool
870-
canon = canonical_header_key(key)
871880
for (name, _) in headers
872-
name == canon && return true
881+
_ascii_equal_fold(name, key) && return true
873882
end
874883
return false
875884
end
@@ -884,9 +893,8 @@ Return `true` when any stored header value for `key` matches `value`
884893
case-insensitively.
885894
"""
886895
function hasheader(headers::Headers, key::AbstractString, value::AbstractString)::Bool
887-
canon = canonical_header_key(key)
888896
for (name, current) in headers
889-
name == canon || continue
897+
_ascii_equal_fold(name, key) || continue
890898
_ascii_equal_fold(current, value) && return true
891899
end
892900
return false
@@ -897,7 +905,8 @@ end
897905
setheader(headers, key, value) -> Headers
898906
899907
Replace all stored values for `key` with `value`, preserving the first matching
900-
position if the key already exists and appending it otherwise. Returns the
908+
position if the key already exists and appending it otherwise. Names match in
909+
any case; the kept entry takes the spelling of `key`. Returns the
901910
mutated `headers`. [`setheader!`](@ref) is the same function under the
902911
conventional mutating-name spelling.
903912
"""
@@ -909,7 +918,7 @@ function setheader(headers::Headers, header::Pair)
909918
write_idx = 1
910919
@inbounds for read_idx in eachindex(entries)
911920
entry = entries[read_idx]
912-
if first(entry) == key
921+
if _ascii_equal_fold(first(entry), key)
913922
if first_idx == 0
914923
first_idx = write_idx
915924
entries[write_idx] = item
@@ -940,18 +949,18 @@ end
940949
941950
Append a header value to `headers`.
942951
943-
If the previous stored header has the same name and the key is not
944-
`Set-Cookie`, the value is merged into the previous entry with a comma
945-
(no whitespace), as permitted by RFC 9110 §5.3 and required by common
946-
request-signing canonicalizations.
952+
If the previous stored header has the same name (in any case) and the key is
953+
not `Set-Cookie`, the value is merged into the previous entry, which keeps its
954+
spelling, with a comma (no whitespace), as permitted by RFC 9110 §5.3 and
955+
required by common request-signing canonicalizations.
947956
Otherwise a new pair is appended. [`appendheader!`](@ref) is the same
948957
function under the conventional mutating-name spelling.
949958
"""
950959
function appendheader(headers::Headers, header::Pair)
951960
item = _header_pair(header.first, header.second)
952961
if !isempty(headers.entries)
953962
last_header = headers.entries[end]
954-
if first(item) != "Set-Cookie" && first(last_header) == first(item)
963+
if !_ascii_equal_fold(first(item), "Set-Cookie") && _ascii_equal_fold(first(last_header), first(item))
955964
headers.entries[end] = first(last_header) => string(last(last_header), ",", last(item))
956965
return headers
957966
end
@@ -972,12 +981,11 @@ Remove every stored header for `key` and return the mutated `headers`.
972981
mutating-name spelling.
973982
"""
974983
function removeheader(headers::Headers, key::AbstractString)
975-
canon = canonical_header_key(key)
976984
entries = headers.entries
977985
write_idx = 1
978986
@inbounds for read_idx in eachindex(entries)
979987
entry = entries[read_idx]
980-
if first(entry) == canon
988+
if _ascii_equal_fold(first(entry), key)
981989
continue
982990
end
983991
if write_idx != read_idx
@@ -1139,9 +1147,8 @@ tokens rather than one opaque string.
11391147
"""
11401148
function headercontains(headers::Headers, key::AbstractString, token::AbstractString)::Bool
11411149
needle = token isa String ? (token::String) : String(token)
1142-
canon = canonical_header_key(key)
11431150
for (name, value) in headers
1144-
name == canon || continue
1151+
_ascii_equal_fold(name, key) || continue
11451152
_header_value_contains_token(value, needle) && return true
11461153
end
11471154
return false

0 commit comments

Comments
 (0)