Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ internal/examples/thrift-oneway/thrift-oneway
internal/service-test/service-test
.idea
.vscode/
.bench/
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@
/internal/service-test/service-test
.idea
.vscode/
.bench/
24 changes: 24 additions & 0 deletions transport/http/header.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ func (hm headerMapper) ToHTTPHeaders(from transport.Headers, to http.Header) htt
return to
}

// ToHTTPHeadersPreserveCase converts application headers into HTTP headers,
// writing keys directly into the destination map.
//
// Unlike ToHTTPHeaders, this method does not use net/http Header.Set/Add and
// therefore skips textproto.CanonicalMIMEHeaderKey, avoiding its per-key
// allocation in hot paths. Keys are written with the original casing supplied
// by the caller (via transport.Headers.With or HeaderMapping). This is
// intended for use on the HTTP/2 outbound path, where the wire format
// mandates lowercase header names regardless of map casing.
func (hm headerMapper) ToHTTPHeadersPreserveCase(from transport.Headers, to http.Header) http.Header {
if to == nil {
to = make(http.Header, from.OriginalItemsLen())
}
for key, val := range from.OriginalItems() {
if isTracingHeader(key) || isRoutingHeader(key) {
to[key] = append(to[key], val)
} else {
prefixedKey := hm.Prefix + key
to[prefixedKey] = append(to[prefixedKey], val)
}
}
return to
}

// fromHTTPHeaders converts HTTP headers to application headers.
//
// Headers are read from 'from' and written to 'to'. The final header collection
Expand Down
82 changes: 69 additions & 13 deletions transport/http/outbound.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,10 +457,26 @@ func (o *Outbound) createRequest(treq *transport.Request) (*http.Request, error)
// header is given along a HTTP/1 request.
// see: https://cs.opensource.google/go/x/net/+/c6fcb2db:http/httpguts/httplex.go;l=203
headers := applicationHeaders.deleteHTTP2PseudoHeadersIfNeeded(treq.Headers)
hreq.Header = applicationHeaders.ToHTTPHeaders(headers, nil)
if o.useHTTP2 {
// HTTP/2 lowercases header names on the wire, so the cheaper
// direct-map writes are safe and equivalent.
hreq.Header = applicationHeaders.ToHTTPHeadersPreserveCase(headers, nil)
} else {
hreq.Header = applicationHeaders.ToHTTPHeaders(headers, nil)
}
return hreq, nil
}

// directHTTPHeadersCarrier is an opentracing.TextMapWriter that writes into
// an http.Header without canonicalizing keys, avoiding the per-Set allocation
// of textproto.CanonicalMIMEHeaderKey. Used only on the HTTP/2 path, where
// the wire format mandates lowercase header names regardless of map casing.
type directHTTPHeadersCarrier http.Header

func (h directHTTPHeadersCarrier) Set(key, val string) {
setHeaderDirect(http.Header(h), key, val)
}

func (o *Outbound) withOpentracingSpan(ctx context.Context, req *http.Request, treq *transport.Request, start time.Time) (context.Context, *http.Request, opentracing.Span, error) {
// Apply HTTP Context headers for tracing and baggage carried by tracing.
tracer := o.tracer
Expand Down Expand Up @@ -488,54 +504,94 @@ func (o *Outbound) withOpentracingSpan(ctx context.Context, req *http.Request, t
ext.HTTPUrl.Set(span, req.URL.String())
ctx = opentracing.ContextWithSpan(ctx, span)

var carrier opentracing.TextMapWriter
if o.useHTTP2 {
// HTTP/2 lowercases header names on the wire, so we can bypass
// textproto.CanonicalMIMEHeaderKey via the direct carrier.
carrier = directHTTPHeadersCarrier(req.Header)
} else {
carrier = opentracing.HTTPHeadersCarrier(req.Header)
}
err := tracer.Inject(
span.Context(),
opentracing.HTTPHeaders,
opentracing.HTTPHeadersCarrier(req.Header),
carrier,
)

return ctx, req, span, err
}

func (o *Outbound) withCoreHeaders(req *http.Request, treq *transport.Request, ttl time.Duration) *http.Request {
// On HTTP/2 we bypass textproto.CanonicalMIMEHeaderKey because the wire
// format mandates lowercase header names regardless of map casing. On
// HTTP/1.1 we keep the historical canonicalization behavior for
// compatibility with peers that perform case-sensitive header matching.
setHeader := req.Header.Set
addHeader := req.Header.Add
if o.useHTTP2 {
setHeader = func(k, v string) { setHeaderDirect(req.Header, k, v) }
addHeader = func(k, v string) { addHeaderDirect(req.Header, k, v) }
}

// Add default headers to all requests.
for k, vs := range o.headers {
for _, v := range vs {
req.Header.Add(k, v)
addHeader(k, v)
}
}

req.Header.Set(CallerHeader, treq.Caller)
req.Header.Set(ServiceHeader, treq.Service)
req.Header.Set(ProcedureHeader, treq.Procedure)
setHeader(CallerHeader, treq.Caller)
setHeader(ServiceHeader, treq.Service)
setHeader(ProcedureHeader, treq.Procedure)
if ttl != 0 {
req.Header.Set(TTLMSHeader, fmt.Sprintf("%d", ttl/time.Millisecond))
setHeader(TTLMSHeader, strconv.FormatInt(int64(ttl/time.Millisecond), 10))
}
if treq.ShardKey != "" {
req.Header.Set(ShardKeyHeader, treq.ShardKey)
setHeader(ShardKeyHeader, treq.ShardKey)
}
if treq.RoutingKey != "" {
req.Header.Set(RoutingKeyHeader, treq.RoutingKey)
setHeader(RoutingKeyHeader, treq.RoutingKey)
}
if treq.RoutingDelegate != "" {
req.Header.Set(RoutingDelegateHeader, treq.RoutingDelegate)
setHeader(RoutingDelegateHeader, treq.RoutingDelegate)
}
if treq.CallerProcedure != "" {
req.Header.Set(CallerProcedureHeader, treq.CallerProcedure)
setHeader(CallerProcedureHeader, treq.CallerProcedure)
}

encoding := string(treq.Encoding)
if encoding != "" {
req.Header.Set(EncodingHeader, encoding)
setHeader(EncodingHeader, encoding)
}

if o.bothResponseError {
req.Header.Set(AcceptsBothResponseErrorHeader, AcceptTrue)
setHeader(AcceptsBothResponseErrorHeader, AcceptTrue)
}

return req
}

// addHeaderDirect appends value to the existing values for key on headers.
//
// Unlike http.Header.Add, this intentionally bypasses
// textproto.CanonicalMIMEHeaderKey, avoiding its per-call allocation in hot
// paths. Callers MUST pass key in the exact casing they want stored in the
// map. This is only used on the HTTP/2 path, where the wire format mandates
// lowercase header names regardless of the map casing.
func addHeaderDirect(headers http.Header, key, value string) {
headers[key] = append(headers[key], value)
}

// setHeaderDirect overwrites the values for key on headers with a single
// value.
//
// Unlike http.Header.Set, this intentionally bypasses
// textproto.CanonicalMIMEHeaderKey to avoid its per-call allocation in hot
// paths. See addHeaderDirect for the casing contract.
func setHeaderDirect(headers http.Header, key, value string) {
headers[key] = []string{value}
}

func getYARPCErrorFromResponse(tres *transport.Response, response *http.Response, bothResponseError bool) (*transport.Response, error) {
var contents string
var details []byte
Expand Down
155 changes: 155 additions & 0 deletions transport/http/outbound_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// Copyright (c) 2026 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

package http

import (
"fmt"
"net/http"
"testing"
"time"

"go.uber.org/yarpc/api/transport"
)

// BenchmarkOutboundBuildRequest measures the end-to-end CPU and allocation
// cost of constructing an outbound *http.Request — the per-call hot path —
// comparing the HTTP/1.1 path (which canonicalizes header names via
// textproto.CanonicalMIMEHeaderKey) against the HTTP/2 fast path (which
// writes directly into the http.Header map).
//
// Three header profiles are exercised:
//
// - many headers, all lowercase (best case for canonicalization)
// - many headers, mixed case (worst case — every key allocates)
// - few headers, mixed case (typical YARPC workload)
//
// "many" = 24 (a realistic upper bound); "few" = 4.
func BenchmarkOutboundBuildRequest(b *testing.B) {
const benchTTL = 500 * time.Millisecond

for _, tc := range benchScenarios() {
b.Run(tc.name, func(b *testing.B) {
b.Run("http1_canonical", func(b *testing.B) {
benchBuildRequest(b, benchOutbound(false), tc.headers, benchTTL)
})
b.Run("http2_direct", func(b *testing.B) {
benchBuildRequest(b, benchOutbound(true), tc.headers, benchTTL)
})
})
}
}

func benchBuildRequest(b *testing.B, o *Outbound, headers transport.Headers, ttl time.Duration) {
treq := benchTransportRequest(headers)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
hreq, err := o.createRequest(treq)
if err != nil {
b.Fatal(err)
}
o.withCoreHeaders(hreq, treq, ttl)
}
}

// ---------------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------------

type headerBenchScenario struct {
name string
headers transport.Headers
}

func benchScenarios() []headerBenchScenario {
return []headerBenchScenario{
{
name: "many_headers_all_lowercase",
headers: makeBenchHeaders(24, lowercaseOnly),
},
{
name: "many_headers_mixed_case",
headers: makeBenchHeaders(24, mixedCase),
},
{
name: "few_headers_mixed_case",
headers: makeBenchHeaders(4, mixedCase),
},
}
}

type caseStyle int

const (
lowercaseOnly caseStyle = iota
mixedCase
)

func makeBenchHeaders(n int, style caseStyle) transport.Headers {
h := transport.NewHeadersWithCapacity(n)
for i := 0; i < n; i++ {
var key string
switch style {
case lowercaseOnly:
key = fmt.Sprintf("x-custom-header-%03d", i)
case mixedCase:
switch i % 3 {
case 0:
key = fmt.Sprintf("X-Custom-Header-%03d", i)
case 1:
key = fmt.Sprintf("x-custom-HeaDer-%03d", i)
default:
key = fmt.Sprintf("x-custom-header-%03d", i)
}
}
// Vary value length so the compiler can't constant-fold.
h = h.With(key, fmt.Sprintf("value-%d-padding-abcdefgh", i))
}
return h
}

// benchOutbound returns an Outbound with two static extra headers, matching a
// realistic config (e.g. addHeaders in YAML).
func benchOutbound(useHTTP2 bool) *Outbound {
return &Outbound{
urlTemplate: defaultURLTemplate,
bothResponseError: true,
useHTTP2: useHTTP2,
headers: http.Header{
"X-Static-Token": []string{"tok_abc123"},
"X-Request-Id": []string{"rid-00000000"},
},
}
}

func benchTransportRequest(headers transport.Headers) *transport.Request {
return &transport.Request{
Caller: "bench-caller",
Service: "bench-service",
Encoding: "raw",
Procedure: "BenchProcedure",
ShardKey: "shard-42",
RoutingKey: "routing-key",
RoutingDelegate: "delegate-svc",
CallerProcedure: "CallerProc",
Headers: headers,
}
}
Loading