Skip to content

fix(uhttp): opt-in cache-key headers via CacheOption + WithCacheKeyHeaders - #1093

Open
Bencheng21 wants to merge 1 commit into
mainfrom
ben.su/CE-1056/cache-option-wrapper
Open

fix(uhttp): opt-in cache-key headers via CacheOption + WithCacheKeyHeaders#1093
Bencheng21 wants to merge 1 commit into
mainfrom
ben.su/CE-1056/cache-option-wrapper

Conversation

@Bencheng21

Copy link
Copy Markdown
Contributor

Summary

  • CreateCacheKey only folded Accept, Content-Type, Cookie, and Range into the HTTP response cache key. Any other request header was silently ignored, so two GET requests differing only in an unlisted header (e.g. Authorization, a custom API-version or tenant header) collided on the same cache entry and one request could be served the other's cached response.
  • CreateCacheKey(req *http.Request, opts ...CacheOption) -- a trailing variadic, so every existing zero-arg CreateCacheKey(req) / cache.Get(req) / cache.Set(req, resp) call keeps compiling unchanged. CacheOption is an interface rather than a concrete parameter, leaving room for future dimensions (TTL, query-param keying, etc.) without touching these signatures again.
  • WithCacheKeyHeaders(...) is a WrapperOption, configured once at client construction and applied to every request that client makes -- not a per-call option:
    cli := uhttp.NewBaseHttpClient(httpClient, uhttp.WithCacheKeyHeaders("Authorization"))
    BaseHttpClient stores the resulting CacheOption(s) and forwards them to every Get/Set call itself makes. Do's signature is completely untouched -- no sibling method, no per-call option, every existing call site keeps working exactly as before.
  • The value folded into the key is read from req.Header at key-computation time, not supplied by the caller -- so the key can never describe a token that isn't the one actually sent on that request.
  • Because the opted-in header's value is read from req.Header (same source as the default set), the request-clone fix in BaseHttpClient.Do matters here: http.Client.Do forks the *http.Request struct on every call once Timeout > 0 (which uhttp.NewClient always sets), but that fork is shallow, so Header stays the same map the caller passed in -- a RoundTripper mutating it between the cache's Get and Set calls (userAgentTripper does this for User-Agent) would otherwise make the Set key diverge from every future Get key for an opted-in header. Fixed by round-tripping on req.Clone(req.Context()) instead of req.

Alternatives considered

Two other API shapes for the same fix were explored and are open for comparison:

This PR's design keeps Do and every existing call site fully untouched, matches values to what's actually sent, and leaves room to grow via CacheOption without further signature churn -- at the cost of a per-client rather than per-call configuration point.

Test plan

  • pkg/uhttp/client_test.go: default set unaffected by headers outside it; a CacheOption opts a header in (case-insensitively) without widening the key to everything else. All pre-existing zero-arg CreateCacheKey/Get/Set calls compile and pass unmodified.
  • pkg/uhttp/wrapper_test.go: TestWrapper_WithCacheKeyHeaders_DistinguishesRequests confirms different Authorization values no longer collide while the same value still hits cache; TestWrapper_Do_CachesDespiteRoundTripHeaderInjection confirms the request-clone fix is load-bearing -- fails without it, passes with it.
  • go test ./pkg/uhttp/... and go test ./... (whole module)
  • golangci-lint run ./pkg/uhttp/...
  • go build ./...

…aders

CreateCacheKey only folded Accept, Content-Type, Cookie, and Range into
the HTTP response cache key. Any other request header was silently
ignored, so two GET requests differing only in an unlisted header (e.g.
Authorization) collided on the same cache entry and one could be served
the other's cached response.

CreateCacheKey now takes a trailing ...CacheOption -- additive, so every
existing zero-arg CreateCacheKey(req)/cache.Get(req)/cache.Set(req, resp)
call keeps compiling. CacheOption is an interface (not a concrete param)
so future dimensions (TTL, query-param keying, etc.) don't require
touching these signatures again.

WithCacheKeyHeaders(...) is a WrapperOption, set once at client
construction:

  cli := uhttp.NewBaseHttpClient(httpClient, uhttp.WithCacheKeyHeaders("Authorization"))

BaseHttpClient stores the resulting CacheOption(s) and forwards them to
every Get/Set call itself makes, so Do's signature is completely
untouched -- no sibling method, no per-call option needed.

Values are read from req.Header at key-computation time rather than
supplied by the caller, so the key can never describe a token other than
the one actually sent. That also means the round-trip-clone fix in
BaseHttpClient.Do (round-tripping on req.Clone(req.Context()) instead of
req) matters again: http.Client.Do forks the *http.Request on every call
once Timeout > 0 (which uhttp.NewClient always sets), but that fork is
shallow, so Header stays the same map the caller passed in -- a
RoundTripper mutating it between the Get and Set calls (transport.go's
userAgentTripper does this for User-Agent) would otherwise make the Set
key diverge from every future Get key for an opted-in header.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
@linear-code

linear-code Bot commented Aug 13, 2026

Copy link
Copy Markdown

CE-1056

Comment thread pkg/uhttp/wrapper.go
// key (via WithCacheKeyHeaders) between the Get above and the Set
// below, and CreateCacheKey(req) would hash a different value for
// each -- a store that no future lookup can ever match.
resp, err = c.HttpClient.Do(req.Clone(req.Context())) // #nosec G704 -- this HTTP wrapper intentionally supports arbitrary connector-defined endpoints.

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 (medium-high confidence): this clone also changes default-path cache behavior, not just the opt-in path, and the change should be called out. http.Client.send calls req.AddCookie on the caller's request before its internal fork (net/http/client.go), so for any wrapped client with a Jar, jar cookies previously landed on req.Header between Get and Set — and Cookie is in the default key set, so those clients effectively had a write-only cache (Set key ≠ every future Get key). After this change both keys omit jar cookies, so the cache starts hitting and two requests carrying different jar sessions now share one entry. That is the same collision class this PR is fixing for Authorization, arriving silently by default for jar-based connectors. Consider either folding c.HttpClient.Jar cookies into the key, or documenting this in the PR and the CreateCacheKey doc comment, plus a test asserting the caller's req.Header is no longer mutated by the transport.

Comment thread pkg/uhttp/wrapper.go
// don't collide in the cache. The value folded in is always read from
// req.Header at request time, so the key can never describe a value other
// than the one actually sent.
func WithCacheKeyHeaders(headers ...string) WrapperOption {

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 (high confidence in the mechanism): the key is computed from req.Header before the round trip, so any header attached below Do — an oauth2.Transport/token RoundTripper, URL.User basic auth, or the cookie jar — is invisible to it. WithCacheKeyHeaders("Authorization") on a client whose token is injected by a transport therefore contributes nothing to the key and the requests still collide, with no error and no log line, while the caller believes they opted in (the clone above now guarantees that value can never be observed). Worth documenting explicitly here, and consider a debug/warn in Do when a named header is absent from req.Header.

Comment thread pkg/uhttp/client.go
// 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 {

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: CacheOption is exported and now appears in exported signatures (CreateCacheKey, GoCache.Get/Set, DBCache.Get/Set), but its only method is unexported and the only implementation (cacheKeyHeadersOption) is unexported — WithCacheKeyHeaders returns a WrapperOption, not a CacheOption. No package outside uhttp can construct one, so those exported variadic parameters are unusable downstream. Consider exporting a constructor (e.g. func CacheKeyHeaders(headers ...string) CacheOption) and having WithCacheKeyHeaders wrap it.

Comment thread pkg/uhttp/client.go
Comment on lines +163 to +168
for _, h := range cfg.headers {
key := http.CanonicalHeaderKey(h)
for _, value := range req.Header[key] {
headerParts = append(headerParts, fmt.Sprintf("%s=%s", key, value))
}
}

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 (minor): names already covered by the default set, or repeated across options, are folded in twice — WithCacheKeyHeaders("Accept") yields Accept=...&Accept=... in cacheString. Keys stay internally consistent so nothing breaks, but it makes the key depend on redundant configuration. Dedupe against the default set and against already-appended names before appending.

Comment thread pkg/uhttp/gocache.go
}

func (g *GoCache) Get(req *http.Request) (*http.Response, error) {
func (g *GoCache) Get(req *http.Request, opts ...CacheOption) (*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: adding a variadic parameter to exported methods on exported types (GoCache.Get/Set, DBCache.Get/Set) keeps ordinary call sites compiling, but it does break downstream code that assigns these methods to a func(*http.Request) (*http.Response, error) value or that satisfies a locally-declared cache interface with the old signature. Per the repo's compatibility criteria this is worth a note in the PR description and a 0.x minor bump in pkg/sdk/version.go (currently unchanged at v0.24.1) rather than shipping silently as a patch.

@github-actions

Copy link
Copy Markdown
Contributor

General PR Review: fix(uhttp): opt-in cache-key headers via CacheOption + WithCacheKeyHeaders

Blocking Issues: 0 | Suggestions: 5 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base f7333f66e01d.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. The core change is sound: the option is additive and variadic so existing call sites compile unchanged, the key is SHA-256 hashed so opting Authorization in does not persist a token in plaintext (including the DB backend), and cacheOptions is written only at construction and read-only thereafter. The req.Clone fix is correctly motivated — net/http's send forks the request shallowly when Timeout > 0, so the Header map really was shared with in-place mutating RoundTrippers like userAgentTripper — and the two new wrapper tests are load-bearing rather than decorative. No blocking issues; the suggestions below are about the default-path side effects of the clone and about the silent-failure mode when an opted-in header is attached below Do.

Risk triage (per docs/BUG_CATCHING.md §2): silence — yes, a wrong cache key produces a well-formed wrong response rather than an error; durability — bounded, cache lifetime only (in-memory TTL or the per-process DB cache), no c1z/proto/sync-token surface; uncontrolled dimensions — depends on the caller's http.Client shape (jar, custom RoundTripper), not on schedule or SDK version pairing; consumer distance — downstream connectors. Consequence sits at rung 2 (re-sync). Verdict: MEDIUM. The absence class that matters here is the cache-collision/default-path change, and the diff does contain the right instrument for the opt-in path (TestWrapper_Do_CachesDespiteRoundTripHeaderInjection fails without the clone); what it does not contain is coverage for the default path the clone also changed.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/wrapper.go:494 — the clone also changes default-path cache behavior for any wrapped client with a cookie Jar: Client.send calls req.AddCookie on the caller's request before its internal fork, so jar clients previously had an effectively write-only cache (Cookie is in the default key set), and now both keys omit jar cookies — the cache starts hitting and different jar sessions share one entry.
  • pkg/uhttp/wrapper.go:103 — headers attached below Do (token RoundTripper, URL.User basic auth, jar) are invisible to CreateCacheKey, so WithCacheKeyHeaders("Authorization") silently contributes nothing when the token is transport-injected; worth documenting, plus a debug/warn when a named header is absent from req.Header.
  • pkg/uhttp/client.go:121CacheOption appears in exported signatures but has an unexported method and no exported constructor, so no package outside uhttp can build one; export e.g. CacheKeyHeaders(...) CacheOption and have WithCacheKeyHeaders wrap it.
  • pkg/uhttp/gocache.go:222 — the variadic additions to GoCache.Get/Set and DBCache.Get/Set break method values and locally-declared cache interfaces downstream; per the repo criteria that warrants a PR note and a 0.x minor bump (pkg/sdk/version.go is unchanged at v0.24.1).
  • pkg/uhttp/client.go:163-168 — names already in the default set, or repeated across options, are folded into the key twice; dedupe before appending.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/uhttp/wrapper.go`:
- Around line 494: The switch to `c.HttpClient.Do(req.Clone(req.Context()))` also changes
  default-path caching, not only the opt-in path. `net/http`'s `(*Client).send` calls
  `req.AddCookie(...)` on the caller's request before its own shallow fork, so for any
  wrapped `*http.Client` that has a `Jar`, jar cookies used to be written onto
  `req.Header` between the cache `Get` and the cache `Set`. Because `Cookie` is in
  `CreateCacheKey`'s default header set, those clients had a write-only cache (the Set
  key never matched a later Get key). With the clone, both keys omit jar cookies: the
  cache now hits, and two requests carrying different jar sessions share one entry.
  Either fold `c.HttpClient.Jar.Cookies(req.URL)` into the key alongside the request's
  own `Cookie` header, or explicitly document this default-behavior change in the PR
  description and in `CreateCacheKey`'s doc comment. Add a test asserting that after
  `Do`, the caller's `req.Header` is not mutated by the transport, so the default-path
  half of this change is covered too.
- Around line 103 (`WithCacheKeyHeaders`): the cache key is computed from `req.Header`
  before the round trip, so headers attached below `Do` — an `oauth2.Transport` or other
  token-setting RoundTripper, `URL.User` basic auth, or the cookie jar — are never seen.
  `WithCacheKeyHeaders("Authorization")` on such a client contributes nothing to the key
  and the collisions it is meant to prevent persist silently. Document this limitation in
  the doc comment ("the named header must be set on the request itself, e.g. via
  `WithBearerToken`; headers injected by a RoundTripper are not visible here"), and
  consider logging at debug/warn level in `Do` when a configured cache-key header is
  absent from `req.Header`.

In `pkg/uhttp/client.go`:
- Around line 121: `CacheOption` is exported and now appears in the exported signatures
  of `CreateCacheKey`, `GoCache.Get/Set`, and `DBCache.Get/Set`, but its only method
  (`applyCache`) is unexported and the only implementation (`cacheKeyHeadersOption`) is
  unexported; `WithCacheKeyHeaders` returns a `WrapperOption`. No package outside `uhttp`
  can construct a `CacheOption`, making those exported parameters unusable downstream.
  Add an exported constructor such as
  `func CacheKeyHeaders(headers ...string) CacheOption { return cacheKeyHeadersOption(headers) }`
  and have `WithCacheKeyHeaders` build its wrapper option from it.
- Around lines 163-168: header names that are already in the default set
  (Accept, Content-Type, Cookie, Range), or repeated across multiple options, get folded
  into `headerParts` twice, producing entries like `Accept=application/json` duplicated in
  `cacheString`. Keys stay internally consistent so nothing breaks, but the key ends up
  depending on redundant configuration. Track appended `key=value` pairs (or canonical
  header names) in a `map[string]struct{}` and skip duplicates, including names already
  handled by the default-set loop above.

In `pkg/uhttp/gocache.go`:
- Around line 222 (and the same change at `pkg/uhttp/dbcache.go:193` and `:253`): adding a
  variadic `opts ...CacheOption` parameter to exported methods on the exported `GoCache`
  and `DBCache` types keeps ordinary call sites compiling, but breaks downstream code that
  assigns these methods to a `func(*http.Request) (*http.Response, error)` value or that
  relies on these types satisfying a locally-declared interface with the old signature.
  Per the repo's compatibility criteria, note this in the PR description and bump the 0.x
  minor version in `pkg/sdk/version.go` (currently `v0.24.1`, unchanged by this PR) rather
  than shipping the signature change as a patch.

@github-actions github-actions Bot left a comment

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.

No blocking issues found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant