Skip to content

bugfix(drapi): don't hammer the version API - #493

Open
ajalon1 wants to merge 13 commits into
datarobot-oss:mainfrom
ajalon1:aj/cache-tokens-and-errors-properly
Open

bugfix(drapi): don't hammer the version API#493
ajalon1 wants to merge 13 commits into
datarobot-oss:mainfrom
ajalon1:aj/cache-tokens-and-errors-properly

Conversation

@ajalon1

@ajalon1 ajalon1 commented May 8, 2026

Copy link
Copy Markdown
Contributor

RATIONALE

When GetAPIKey fails (i.e. API token expires after 12 hours), the cached internal.drapi.get.token variable stayed empty. This causes every subsequent API call in the process to invoke GET /api/v2/version via VerifyToken.

We've seen in https://datarobot.slack.com/archives/C07GTBE7UAE/p1778257695531299 this possibly happening. Maybe it's a CI job gone awry, or a user's script or agent gone awry. Not sure.

This can be properly fixed by caching not just the API token, but also the error response when verifying a token.

CHANGES

  • track GetAPIKey failure in errToken alongside token, which live in internal/drapi.

  • memoize these values by a new resolveToken() function that lives in internal/drapi.

  • replace all duplicated cache miss logic throughout the package with resolveToken()

  • remove GetToken/SetToken test seams (which while documented as such, are still part of the "exported production interface" for internal/drapi.

  • add a new GetAPITokenFunc function variable in internal/config that defaults to GetAPIKey(), but can be overridden in tests.

  • override GetAPITokenFunc as a generic test fixture, but also in in internal/drapi where we have an additional cache to reset

PR Automation

Comment-Commands: Trigger CI by commenting on the PR:

  • /trigger-smoke-test or /trigger-test-smoke - Run smoke tests
  • /trigger-install-test or /trigger-test-install - Run installation tests

Labels: Apply labels to trigger workflows:

  • run-smoke-tests or go - Run smoke tests on demand (only works for non-forked PRs)

Important

For Forked PRs: The run-smoke-tests label won't work. A required Smoke Tests check will block merge until a maintainer acts:

  • A maintainer uses /approve-smoke-tests to run smoke tests (results will set the check)
  • A maintainer uses /skip-smoke-tests to bypass the check without running tests

Please comment requesting a maintainer review if you need smoke tests to run.


Note

Medium Risk
Touches shared API authentication/caching used by all HTTP verbs; incorrect memoization or initialization order could break requests or mask token refresh behavior.

Overview
Fixes internal/drapi token caching to memoize both the API token and token-fetch failure via sync.Once (resolveToken + errToken), preventing repeated GetAPIKey/verification calls when the token is missing/expired.

Updates all verb helpers (Get, Post, Patch, Delete) and SetAuthHeaders to use the new resolver, adds drapi.Init(config.GetAPIKey) at CLI startup, and refactors tests to use a new StubAPIToken helper (including a shared internal/testutil version for non-drapi packages). Documentation is tweaked to emphasize avoiding global mutable state in tests and to call out memoizing failures in caches.

Reviewed by Cursor Bugbot for commit f7ece89. Bugbot is set up for automated code reviews on this repo. Configure here.

ajalon1 and others added 12 commits May 8, 2026 10:38
… probes

When GetAPIKey fails (e.g. expired token), token stays "" so every
subsequent Get/Post/… call re-invokes VerifyToken, generating a
/api/v2/version/ request for each API call in the process lifetime.

Introduce tokenErr alongside the existing token memoization and a
resolveToken() helper that caches both the success and the first failure.
SetToken clears tokenErr so callers that inject a fresh token (e.g. auth
flows) still work correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…en()

Replace the duplicated inline token-fetch blocks in each verb helper
with the new resolveToken() introduced in the previous commit, so auth
failure memoization applies consistently across all outbound HTTP calls.
Removes the now-unused "context" import from each file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rename tokenErr → errToken to satisfy golangci-lint errname rule.
Update resetTokenForTest to save/restore errToken alongside token so
tests that run after an auth-failure test don't inherit the cached error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inline comments at each change point explain the why for future
developers: why errToken is cleared in SetToken, why call sites delegate
to resolveToken() rather than inlining GetAPIKey, and why the test helper
resets errToken alongside token to prevent cross-test bleed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sync.Once is the idiomatic primitive for "call at most once per process."
It replaces the manual nil-check pattern in resolveToken() and makes the
intent clearer. SetToken (a test seam) resets the Once so tests can inject
a known token without going through GetAPIKey; the Do callback guards on
token == "" so a pre-seeded value is never overwritten.

Also removes a now-stale nolintlint directive in telemetry/userid.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Centralises token stubbing in testutil_test.go. StubAPIToken uses
t.Cleanup so call sites drop the defer ...() pattern entirely. The old
resetTokenForTest helper in post_test.go is deleted; all 19 call sites
across auth, patch, delete, and post tests are updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…StubAPIToken

Moving the func var to config breaks the import cycle that previously prevented
a shared test helper: testutil can now import config (which doesn't import drapi)
without creating a cycle.

- config/auth.go: declare GetAPITokenFunc alongside GetAPIKey
- drapi/get.go: call config.GetAPITokenFunc; remove local var
- drapi/testutil_test.go: delegate func swap to testutil.StubAPIToken
- testutil/drapi.go: new shared StubAPIToken for black-box tests
- telemetry/userid_test.go: replace local resetTokenForTest with testutil.StubAPIToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread internal/testutil/drapi.go
Comment thread internal/drapi/get.go
// SetToken sets the cached API token.
func SetToken(value string) {
token = value
return token, errToken

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsynchronized resolveToken races on new errToken variable

Medium Severity

resolveToken() reads and writes the new package-level errToken (and token) without any synchronization. Under concurrent access — multiple goroutines calling Get/Post/Delete/Patch — all of them can simultaneously observe errToken == nil and token == "", then all call config.GetAPITokenFunc, defeating the "at most once" memoization goal. With error caching, a race could also cause one goroutine to permanently cache a transient error, blocking all subsequent API calls. The PR's own AGENTS.md addition recommends sync.Once for exactly this pattern.

Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Rules for DataRobot CLI

Reviewed by Cursor Bugbot for commit d877a03. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Reviewed by Cursor Bugbot for commit f7ece89. Configure here.

Comment thread internal/drapi/post.go
return nil, err
}
// resolveToken memoizes both success and failure; see get.go for rationale.
if token, err = resolveToken(); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data race: unsynchronized writes to package-level token

Medium Severity

Post(), Patch(), Delete(), and SetAuthHeaders() all assign resolveToken()'s return value back to the package-level token variable (if token, err = resolveToken()) outside the sync.Once protection. Concurrent callers race on that write. Get() correctly uses a local variable (tok, err := resolveToken()). The comment on resolveToken() claims it's "safe for concurrent callers," but the callers in these functions undermine that guarantee by performing unsynchronized writes to the shared token variable.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by project rule: Bugbot Rules for DataRobot CLI

Reviewed by Cursor Bugbot for commit f7ece89. Configure here.

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