Skip to content

[CXP-527] Mint fresh installation token at start of every gRPC call (experiment) - #155

Closed
johnallers wants to merge 1 commit into
mainfrom
johnallers/cxp-527-per-grpc-token-refresh
Closed

[CXP-527] Mint fresh installation token at start of every gRPC call (experiment)#155
johnallers wants to merge 1 commit into
mainfrom
johnallers/cxp-527-per-grpc-token-refresh

Conversation

@johnallers

Copy link
Copy Markdown
Contributor

Summary

Experiment branch testing btipling's suggestion from #151: mint a fresh GitHub App installation token at the start of every gRPC method on the connector, instead of relying on oauth2.ReuseTokenSource. Branched off main, not stacked on #151 or #152, so it can be tested in isolation.

Hypothesis

PR #152 (cap cached token lifetime at stated_expiry - 10m) deployed as v0.3.2-test.1 and did not stop the 401s — three more in the 3 hours after deploy on fanduel. That weakens the clock-skew / cache-aging story and suggests either (a) the existing oauth2 refresh path is broken in a way we can't see, or (b) tokens are being invalidated externally. This PR bypasses oauth2.ReuseTokenSource entirely so we can tell which.

If 401s drop with this change, the existing refresh path has a bug we should fix. If they don't, the cause is external and PR #151's reactive retry is the right answer.

What changed

pkg/connector/connector.go:

  • New forceRefreshTokenSource — caches a token like oauth2.ReuseTokenSource but exposes ForceRefreshNext() to make the next Token() call refresh regardless of expiry.
  • newWithGithubApp uses it for the installation token; stored on the GitHub struct as installationTokenSource.
  • ResourceSyncers() wraps each syncer with wrapSyncerForRefresh, which forces a refresh on every method entry.
  • Wrapper variants cover the optional-interface combinations actually present on this connector (Provisioner, Provisioner+StaticEnt, Deleter, Deleter+AccountManager, StaticEnt, plain) so capability detection via type assertion still works.

PAT mode is unaffected — installationTokenSource is nil and ResourceSyncers() skips wrapping.

A DEBUG log line fires when a fresh token is actually minted:

github-connector: minted fresh installation token (per-grpc refresh)

Cost

~200ms per gRPC call. For a multi-day fanduel sync that's roughly 16–30 min of cumulative latency. Acceptable for an experiment. JWT-authenticated endpoints on GitHub Apps are limited to 30/min per app — well above per-Lambda-instance rate, but worth watching during the test.

Test plan

  • go test -race ./pkg/connector/
  • make lint
  • Build a test release off this branch.
  • Point fanduel's connector at it.
  • Watch for 24h. Confirm via Datadog: mint-log rate roughly tracks gRPC call rate; 401 sync-failure rate compared against baseline.

If 401s persist after this, the cause is external and we should land #151. If they stop, we have a bug to chase in the existing refresh path.

Wrap each ResourceSyncerV2 returned from ResourceSyncers() with a thin
shim that flags the next Token() call to refresh, so each entry into a
gRPC method on the connector picks up a freshly-minted installation
token instead of reusing the cached one.

baton-sdk does not expose gRPC interceptors, so the refresh hook is
attached at the syncer level. Wrapper variants cover the combinations
of optional ResourceSyncer interfaces present today (Provisioner,
Deleter, AccountManager, StaticEntitlements) so capability detection
via type assertion still works.

Experiment to test whether bypassing oauth2.ReuseTokenSource entirely
eliminates the mid-sync 401s seen at ~1-5% rate across ~40 GitHub App
connectors. Branched off main, not stacked on PRs 151 or 152.
@johnallers
johnallers requested a review from a team May 18, 2026 18:45
@linear-code

linear-code Bot commented May 18, 2026

Copy link
Copy Markdown

CXP-527

Comment on lines +720 to 735
return &rsStaticEnt{rsBase: base, se: se}
case isDel && isAcct:
return &rsDeleterAccountMgr{
rsDeleter: rsDeleter{rsBase: base, del: del},
acct: acct,
}
case isDel:
return &rsDeleter{rsBase: base, del: del}
default:
return &base
}
}

func getOrgs(ctx context.Context, client *github.Client, orgs []string) ([]string, error) {
if len(orgs) != 0 {
return orgs, nil

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 switch silently drops interfaces for combinations not explicitly listed. For example, a syncer implementing both Deleter and StaticEntitlementSyncer would lose the StaticEntitlements capability. This is fine for the current experiment since all existing builder combos are covered, but consider adding a debug log in the default case (or a comment in wrapSyncerForRefresh) that lists the expected set of builders hitting each branch, so a future reader can tell at a glance whether a new builder needs a new variant.

@github-actions

Copy link
Copy Markdown
Contributor

Connector PR Review: [CXP-527] Mint fresh installation token at start of every gRPC call (experiment)

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Review mode: full
View review run

Review Summary

This PR adds a forceRefreshTokenSource that bypasses oauth2.ReuseTokenSource caching to mint a fresh GitHub App installation token before every gRPC-driven syncer method, in order to diagnose persistent 401 failures. The token source is properly mutex-protected, the wrapper types correctly preserve all optional syncer interfaces for the combinations currently in use (Provisioner+StaticEnt, Deleter, Deleter+AccountManager, StaticEnt-only, base-only), and PAT mode is unaffected. Tests cover token caching, forced refresh, error propagation, and capability preservation. No security or correctness issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/connector.go:720-735: The wrapSyncerForRefresh switch covers all current builder combos but would silently drop interfaces for future unhandled combinations. Consider adding a debug log or comment mapping builders to branches.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/connector.go`:
- Around lines 720-735: The wrapSyncerForRefresh switch statement handles all current
  syncer interface combinations but will silently drop capabilities for future combinations
  it does not cover (e.g., a syncer implementing both Deleter and StaticEntitlementSyncer).
  Consider adding a debug-level log line in the default case that prints which interfaces
  were detected but not wrapped, or add a comment mapping each existing builder to the
  switch branch it is expected to hit, so future developers know when to add a new variant.

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

@johnallers

johnallers commented May 19, 2026

Copy link
Copy Markdown
Contributor Author

Tested this change. Does not address issue.

@johnallers johnallers closed this May 19, 2026
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