-
Notifications
You must be signed in to change notification settings - Fork 5
fix(uhttp): opt-in cache-key headers via DoWithCacheKeyHeaders #1092
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4daf5c1
ebb7238
613728c
2dd9053
cfeafe9
ac872e5
6f09a54
c8fb778
8632fba
528e4b4
e36ba45
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion (confidence: medium): Reverting the clone in |
||
| // 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: because values are read from |
||
| 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 | ||
|
|
@@ -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 | ||
| } | ||
|
|
@@ -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)) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,7 +8,9 @@ import ( | |
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "net/url" | ||
| "sync/atomic" | ||
| "testing" | ||
| "time" | ||
|
|
||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: |
||
| 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") | ||
| } | ||
There was a problem hiding this comment.
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")appendsAccept=<v>a second time. The key stays deterministic, but it becomes a bag rather than a set: a request withAccept: xopted in produces the sameheaderPartsas a request with twoAccept: xvalues that opted nothing in. Skipping names already in the default set (and dedupingcfg.headers) removes that overlap. (confidence: medium)