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
43 changes: 40 additions & 3 deletions pkg/uhttp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,17 +104,48 @@ func NewClient(ctx context.Context, options ...Option) (*http.Client, error) {
}

type icache interface {
Get(req *http.Request) (*http.Response, error)
Set(req *http.Request, value *http.Response) error
Get(req *http.Request, opts ...CacheOption) (*http.Response, error)
Set(req *http.Request, value *http.Response, opts ...CacheOption) error
Clear(ctx context.Context) error
Stats(ctx context.Context) CacheStats
}

type cacheKeyConfig struct {
headers []string
}

// CacheOption configures how CreateCacheKey computes its key, beyond the
// default set of headers (Accept, Content-Type, Cookie, Range). Kept as an
// interface so future dimensions (TTL, query-param keying, etc.) can be
// added without changing CreateCacheKey's or icache's signatures again.
type CacheOption interface {
applyCache(*cacheKeyConfig)
}

type cacheKeyHeadersOption []string

func (o cacheKeyHeadersOption) applyCache(c *cacheKeyConfig) {
c.headers = append(c.headers, o...)
}

// WithCacheKeyHeaders returns a CacheOption that folds the named headers
// into the HTTP response cache key, on top of the default set (Accept,
// Content-Type, Cookie, Range). The value folded in for each is read from
// req.Header at CreateCacheKey time, same as the default set.
func WithCacheKeyHeaders(headers ...string) CacheOption {
return cacheKeyHeadersOption(headers)
}

// CreateCacheKey generates a cache key based on the request URL, query parameters, and headers.
func CreateCacheKey(req *http.Request) (string, error) {
func CreateCacheKey(req *http.Request, opts ...CacheOption) (string, error) {
if req == nil {
return "", fmt.Errorf("request is nil")
}
var cfg cacheKeyConfig
for _, o := range opts {
o.applyCache(&cfg)
}

var sortedParams []string
// Normalize the URL path
path := strings.ToLower(req.URL.Path)
Expand All @@ -137,6 +168,12 @@ func CreateCacheKey(req *http.Request) (string, error) {
}
}
}
for _, h := range cfg.headers {
key := http.CanonicalHeaderKey(h)
for _, value := range req.Header[key] {
headerParts = append(headerParts, fmt.Sprintf("%s=%s", key, value))
}
}
Comment on lines +171 to +176

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: opted-in names aren't deduped against the default set or each other, so WithCacheKeyHeaders("Accept") appends Accept=<v> a second time. The key stays deterministic, but it becomes a bag rather than a set: a request with Accept: x opted in produces the same headerParts as a request with two Accept: x values that opted nothing in. Skipping names already in the default set (and deduping cfg.headers) removes that overlap. (confidence: medium)


sort.Strings(headerParts)
headersString := strings.Join(headerParts, "&")
Expand Down
120 changes: 120 additions & 0 deletions pkg/uhttp/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package uhttp

import (
"context"
"net/http"
"testing"

"github.com/stretchr/testify/require"
)

func newCacheKeyRequest(t *testing.T, headerKey, headerValue string) *http.Request {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com/widgets?id=1", nil)
require.NoError(t, err)
if headerKey != "" {
req.Header.Set(headerKey, headerValue)
}
return req
}

func TestCreateCacheKey_NilRequest(t *testing.T) {
_, err := CreateCacheKey(nil)
require.Error(t, err)
}

func TestCreateCacheKey_IdenticalRequestsMatch(t *testing.T) {
req1 := newCacheKeyRequest(t, "Accept", "application/json")
req2 := newCacheKeyRequest(t, "Accept", "application/json")

key1, err := CreateCacheKey(req1)
require.NoError(t, err)
key2, err := CreateCacheKey(req2)
require.NoError(t, err)
require.Equal(t, key1, key2)
}

// TestCreateCacheKey_HeadersOutsideDefaultSetAreIgnoredByDefault documents
// current, intentional behavior: only the default set affects the key
// unless a caller opts in via extraCacheKeyHeaders. Folding in every header
// unconditionally would key the cache on values that have nothing to do
// with the response (transport-injected headers, tracing IDs, etc.) and
// silently tank the hit rate for every caller who never asked for that.
func TestCreateCacheKey_HeadersOutsideDefaultSetAreIgnoredByDefault(t *testing.T) {
headers := []string{"Authorization", "X-Api-Version", "X-Tenant-Id", "User-Agent"}
for _, header := range headers {
t.Run(header, func(t *testing.T) {
reqA := newCacheKeyRequest(t, header, "value-a")
reqB := newCacheKeyRequest(t, header, "value-b")

keyA, err := CreateCacheKey(reqA)
require.NoError(t, err)
keyB, err := CreateCacheKey(reqB)
require.NoError(t, err)
require.Equal(t, keyA, keyB, "%s is not in the default set and must not affect the key", header)
})
}
}

func TestCreateCacheKey_DefaultHeadersStillChangeKey(t *testing.T) {
headers := []string{"Accept", "Content-Type", "Cookie", "Range"}
for _, header := range headers {
t.Run(header, func(t *testing.T) {
reqA := newCacheKeyRequest(t, header, "value-a")
reqB := newCacheKeyRequest(t, header, "value-b")

keyA, err := CreateCacheKey(reqA)
require.NoError(t, err)
keyB, err := CreateCacheKey(reqB)
require.NoError(t, err)
require.NotEqual(t, keyA, keyB)
})
}
}

// TestCreateCacheKey_WithCacheKeyHeadersOptsInAdditionalHeaders is the
// regression test for CE-1056: a caller that knows a header varies the
// response (e.g. Authorization scoping the result set) can now opt that
// header into the key instead of two requests silently colliding. The value
// folded into the key is read from req.Header, same as the default set.
func TestCreateCacheKey_WithCacheKeyHeadersOptsInAdditionalHeaders(t *testing.T) {
reqA := newCacheKeyRequest(t, "Authorization", "value-a")
reqB := newCacheKeyRequest(t, "Authorization", "value-b")

keyA, err := CreateCacheKey(reqA, WithCacheKeyHeaders("Authorization"))
require.NoError(t, err)
keyB, err := CreateCacheKey(reqB, WithCacheKeyHeaders("Authorization"))
require.NoError(t, err)
require.NotEqual(t, keyA, keyB)
}

// TestCreateCacheKey_WithCacheKeyHeadersOnlyAffectsNamedHeaders confirms
// opting a header in doesn't widen the key to every header on the request --
// a header present on req.Header but absent from the CacheOption still falls
// back to the default-set rule.
func TestCreateCacheKey_WithCacheKeyHeadersOnlyAffectsNamedHeaders(t *testing.T) {
reqA := newCacheKeyRequest(t, "X-Tenant-Id", "tenant-a")
reqA.Header.Set("Authorization", "same-token")
reqB := newCacheKeyRequest(t, "X-Tenant-Id", "tenant-b")
reqB.Header.Set("Authorization", "same-token")

extra := WithCacheKeyHeaders("Authorization")
keyA, err := CreateCacheKey(reqA, extra)
require.NoError(t, err)
keyB, err := CreateCacheKey(reqB, extra)
require.NoError(t, err)
require.Equal(t, keyA, keyB, "X-Tenant-Id was never opted in, so it must not affect the key")
}

// TestCreateCacheKey_WithCacheKeyHeadersCanonicalizesNames confirms header
// names passed to WithCacheKeyHeaders are treated the same regardless of
// casing.
func TestCreateCacheKey_WithCacheKeyHeadersCanonicalizesNames(t *testing.T) {
req := newCacheKeyRequest(t, "Authorization", "value-a")

keyA, err := CreateCacheKey(req, WithCacheKeyHeaders("authorization"))
require.NoError(t, err)
keyB, err := CreateCacheKey(req, WithCacheKeyHeaders("Authorization"))
require.NoError(t, err)
require.Equal(t, keyA, keyB)
}
8 changes: 4 additions & 4 deletions pkg/uhttp/dbcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,12 @@ func (d *DBCache) removeDB(ctx context.Context) error {
}

// Get returns cached response (if exists).
func (d *DBCache) Get(req *http.Request) (*http.Response, error) {
func (d *DBCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) {
var (
isFound = false
resp *http.Response
)
key, err := CreateCacheKey(req)
key, err := CreateCacheKey(req, opts...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -250,8 +250,8 @@ func (d *DBCache) pick(ctx context.Context, key string) ([]byte, error) {
}

// Set stores and save response in the db.
func (d *DBCache) Set(req *http.Request, value *http.Response) error {
key, err := CreateCacheKey(req)
func (d *DBCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error {
key, err := CreateCacheKey(req, opts...)
if err != nil {
return err
}
Expand Down
12 changes: 6 additions & 6 deletions pkg/uhttp/gocache.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@ func NewNoopCache(ctx context.Context) *NoopCache {
return &NoopCache{}
}

func (g *NoopCache) Get(req *http.Request) (*http.Response, error) {
func (g *NoopCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) {
// This isn't threadsafe but who cares? It's the noop cache.
g.counter++
return nil, nil
}

func (n *NoopCache) Set(req *http.Request, value *http.Response) error {
func (n *NoopCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error {
return nil
}

Expand Down Expand Up @@ -219,12 +219,12 @@ func (g *GoCache) Stats(ctx context.Context) CacheStats {
}
}

func (g *GoCache) Get(req *http.Request) (*http.Response, error) {
func (g *GoCache) Get(req *http.Request, opts ...CacheOption) (*http.Response, error) {
if g.rootLibrary == nil {
return nil, nil
}

key, err := CreateCacheKey(req)
key, err := CreateCacheKey(req, opts...)
if err != nil {
return nil, err
}
Expand All @@ -247,12 +247,12 @@ func (g *GoCache) Get(req *http.Request) (*http.Response, error) {
return resp, nil
}

func (g *GoCache) Set(req *http.Request, value *http.Response) error {
func (g *GoCache) Set(req *http.Request, value *http.Response, opts ...CacheOption) error {
if g.rootLibrary == nil {
return nil
}

key, err := CreateCacheKey(req)
key, err := CreateCacheKey(req, opts...)
if err != nil {
return err
}
Expand Down
20 changes: 18 additions & 2 deletions pkg/uhttp/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,22 @@ func (c *BaseHttpClient) recordCacheMiss(ctx context.Context) {
}

func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Response, error) {
return c.do(req, nil, options...)
}

// DoWithCacheKeyHeaders is a sibling to Do that additionally folds the named
// headers into the HTTP response cache key for this call, on top of the
// default set (Accept, Content-Type, Cookie, Range). Use this when a request
// varies by a header the cache wouldn't otherwise key on -- e.g. a per-call
// Authorization token -- so requests that only differ in that header don't
// collide. Each header's value is read from req.Header, same as the default

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion (confidence: medium): Reverting the clone in do is right for existing Do callers, but it changes the failure mode of this opt-in path rather than removing it. http.Client.send only shallow-forks the request when Timeout > 0 (net/http/client.go:254), so Header stays the caller's map, and userAgentTripper.RoundTrip (pkg/uhttp/transport.go:137-142) writes User-Agent into it mid-flight. DoWithCacheKeyHeaders(req, []string{"User-Agent"}) then computes the Get key before injection and the Set key after, so every store lands under a key no later lookup can reach — a silently write-only cache that keeps growing. Worth stating in this doc comment that opted-in headers must be set by the caller before the call (not transport-injected), ideally with a test pinning that documented limitation now that TestWrapper_DoWithCacheKeyHeaders_CachesDespiteRoundTripHeaderInjection is gone.

// set. Do's own signature is untouched -- zero compatibility risk for
// existing callers.
func (c *BaseHttpClient) DoWithCacheKeyHeaders(req *http.Request, cacheKeyHeaders []string, options ...DoOption) (*http.Response, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: because values are read from req.Header, naming a header that a RoundTripper injects (the common oauth2.Transport-style Authorization, or User-Agent) is a silent no-op — and the new clone guarantees that value can never reach req.Header. A connector that calls DoWithCacheKeyHeaders(req, []string{"Authorization"}) under that setup gets exactly the cross-token collision it was trying to prevent, with no error and no log. Worth stating in the doc comment that the header must already be set on req (e.g. via WithBearerToken), and/or logging at debug when a named header is absent from req.Header. (confidence: medium)

return c.do(req, []CacheOption{WithCacheKeyHeaders(cacheKeyHeaders...)}, options...)
}

func (c *BaseHttpClient) do(req *http.Request, cacheOpts []CacheOption, options ...DoOption) (*http.Response, error) {
var (
err error
resp *http.Response
Expand All @@ -448,7 +464,7 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo
}

if req.Method == http.MethodGet && req.Header.Get("Cache-Control") != "no-cache" {
resp, err = c.baseHttpCache.Get(req)
resp, err = c.baseHttpCache.Get(req, cacheOpts...)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -520,7 +536,7 @@ func (c *BaseHttpClient) Do(req *http.Request, options ...DoOption) (*http.Respo
}

if req.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
cacheErr := c.baseHttpCache.Set(req, resp)
cacheErr := c.baseHttpCache.Set(req, resp, cacheOpts...)
if cacheErr != nil {
l.Warn("error setting cache", zap.String("url", req.URL.String()), zap.Error(cacheErr))
}
Expand Down
43 changes: 43 additions & 0 deletions pkg/uhttp/wrapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -727,3 +729,44 @@ func TestWrapper_RedactSensitiveHeaders(t *testing.T) {
"Custom-Api-Key": {"REDACTED"},
}, redactedHeaders)
}

func newCountingServer(hits *int32) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(hits, 1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
}

// TestWrapper_DoWithCacheKeyHeaders_DistinguishesRequests is the regression
// test for CE-1056: without opting Authorization into the cache key via
// DoWithCacheKeyHeaders, two GET requests that only differ in that header
// would collide. With it named, they don't -- both reach the server -- while
// two calls sharing the same Authorization value still hit the cache on the
// second call.
func TestWrapper_DoWithCacheKeyHeaders_DistinguishesRequests(t *testing.T) {
var hits int32
ts := newCountingServer(&hits)
defer ts.Close()

client, err := NewBaseHttpClientWithContext(ctx, http.DefaultClient)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: NewBaseHttpClientWithContext builds its cache via NewHttpCache(ctx, nil), which falls through to NewCacheConfigFromEnv(). Both new tests assert real cache behavior (hits staying flat on the repeat call), so they fail in any environment that sets BATON_DISABLE_HTTP_CACHE=true, BATON_HTTP_CACHE_BACKEND=noop, or BATON_HTTP_CACHE_TTL=0. A t.Setenv("BATON_DISABLE_HTTP_CACHE", "false") plus t.Setenv("BATON_HTTP_CACHE_BACKEND", "memory") pins the assumption instead of inheriting it. (confidence: high)

require.NoError(t, err)

u, err := url.Parse(ts.URL)
require.NoError(t, err)

doWithAuth := func(token string) {
req, err := client.NewRequest(ctx, http.MethodGet, u, WithBearerToken(token))
require.NoError(t, err)
resp, err := client.DoWithCacheKeyHeaders(req, []string{"Authorization"})
require.NoError(t, err)
resp.Body.Close()
}

doWithAuth("token-a")
doWithAuth("token-b")
require.EqualValues(t, 2, atomic.LoadInt32(&hits), "different Authorization values must not collide in the cache")

doWithAuth("token-a")
require.EqualValues(t, 2, atomic.LoadInt32(&hits), "repeating the same Authorization value should be served from cache")
}
Loading