Skip to content

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

Merged
Bencheng21 merged 2 commits into
mainfrom
ben.su/CE-1056/cache-option-wrapper
Aug 20, 2026
Merged

fix(uhttp): opt-in cache-key headers via CacheOption + WithCacheKeyHeaders#1093
Bencheng21 merged 2 commits 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 Outdated
// 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

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

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

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base f7333f66e01d.
Review mode: incremental since 7c4a36e9
View review run

Review Summary

The full PR diff was scanned for security and correctness. The new commit addresses three prior findings: CacheKeyHeaders is now an exported constructor that WithCacheKeyHeaders wraps, CreateCacheKey dedupes opted-in names against the default set via seenHeaders, and the req.Clone in Do is reverted so default-path cache behavior for cookie-jar clients matches main. The revert restores the Get/Set cache-key asymmetry, which now only bites callers who opt a transport-injected header into the key; the new doc note covers that case but understates it, so it is filed as a suggestion rather than a blocker. The earlier pkg/sdk/version.go point (variadic Get/Set on exported GoCache/DBCache/NoopCache with no 0.x minor bump, still v0.24.1) is still open and is not re-filed here.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/wrapper.go:549 — the Set key is computed from req after the round trip while the Get key at :477 is computed before it; an opted-in header injected in place by a RoundTripper makes the stored entry unreachable, so the cache silently never hits instead of merely ignoring that header.
  • pkg/uhttp/client.go:193name=value parts joined by & are not an injective encoding now that arbitrary caller-named headers are folded in; values containing & or = could collide across distinct header sets.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In pkg/uhttp/wrapper.go:
- Around lines 477-549: Do computes the cache key from req twice, via baseHttpCache.Get
  before the round trip and baseHttpCache.Set after it. http.Client.send only shallow-forks
  the Request, so Header stays the map owned by the caller and an in-place RoundTripper
  (see userAgentTripper at pkg/uhttp/transport.go:139) can change it between the two. If
  that header is named in WithCacheKeyHeaders, Set stores under a key no Get will produce
  and the cache silently never hits. Compute the key once before the round trip and reuse
  it for Set (or snapshot the opted-in header values). If the current behavior is kept
  deliberately, fix the WithCacheKeyHeaders doc at lines 100-108 -- the header IS reflected
  in the Set key, which makes the cache write-only rather than simply unaware of it -- and
  add a test, since TestWrapper_Do_CachesDespiteRoundTripHeaderInjection was deleted here.

In pkg/uhttp/client.go:
- Around line 193: headerParts entries are name=value joined by an ampersand, which is not
  injective once arbitrary caller-named headers are folded in: a value containing an
  ampersand or an equals sign can yield the same joined string as a different header set,
  giving two distinct requests the same cache key. Escape name and value (e.g.
  url.QueryEscape) here and in the default-set loop above.

Note: this run could not emit the machine-readable review-state marker (the CI shell guard rejects the JSON literal), so the next review will run in full mode against head e103e442bacb5f5cbcd1bf9b00b2a670d8446774 (base f7333f66e01de46f1f1e2e58607263ed78e14c8e).

@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.

@kans

kans commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fable:

#1093 (cache-option-wrapper) — required changes

  1. Drop the unconditional req.Clone in Do (fix(uhttp): opt-in cache-key headers via DoWithCacheKeyHeaders #1092 already reverted this).
    It doesn't help caller-set headers, and for headers injected mid-flight
    (jar cookies — Cookie is in the default key set — or in-place
    RoundTrippers) it turns a dead cache into cross-scope cache hits: the
    CE-1056 bug reintroduced on the default path.

  2. Delete TestWrapper_Do_CachesDespiteRoundTripHeaderInjection — it pins
    the dangerous behavior (cache hit while ignoring an opted-in header).

  3. Export a CacheOption constructor, e.g.
    func CacheKeyHeaders(headers ...string) CacheOption, and have
    WithCacheKeyHeaders wrap it. Today CacheOption appears in exported
    signatures (CreateCacheKey, GoCache/DBCache.Get/Set) but can't be
    constructed outside uhttp; the client_test builds the unexported type
    directly.

  4. Doc WithCacheKeyHeaders: named headers must be set on the request
    before Do; transport-injected headers are not seen. Nice-to-have:
    warn in Do when a configured header is absent from req.Header.

  5. Dedupe opted-in names against the default set and each other
    (WithCacheKeyHeaders("Accept") currently folds values in twice).

  6. Pin the cache env in the new wrapper tests
    (t.Setenv BATON_DISABLE_HTTP_CACHE=false, backend=memory) so they
    don't fail under ambient config.

  7. Rebase onto main (only XML-helper drift in wrapper.go; merges clean).

  8. Minor version bump per repo compat rules (variadic change to exported
    Get/Set signatures breaks method-value assignments downstream).

Keep: option-pattern API, values read from req.Header, client-level
opt-in. Don't close CE-1056 on merge — the fix lands only when the
header-scoped connectors (sendgrid et al) adopt WithCacheKeyHeaders.

- Drop the unconditional req.Clone(...) round-trip in Do. It didn't help
  caller-set headers, and for headers injected mid-flight (jar cookies --
  Cookie is in the default key set -- or in-place RoundTrippers) it turned
  a dead cache into cross-scope cache hits, reintroducing the CE-1056 bug
  on the default path. Delete the test that pinned that behavior
  (TestWrapper_Do_CachesDespiteRoundTripHeaderInjection).
- Export CacheKeyHeaders(...) CacheOption and have WithCacheKeyHeaders wrap
  it, so CacheOption -- which already appears in exported signatures
  (CreateCacheKey, GoCache/DBCache Get/Set) -- can be constructed outside
  uhttp.
- Document on WithCacheKeyHeaders/CacheKeyHeaders that named headers must
  be set on the request before Do; transport-injected headers are not
  seen.
- Dedupe opted-in header names against the default set and each other, so
  WithCacheKeyHeaders("Accept") no longer folds values in twice.

Co-authored-by: c1-squire-dev[bot] <c1-squire-dev[bot]@users.noreply.github.com>
Comment thread pkg/uhttp/wrapper.go

if req.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
cacheErr := c.baseHttpCache.Set(req, resp)
cacheErr := c.baseHttpCache.Set(req, resp, c.cacheOptions...)

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): this Set re-derives the cache key from req after the round trip, while the Get at line 477 derived it before. http.Client.send forks the Request struct shallowly once Timeout > 0, so the Header map stays the caller's — a RoundTripper that sets a header in place (this repo's own userAgentTripper, transport.go:139, does exactly that) mutates the very request Do re-keys on. If such a header is named in WithCacheKeyHeaders, the entry is stored under a key no future Get can produce, so the cache silently never hits — stronger than what the new doc says ("not seen by the cache lookup and will not be reflected in the key"): it is reflected, but only on the write side. Consider computing the key once before the round trip and reusing it for Set (or snapshotting the opted-in header values), and either way tightening the doc wording and adding a test pinning the documented behavior now that TestWrapper_Do_CachesDespiteRoundTripHeaderInjection was removed.

Comment thread pkg/uhttp/client.go
}
seenHeaders[key] = true
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 (low confidence): now that arbitrary caller-named headers are folded in, the "%s=%s" parts joined with & are no longer an injective encoding — a header value containing & or = can produce the same joined string as a different set of headers, i.e. a cache-key collision between two requests that should be distinct. Values here are typically opaque tokens/tenant IDs so this is unlikely in practice, but escaping each name and value (e.g. url.QueryEscape) before joining would make the encoding unambiguous.

@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.

@Bencheng21
Bencheng21 merged commit 57bcd02 into main Aug 20, 2026
12 checks passed
@Bencheng21
Bencheng21 deleted the ben.su/CE-1056/cache-option-wrapper branch August 20, 2026 18:57
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.

3 participants