Skip to content

Restore http cache for subuser calls via cache-key header - #36

Merged
Bencheng21 merged 2 commits into
mainfrom
ben/ce1056
Aug 26, 2026
Merged

Restore http cache for subuser calls via cache-key header#36
Bencheng21 merged 2 commits into
mainfrom
ben/ce1056

Conversation

@Bencheng21

Copy link
Copy Markdown
Contributor

Summary

  • Bumps baton-sdk from v0.24.4 to v0.24.6 to pick up uhttp.WithCacheKeyHeaders.
  • Registers on-behalf-of as a cache-key header on the shared uhttp client so parent- and subuser-scoped responses key separately in the cache.
  • Drops the WithNoCache() workaround from onBehalfOfOpts that was needed before the SDK exposed per-header cache-key control (originally added in CXP-860 Remove http-cache for requests with additional headers  #34).

Test plan

  • go build ./...
  • go test ./...
  • Sync against an account with subusers and confirm parent and subuser data don't collide

🤖 Generated with Claude Code

Comment on lines +408 to 416
// onBehalfOfOpts scopes a request to a subuser when onBehalfOf is set. The
// on-behalf-of header is folded into the cache key via WithCacheKeyHeaders on
// the client, so parent- and subuser-scoped responses stay distinct in the cache.
func onBehalfOfOpts(onBehalfOf OnBehalfOf) []uhttp.RequestOption {
opts := []uhttp.RequestOption{uhttp.WithNoCache()}
if onBehalfOf != "" {
opts = append(opts, uhttp.WithHeader(OnBehalfOfHeaderName, string(onBehalfOf)))
if onBehalfOf == "" {
return nil
}
return opts
return []uhttp.RequestOption{uhttp.WithHeader(OnBehalfOfHeaderName, string(onBehalfOf))}
}

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.

🟠 Bug: Dropping WithNoCache() here also enables caching on GetSpecificTeammate, which scopeBuilder.Grant/Revoke use as the read half of a read-modify-write (helper.go:63scopes.go:81,101). uhttp's cache is per-BaseHttpClient, defaults to in-memory with a 1h TTL, has no write invalidation (Do only Get/Sets on GET), and ClearCaches runs only in Cleanup at end of sync — so in service mode the connector subprocess keeps the entry across provisioning tasks. Grant(B) then Grant(C) on the same teammate reads the pre-grant scopes=[A] from cache and PATCHes [A,C], silently dropping B.

Suggest keeping the request uncached specifically for the provisioning read path — e.g. give GetSpecificTeammate a no-cache variant (or a noCache bool/option param) that Grant/Revoke use, while the sync callers in teammates.go:196,252 keep the cache. The cache-key change itself is correct: NewRequest canonicalizes via req.Header.Set, and CreateCacheKey looks up http.CanonicalHeaderKey("on-behalf-of"), so the keys match.

}

uhtppClient, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient)
uhtppClient, err := uhttp.NewBaseHttpClientWithContext(ctx, httpClient, uhttp.WithCacheKeyHeaders(OnBehalfOfHeaderName))

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: The whole point of this change is that parent- and subuser-scoped responses no longer collide in the cache, but nothing pins that. teammates_test.go uses a fake SendGridClient, so it never exercises the real uhttp client. A test that builds a SendGridClient against an httptest server, calls GetTeammates with "" and then with a subuser, and asserts two distinct upstream hits with distinct bodies would catch a future regression (e.g. someone renaming OnBehalfOfHeaderName without updating the WithCacheKeyHeaders call) that otherwise surfaces as silently wrong sync data.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: Restore http cache for subuser calls via cache-key header

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 4212e0bdb69c.
Review mode: incremental since 6e7728b2
View review run

Review Summary

The new commits address the prior blocking finding: GetSpecificTeammateNoCache now backs the read half of the scope read-modify-write in getTeammateWithFreshOnBehalfOf, and scopes_test.go pins that behavior with a fake that models the cache (cachedReads must stay zero), covering consecutive grants, revoke, and the already-granted short-circuit. The full PR diff was scanned for security and correctness; no blocking issues found (go.mod/go.sum are unchanged in this PR — the SDK is already on v0.24.6 at base, so uhttp.WithCacheKeyHeaders is available). Two residual cache-staleness paths remain and are filed as suggestions, plus the still-open gap that nothing exercises WithCacheKeyHeaders against the real uhttp client.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/client/client.go:158WithNoCache() only skips the cache read; Do still write-throughs every 200 GET (wrapper.go:592-596, not gated on Cache-Control, which is also not part of the cache key), so the provisioning read stores a pre-write snapshot under the key the cached sync read (teammates.go:252) later hits.
  • pkg/connector/helper.go:80 — the 404 rename-recovery retry re-resolves via GetSubusers, which is now cacheable, so the "fresh" on-behalf-of can be the same stale value that just 404'd; teammateBuilder.Delete has the same exposure and swallows the resulting 404 as "already deleted". The "plain, uncached call" comments at helper.go:57-58 and :91-92 are still inaccurate (carried over).
  • pkg/connector/client/client.go:95 — (carried over) no test pins WithCacheKeyHeaders(OnBehalfOfHeaderName); the new scopes_test.go uses a fake SendGridClient, so the parent-vs-subuser cache-key separation this PR relies on is still unexercised. A pkg/connector/client test against an httptest server hitting the same URL with and without the header would cover it. The new tests also only use a parent-scope principal, so the subuser-scoped Grant/Revoke path is untested.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/client/client.go`:
- Around line 158-160: `GetSpecificTeammateNoCache` uses `uhttp.WithNoCache()`, which only
  suppresses the cache lookup. `BaseHttpClient.Do` still calls `baseHttpCache.Set` for every
  200-OK GET regardless of the `Cache-Control` header, and `Cache-Control` is not folded into
  `CreateCacheKey`. The no-cache read therefore stores the pre-write scope list under exactly
  the key `GetSpecificTeammate` reads from, and caches are only cleared at end-of-sync (1h TTL),
  so a sync running later in the same process (`pkg/connector/teammates.go:252`) emits scope
  grants that omit whatever the grant task just wrote. Fix by either keeping `uhttp.WithNoCache()`
  on the specific-teammate endpoint outright (this PR's goal is caching the subuser calls, not
  this one), or by re-issuing the no-cache GET after a successful `SetTeammateScopes` so the
  write-through refreshes the cached entry to live state.
- Around line 95: add a test in `pkg/connector/client` that stands up an `httptest` server and
  asserts that two GETs to the same URL differing only in the `on-behalf-of` header both reach
  the server (distinct cache keys) while a repeat of the same header value is served from cache.
  Nothing currently exercises `uhttp.WithCacheKeyHeaders(OnBehalfOfHeaderName)` against the real
  uhttp client; `pkg/connector/scopes_test.go` uses a fake `SendGridClient`.

In `pkg/connector/helper.go`:
- Around line 75-80: the 404 rename-recovery retry in `getTeammateWithFreshOnBehalfOf` calls
  `resolveOnBehalfOfByParentID`, which goes through `GetSubuserUsernameByID` -> `GetSubusers`, a
  plain GET that this PR made cacheable. If the subuser list is already in the cache for this
  process (1h TTL), the "fresh" resolution returns the same stale username that just 404'd, so
  the retry cannot recover from a subuser rename. `teammateBuilder.Delete`
  (`pkg/connector/teammates.go:285`) resolves on-behalf-of the same way and swallows the
  resulting 404 as "already deleted" (`teammates.go:292-295`), producing a silent no-op delete.
  Add a no-cache variant of the subuser lookup (mirroring `GetSpecificTeammateNoCache`) and use
  it from these provisioning paths.
- Around line 57-58 and line 91-92: the comments still say these are "plain, uncached" client
  calls. That is no longer true now that `GetSubusers` is cacheable. Update both comments to
  describe the actual caching behavior.

In `pkg/connector/scopes_test.go`:
- Around line 61-72: `teammatePrincipal` always builds a parent-scope teammate (nil parent
  resource ID), so `teammateOnBehalfOf` returns "" and the subuser-scoped Grant/Revoke flow —
  including the on-behalf-of resolution and the 404 re-resolution retry — is never exercised.
  Add a variant with a parent resource ID and a `subuser_username` profile field.

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

Blocking issues found — see review comments.

Bump baton-sdk to v0.24.6 and register on-behalf-of as a cache-key
header on the shared uhttp client so parent- and subuser-scoped
responses no longer collide on the same key. Drops the WithNoCache
workaround from onBehalfOfOpts that was needed before the SDK
exposed WithCacheKeyHeaders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

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

Blocking issues found — see review comments.

Commit 6e7728b fixed the on-behalf-of cache-key collision with
WithCacheKeyHeaders and dropped the blanket WithNoCache(), which
re-enabled caching for GetSpecificTeammate — including the read half of
the read-modify-write in scopeBuilder.Grant/Revoke.

That read's Scopes become a full-list SetTeammateScopes PATCH, and uhttp
caches GETs for an hour, never invalidates them on a write, and only
clears caches at end-of-sync. So two provisioning tasks on the same
teammate inside the TTL both compute their new scope list from the same
pre-write snapshot, and the second silently drops what the first granted
while still reporting success.

Split GetSpecificTeammate into a shared private helper with two
wrappers: the existing cacheable one for sync-time reads, and
GetSpecificTeammateNoCache for reads that feed a write.
getTeammateWithFreshOnBehalfOf — reached only from scope Grant/Revoke —
uses the latter for both its initial read and its post-rename retry. The
WithCacheKeyHeaders fix stays, so subuser-scoped sync reads keep their
cache without colliding with parent scope.

Tests model the cache (first response replayed, never invalidated) plus
the full-replace write, and cover grant accumulation, revoke, and the
GrantAlreadyExists short-circuit. All three fail against the cached
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +158 to +160
func (h *SendGridClient) GetSpecificTeammateNoCache(ctx context.Context, username Username, onBehalfOf OnBehalfOf) (*models.TeammateScope, error) {
return h.getSpecificTeammate(ctx, username, onBehalfOf, uhttp.WithNoCache())
}

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: uhttp.WithNoCache() only suppresses the cache readBaseHttpClient.Do still writes every 200-OK GET into the cache unconditionally (vendor/.../uhttp/wrapper.go:592-596 is not gated on Cache-Control), and Cache-Control is not part of CreateCacheKey. So this "no-cache" read stores the pre-write scope list under exactly the key GetSpecificTeammate reads from, and since caches are only cleared at end-of-sync (TTL 1h), a sync running later in the same process (teammates.go:252) emits scope grants missing whatever the grant task just wrote. Simplest fixes: keep WithNoCache() on the specific-teammate endpoint entirely (the PR's goal is caching the subuser calls), or re-issue the no-cache GET after a successful SetTeammateScopes so the write-through refreshes the entry to live state. (medium confidence — depends on provisioning and sync sharing a process)

Comment thread pkg/connector/helper.go
}

teammate, err = client.GetSpecificTeammate(ctx, sgclient.Username(username), sgclient.OnBehalfOf(freshOnBehalfOf))
teammate, err = client.GetSpecificTeammateNoCache(ctx, sgclient.Username(username), sgclient.OnBehalfOf(freshOnBehalfOf))

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: this rename-recovery retry now re-resolves through a cached path. resolveOnBehalfOfByParentIDGetSubuserUsernameByIDGetSubusers, and GetSubusers is a plain GET that this PR made cacheable, so if the subuser list was already fetched in this process within the 1h TTL the "fresh" lookup returns the same stale username that just 404'd and the retry is a no-op. teammateBuilder.Delete (teammates.go:285) has the same exposure, and there a stale on-behalf-of produces a 404 that is swallowed as "already deleted" (teammates.go:292-295), i.e. a silently no-op delete. Consider a no-cache variant of the subuser lookup for these provisioning paths. The comments at helper.go:57-58 and :91-92 still describe these as "plain, uncached" calls and should be updated either way.

@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 e3108e6 into main Aug 26, 2026
11 checks passed
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.

2 participants