Skip to content

feat: cache stack API tokens to disk and lazy-load OIDC discovery#116

Merged
sylr merged 1 commit into
mainfrom
feat/cache-stack-token
Feb 20, 2026
Merged

feat: cache stack API tokens to disk and lazy-load OIDC discovery#116
sylr merged 1 commit into
mainfrom
feat/cache-stack-token

Conversation

@sylr

@sylr sylr commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Cache the stack API token (obtained from FetchStackToken) to disk so that repeated CLI invocations skip the stack OIDC discovery and JWT bearer exchange. Also wrap the membership relying party in a lazy proxy that defers the membership OIDC discovery call until actually needed.

With a warm cache, stack commands make zero auth network calls.

@coderabbitai

coderabbitai Bot commented Feb 18, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

Implements disk-based caching for Stack API tokens (read/write per profile/org/stack) and introduces a lazy relying-party wrapper to defer OIDC discovery. Token source constructors and NewStackClient are updated to propagate cmd/profile/org/stack context used for cache persistence.

Changes

Cohort / File(s) Summary
Token caching & profile storage
pkg/profile.go, pkg/clients.go
Added CachedStackAPIToken (with expiry) and functions WriteCachedStackAPIToken / ReadCachedStackAPIToken to persist tokens under profiles/<profile>/organizations/<org>/stacks/<stack>/api_token_cache.json. Integrated cache read/write into the stack token workflow.
Token source signature & propagation
pkg/clients.go, cmd/stack/proxy.go
Extended NewStackTokenSource signature to accept cmd *cobra.Command, profileName, organizationID, stackID and stored them on the stackTokenSource. Updated call sites (including NewStackClient and cmd/stack/proxy.go) to forward the new context so token refresh callbacks can persist cache.
Lazy relying-party initialization
pkg/relyingparty.go, pkg/profile.go
Introduced lazyRelyingParty and NewLazyRelyingParty to defer OIDC discovery until non-HttpClient methods are used. Replaced eager relying-party creation with the lazy factory in profile-related initialization and added mutex/once-style init and delegation methods.
Logging / imports / minor formatting
pkg/relyingparty.go, pkg/clients.go, pkg/profile.go
Added debug log for membership URI, new imports (sync, fmt, jose, oauth2, time, http helper), and minor struct/field alignment changes to accommodate new fields.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client Code
    participant TokenSrc as stackTokenSource
    participant DiskCache as Disk Cache
    participant RemoteAuth as Remote Auth

    Client->>TokenSrc: Token()
    alt Valid cached token exists and not expired
        TokenSrc->>DiskCache: ReadCachedStackAPIToken(cmd, profile, org, stack)
        DiskCache-->>TokenSrc: CachedStackAPIToken
        TokenSrc-->>Client: AccessToken (from cache)
    else No valid cache / expired
        TokenSrc->>RemoteAuth: Fetch/Refresh token
        RemoteAuth-->>TokenSrc: AccessToken
        TokenSrc->>DiskCache: WriteCachedStackAPIToken(cmd, profile, org, stack)
        DiskCache-->>TokenSrc: ✓ Written (best-effort)
        TokenSrc-->>Client: AccessToken
    end
Loading
sequenceDiagram
    participant Code as Client Code
    participant LazyRP as lazyRelyingParty
    participant Discovery as OIDC Discovery

    Code->>LazyRP: HttpClient()
    LazyRP-->>Code: *http.Client (no discovery)
    
    Code->>LazyRP: OAuthConfig() / other OIDC call
    LazyRP->>LazyRP: mustDelegate() / init() (once, may retry)
    LazyRP->>Discovery: perform OIDC discovery
    Discovery-->>LazyRP: delegate RelyingParty
    LazyRP->>LazyRP: delegate.OAuthConfig()
    LazyRP-->>Code: *oauth2.Config
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 I nibble keys and store a token bright,
Lazy discovery sleeps through day and night,
Disk cradles secrets, written soft and neat,
Refresh hops in, then scurries to retreat,
A rabbit's cheer — caching done just right! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: caching stack API tokens to disk and implementing lazy-loading of OIDC discovery, which matches the primary objectives of the changeset.
Description check ✅ Passed The description is directly related to the changeset, explaining the caching mechanism and lazy-loading approach, though it lacks some implementation details present in the code.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/cache-stack-token

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sylr sylr force-pushed the feat/cache-stack-token branch 3 times, most recently from ed887e7 to dffbb6a Compare February 20, 2026 13:59
@sylr sylr marked this pull request as ready for review February 20, 2026 14:50
@sylr sylr requested a review from a team as a code owner February 20, 2026 14:50

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
pkg/clients.go (1)

590-598: Best-effort cache write silently discards errors — acceptable but worth a debug log.

The _ = on line 593 silently swallows write failures (e.g., disk full, permission denied). This is fine for best-effort caching, but adding a debug-level log would help diagnose cache misses in the field.

♻️ Add debug logging on cache write failure
-		_ = WriteCachedStackAPIToken(t.cmd, t.profileName, t.organizationID, t.stackID, CachedStackAPIToken{
+		if err := WriteCachedStackAPIToken(t.cmd, t.profileName, t.organizationID, t.stackID, CachedStackAPIToken{
 			AccessToken: token.AccessToken,
 			TokenType:   token.TokenType,
 			Expiry:      token.Expiry,
-		})
+		}); err != nil {
+			pterm.Debug.Printfln("Failed to write stack API token cache: %v", err)
+		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/clients.go` around lines 590 - 598, The best-effort cache write currently
swallows errors with `_ = WriteCachedStackAPIToken(...)`; update the call in the
token refresh/issuance path (where t.cmd, t.profileName, t.organizationID,
t.stackID and CachedStackAPIToken are used) to capture the returned error and,
on non-nil error, emit a debug-level log (e.g., using processLogger or the
existing logger in scope) that includes a concise message and the error value so
cache write failures (disk full, permission denied) are visible without changing
behavior when successful. Ensure you do not change the call semantics beyond
logging the error.
pkg/relyingparty.go (1)

33-41: Storing context.Context in a struct is discouraged by Go conventions, though practical risk is low here.

The ctx is captured from cmd.Context() (Cobra command context), which outlives the entire command execution. Lazy initialization occurs when methods are invoked during command execution, so the context should remain valid. However, the pattern is unconventional; consider accepting context.Context at the point of use instead if context cancellation becomes relevant.

More concerning: sync.Once caches initialization errors permanently. If OIDC discovery encounters a transient failure (network timeout, temporary service unavailability), the error is cached forever and all subsequent method calls will panic with the same error. There is no recovery mechanism. If retry-on-failure is important, reconsider the sync.Once pattern.

The panic on discovery failure is consistent with error handling patterns used throughout this codebase (see pkg/http.go), so that aspect is acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/relyingparty.go` around lines 33 - 41, The struct lazyRelyingParty
currently stores a context (ctx) and uses sync.Once which permanently caches
init errors; remove the ctx field from lazyRelyingParty and change all
initialization/use sites (methods on lazyRelyingParty and any callers) to accept
a context.Context parameter at call time instead of reading p.ctx; replace
sync.Once with a short, explicit init routine protected by a sync.Mutex (e.g.,
check if delegate != nil under the mutex, otherwise attempt initialization using
the passed context, set delegate on success and set initErr only transiently) so
transient discovery/network failures do not become permanently cached—refer to
lazyRelyingParty, once, delegate, and initErr to locate the code to modify.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@pkg/clients.go`:
- Around line 559-571: The cache logic in NewStackTokenSource (t.accessToken
flow) ignores the promised --stack-token-cache-ttl and uses only token Expiry,
and it accepts cached tokens with no grace period; fix by adding a cache TTL
parameter to NewStackTokenSource (and its callers) that reads the flag/env
(STACK_TOKEN_CACHE_TTL) with default 5m and treating 0 as disabled, then when
loading cached tokens via ReadCachedStackAPIToken apply TTL/grace logic: compute
now := time.Now(); if ttl == 0 skip cache; otherwise treat cached token as valid
only if cached.Expiry.After(now.Add(cacheGrace)) and if cachedSavedAt + ttl >
now (or use cached.Expiry.Sub(cachedSavedAt) >= minTTL) so tokens with less than
a short grace (e.g., 30s) remaining are rejected; ensure the flag name and
parameter flow (NewStackTokenSource, ReadCachedStackAPIToken usage, and
t.accessToken assignment) are updated accordingly to enforce TTL and grace
period and document that 0 disables caching.

In `@pkg/profile.go`:
- Around line 108-110: GetAuthRelyingParty currently hides discovery failures by
returning a Lazy relying party that will panic later in mustDelegate; restore a
surfaced error path by changing NewLazyRelyingParty/GetAuthRelyingParty to
return an error (or perform OIDC discovery eagerly) and propagate that error to
callers like EnsureMembershipAccess and EnsureOrganizationAccess. Specifically,
update NewLazyRelyingParty to return (RelyingParty, error) or add an explicit
Validate/Init call that does discovery and returns an error instead of deferring
to mustDelegate, change GetAuthRelyingParty to return that error instead of nil,
and update callers (EnsureMembershipAccess, EnsureOrganizationAccess) to handle
and return the error rather than allowing a panic from mustDelegate.

In `@pkg/relyingparty.go`:
- Around line 43-55: The current lazyRelyingParty uses sync.Once in init() so a
transient GetAuthRelyingParty error (stored in l.initErr) becomes permanent and
mustDelegate() panics; replace this with a retry-capable initialization: remove
sync.Once usage and instead protect initialization with a sync.Mutex (or
RWMutex), have init attempt to call GetAuthRelyingParty (function name) and on
failure clear any partial state and return the error to callers (propagate via
mustDelegate() by returning a recoverable error or a nil-safe fallback) rather
than panic; update mustDelegate() to call the new init that can retry, avoid
panic(fmt.Sprintf(...)) and either return an error up the call chain or ensure
higher-level code handles the error/recovers, referencing lazyRelyingParty.init,
lazyRelyingParty.mustDelegate, l.delegate, l.initErr, and GetAuthRelyingParty to
locate changes.

---

Nitpick comments:
In `@pkg/clients.go`:
- Around line 590-598: The best-effort cache write currently swallows errors
with `_ = WriteCachedStackAPIToken(...)`; update the call in the token
refresh/issuance path (where t.cmd, t.profileName, t.organizationID, t.stackID
and CachedStackAPIToken are used) to capture the returned error and, on non-nil
error, emit a debug-level log (e.g., using processLogger or the existing logger
in scope) that includes a concise message and the error value so cache write
failures (disk full, permission denied) are visible without changing behavior
when successful. Ensure you do not change the call semantics beyond logging the
error.

In `@pkg/relyingparty.go`:
- Around line 33-41: The struct lazyRelyingParty currently stores a context
(ctx) and uses sync.Once which permanently caches init errors; remove the ctx
field from lazyRelyingParty and change all initialization/use sites (methods on
lazyRelyingParty and any callers) to accept a context.Context parameter at call
time instead of reading p.ctx; replace sync.Once with a short, explicit init
routine protected by a sync.Mutex (e.g., check if delegate != nil under the
mutex, otherwise attempt initialization using the passed context, set delegate
on success and set initErr only transiently) so transient discovery/network
failures do not become permanently cached—refer to lazyRelyingParty, once,
delegate, and initErr to locate the code to modify.

Comment thread pkg/clients.go
Comment on lines 559 to +571
if t.accessToken == nil || t.accessToken.Expiry.Before(time.Now()) {
// Try to load from disk cache
if t.cmd != nil {
cached, err := ReadCachedStackAPIToken(t.cmd, t.profileName, t.organizationID, t.stackID)
if err == nil && cached != nil && cached.Expiry.After(time.Now()) {
t.accessToken = &oauth2.Token{
AccessToken: cached.AccessToken,
TokenType: cached.TokenType,
Expiry: cached.Expiry,
}
return t.accessToken, 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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for TTL flag and implementations
echo "=== Searching for stack-token-cache-ttl flag ==="
rg -n "stack-token-cache-ttl" --type=go

echo -e "\n=== Searching for STACK_TOKEN_CACHE_TTL ==="
rg -n "STACK_TOKEN_CACHE_TTL" --type=go

echo -e "\n=== Searching for cacheTTL, tokenCacheTTL, CacheTTL patterns ==="
rg -n "cacheTTL|tokenCacheTTL|CacheTTL|cacheTimeout|cache.*[Tt][Tt][Ll]" --type=go

echo -e "\n=== Searching for time.Duration related to cache ==="
rg -n "Duration.*cache|cacheDuration|cache.*Duration" --type=go

echo -e "\n=== Looking for NewStackTokenSource function signature ==="
rg -n "func NewStackTokenSource" --type=go -A 10

Repository: formancehq/fctl

Length of output: 832


🏁 Script executed:

#!/bin/bash
# Search for grace period, clock skew, buffer logic
echo "=== Searching for grace period / skew / buffer logic ==="
rg -n "grace|skew|buffer|before.*duration|after.*duration" --type=go pkg/clients.go

echo -e "\n=== Reading lines 559-571 from pkg/clients.go with context ==="
sed -n '555,575p' pkg/clients.go

echo -e "\n=== Checking if there's any TTL or flag definition in cmd/ or commands/ ==="
find . -type f -name "*.go" -path "*/cmd/*" -o -path "*/commands/*" | head -20 | xargs grep -l "stack.*token.*cache\|cache.*ttl" 2>/dev/null || echo "No matches found"

Repository: formancehq/fctl

Length of output: 977


🏁 Script executed:

#!/bin/bash
# Let's search for proxy.go mentioned in the review scratchpad to understand context
echo "=== Searching for proxy.go and similar caching implementations ==="
find . -type f -name "proxy.go" | head -5

echo -e "\n=== Checking proxy.go for TTL implementation ==="
rg -n "stack-token-cache-ttl|cacheTTL|CacheTTL" --type=go pkg/proxy.go 2>/dev/null || echo "Not found or file doesn't exist"

echo -e "\n=== Looking at the PR/commit message or CHANGELOG ==="
git log --oneline -20 2>/dev/null || echo "Git not available"

echo -e "\n=== Search for any flag definitions in the codebase ==="
rg -n "Flag.*cache|AddFlag.*cache" --type=go | head -20

Repository: formancehq/fctl

Length of output: 414


🏁 Script executed:

#!/bin/bash
# Get full commit message
echo "=== Full commit message ==="
git show --no-patch --format=%B dffbb6a 2>/dev/null || echo "Cannot retrieve full commit"

echo -e "\n=== Look for any PR or description files ==="
find . -type f \( -name "*.md" -o -name "*.txt" \) | xargs grep -l "stack.*token.*cache.*ttl\|stack-token-cache-ttl" 2>/dev/null || echo "No matches"

echo -e "\n=== Verify the cache read logic has correct semantics ==="
echo "Checking if cached.Expiry.After(time.Now()) is correct check:"
sed -n '559,571p' pkg/clients.go

Repository: formancehq/fctl

Length of output: 1343


🏁 Script executed:

#!/bin/bash
# Final verification: confirm the flag is missing from the actual codebase
echo "=== Search for any flag parsing in main.go or root command ==="
rg -n "PersistentFlags|Flags()" --type=go cmd/ | grep -i cache || echo "No cache flag setup found"

echo -e "\n=== Check if NewStackTokenSource is called with any TTL parameter ==="
rg -n "NewStackTokenSource" --type=go -B 5 -A 5

echo -e "\n=== Verify ReadCachedStackAPIToken doesn't handle TTL ==="
rg -n "func ReadCachedStackAPIToken" --type=go -A 20

Repository: formancehq/fctl

Length of output: 2536


Cache implementation missing promised --stack-token-cache-ttl flag and grace period.

The commit message states: "Adds --stack-token-cache-ttl flag (default 5m, env STACK_TOKEN_CACHE_TTL) to control cache duration. Setting it to 0 disables caching." However, this flag is not implemented anywhere in the codebase. The NewStackTokenSource function receives no TTL parameter, and the cache validity check on line 563 only verifies the token's own Expiry field—there is no TTL-based expiration or option to disable caching.

Additionally, returning a cached token with 1 second of validity remaining (line 563: cached.Expiry.After(time.Now())) provides no grace period for clock skew, risking token expiration mid-request.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/clients.go` around lines 559 - 571, The cache logic in
NewStackTokenSource (t.accessToken flow) ignores the promised
--stack-token-cache-ttl and uses only token Expiry, and it accepts cached tokens
with no grace period; fix by adding a cache TTL parameter to NewStackTokenSource
(and its callers) that reads the flag/env (STACK_TOKEN_CACHE_TTL) with default
5m and treating 0 as disabled, then when loading cached tokens via
ReadCachedStackAPIToken apply TTL/grace logic: compute now := time.Now(); if ttl
== 0 skip cache; otherwise treat cached token as valid only if
cached.Expiry.After(now.Add(cacheGrace)) and if cachedSavedAt + ttl > now (or
use cached.Expiry.Sub(cachedSavedAt) >= minTTL) so tokens with less than a short
grace (e.g., 30s) remaining are rejected; ensure the flag name and parameter
flow (NewStackTokenSource, ReadCachedStackAPIToken usage, and t.accessToken
assignment) are updated accordingly to enforce TTL and grace period and document
that 0 disables caching.

Comment thread pkg/profile.go
Comment on lines +108 to 110
relyingParty := NewLazyRelyingParty(cmd.Context(), GetHttpClient(cmd), profile.GetMembershipURI())

return profile, profileName, relyingParty, 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.

⚠️ Potential issue | 🟠 Major

Error path removed — OIDC discovery failures now surface as panics.

Previously, GetAuthRelyingParty returned an error that was propagated to the caller. Now NewLazyRelyingParty never fails at construction time, but discovery errors will eventually panic inside mustDelegate() (in pkg/relyingparty.go). This means callers of the returned relyingParty (e.g., EnsureMembershipAccess, EnsureOrganizationAccess) will see panics instead of handleable errors if OIDC discovery fails.

This is the downstream consequence of the design choice discussed in pkg/relyingparty.go.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/profile.go` around lines 108 - 110, GetAuthRelyingParty currently hides
discovery failures by returning a Lazy relying party that will panic later in
mustDelegate; restore a surfaced error path by changing
NewLazyRelyingParty/GetAuthRelyingParty to return an error (or perform OIDC
discovery eagerly) and propagate that error to callers like
EnsureMembershipAccess and EnsureOrganizationAccess. Specifically, update
NewLazyRelyingParty to return (RelyingParty, error) or add an explicit
Validate/Init call that does discovery and returns an error instead of deferring
to mustDelegate, change GetAuthRelyingParty to return that error instead of nil,
and update callers (EnsureMembershipAccess, EnsureOrganizationAccess) to handle
and return the error rather than allowing a panic from mustDelegate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The RelyingParty interface methods don't return errors, so we can't propagate discovery failures through them. The panic is an inherent constraint of the interface.

We've mitigated this by replacing sync.Once with sync.Mutex in the lazy init (addressing the related comment on relyingparty.go), so transient failures are retried on the next call instead of being permanently cached. If discovery fails permanently, the panic message includes the underlying error, which is reasonable for a CLI tool.

Comment thread pkg/relyingparty.go Outdated
Comment on lines +43 to +55
func (l *lazyRelyingParty) init() {
l.once.Do(func() {
l.delegate, l.initErr = GetAuthRelyingParty(l.ctx, l.httpClient, l.membershipURI)
})
}

func (l *lazyRelyingParty) mustDelegate() client.RelyingParty {
l.init()
if l.initErr != nil {
panic(fmt.Sprintf("OIDC discovery failed: %v", l.initErr))
}
return l.delegate
}

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.

⚠️ Potential issue | 🟠 Major

sync.Once + panic makes transient failures permanent and unrecoverable.

Two concerns:

  1. Permanent failure on transient errors: sync.Once guarantees Do runs exactly once. If OIDC discovery fails due to a transient network issue, every subsequent call to mustDelegate() will panic with the cached error — no retry is possible for the lifetime of the process.

  2. panic instead of error propagation: For a CLI tool, panicking produces an ugly stack trace instead of a user-friendly error message. Since the RelyingParty interface methods don't return errors, this is understandably constrained, but consider at minimum recovering the panic at a higher level, or using a pattern like sync.Mutex with retry capability.

♻️ Sketch: retry-capable lazy init
 type lazyRelyingParty struct {
 	ctx           context.Context
 	httpClient    *http.Client
 	membershipURI string
 
-	once     sync.Once
+	mu       sync.Mutex
 	delegate client.RelyingParty
 	initErr  error
 }
 
 func (l *lazyRelyingParty) init() {
-	l.once.Do(func() {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	if l.delegate != nil {
+		return
+	}
+	l.delegate, l.initErr = GetAuthRelyingParty(l.ctx, l.httpClient, l.membershipURI)
-	})
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/relyingparty.go` around lines 43 - 55, The current lazyRelyingParty uses
sync.Once in init() so a transient GetAuthRelyingParty error (stored in
l.initErr) becomes permanent and mustDelegate() panics; replace this with a
retry-capable initialization: remove sync.Once usage and instead protect
initialization with a sync.Mutex (or RWMutex), have init attempt to call
GetAuthRelyingParty (function name) and on failure clear any partial state and
return the error to callers (propagate via mustDelegate() by returning a
recoverable error or a nil-safe fallback) rather than panic; update
mustDelegate() to call the new init that can retry, avoid
panic(fmt.Sprintf(...)) and either return an error up the call chain or ensure
higher-level code handles the error/recovers, referencing lazyRelyingParty.init,
lazyRelyingParty.mustDelegate, l.delegate, l.initErr, and GetAuthRelyingParty to
locate changes.

Cache the stack API token (obtained from FetchStackToken) to disk so that
repeated CLI invocations skip the stack OIDC discovery and JWT bearer
exchange. Also wrap the membership relying party in a lazy proxy that
defers the membership OIDC discovery call until actually needed.

The lazy relying party uses sync.Mutex (not sync.Once) so that transient
OIDC discovery failures are retried on the next call rather than cached
permanently.

With a warm cache, stack commands make zero auth network calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@sylr sylr force-pushed the feat/cache-stack-token branch from dffbb6a to 4e38284 Compare February 20, 2026 15:19

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

🧹 Nitpick comments (1)
pkg/clients.go (1)

559-563: Capture time.Now() once; extract the 5 s grace constant.

Two separate time.Now() calls (line 559 and line 563) produce different instants within the same locked block. While the 5-second guard makes the gap inconsequential in practice, a single captured now is more idiomatic. The 5*time.Second is also a magic number worth naming.

♻️ Proposed refactor
+const cacheGracePeriod = 5 * time.Second

 func (t *stackTokenSource) Token() (*oauth2.Token, error) {
 	t.mu.Lock()
 	defer t.mu.Unlock()

-	if t.accessToken == nil || t.accessToken.Expiry.Before(time.Now()) {
+	now := time.Now()
+	if t.accessToken == nil || t.accessToken.Expiry.Before(now) {
 		// Try to load from disk cache
 		if t.cmd != nil {
 			cached, err := ReadCachedStackAPIToken(t.cmd, t.profileName, t.organizationID, t.stackID)
-			if err == nil && cached != nil && cached.Expiry.After(time.Now().Add(5*time.Second)) {
+			if err == nil && cached != nil && cached.Expiry.After(now.Add(cacheGracePeriod)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pkg/clients.go` around lines 559 - 563, Capture time.Now() once into a local
variable (e.g., now := time.Now()) at the start of the check and use that single
variable for both expiry comparisons (replace uses in the t.accessToken expiry
check and cached.Expiry check), and extract the 5*time.Second magic number into
a named constant (e.g., cacheGrace := 5 * time.Second) to replace the literal;
apply this change around the block referencing t.accessToken, t.cmd,
ReadCachedStackAPIToken and the cached variable so both comparisons use the same
timestamp and the grace period is named.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@pkg/clients.go`:
- Around line 559-571: The code currently uses a hardcoded 5s grace window and
accepts cached tokens without honoring a max cache TTL; implement the promised
--stack-token-cache-ttl flag (env STACK_TOKEN_CACHE_TTL, default 5m, "0"
disables caching) and use it when deciding to accept cached tokens in the token
retrieval logic (the method that checks t.accessToken and calls
ReadCachedStackAPIToken). Add parsing of the flag/env into a duration variable
(e.g., stackTokenCacheTTL) and enforce two checks before using cached: 1)
cached.Expiry must be after time.Now().Add(gracePeriod) (keep gracePeriod
configurable or maintain current small grace), and 2) the cached token's age
(derived from cached.Expiry and token lifetime or a cached.Created/Stored
timestamp returned by ReadCachedStackAPIToken) must not exceed
stackTokenCacheTTL (treat 0 as disable caching). Wire these checks into the
branch around ReadCachedStackAPIToken and ensure cached is rejected when TTL
expired so a fresh token is fetched.

In `@pkg/profile.go`:
- Around line 108-109: NewLazyRelyingParty defers OIDC discovery, but
mustDelegate currently panics on discovery failure so callers (e.g.,
EnsureMembershipAccess, EnsureOrganizationAccess) can't handle errors; revert to
error propagation: change mustDelegate to return (delegate, error) instead of
panicking, have mustDelegate surface discovery errors from NewLazyRelyingParty
and update callers (EnsureMembershipAccess, EnsureOrganizationAccess, and any
code that assumed GetAuthRelyingParty semantics) to check and return the error
instead of allowing a panic, preserving the original GetAuthRelyingParty-style
error handling contract.

In `@pkg/relyingparty.go`:
- Around line 57-62: mustDelegate() currently panics on OIDC discovery failure
(in lazyRelyingParty.mustDelegate calling l.init()), which still causes an
unrecoverable crash; instead add a top-level panic recovery around the CLI
command dispatch (the main/top-level command handler that invokes your commands)
that calls recover(), converts the panic value to a user-friendly error message
and exits cleanly, and leave lazyRelyingParty.mustDelegate as-is so init()
errors still trigger a controlled panic that will be caught by that global
recover; ensure the recover logic formats the recovered value (from panic) into
a readable error and returns a non-zero exit code.

---

Nitpick comments:
In `@pkg/clients.go`:
- Around line 559-563: Capture time.Now() once into a local variable (e.g., now
:= time.Now()) at the start of the check and use that single variable for both
expiry comparisons (replace uses in the t.accessToken expiry check and
cached.Expiry check), and extract the 5*time.Second magic number into a named
constant (e.g., cacheGrace := 5 * time.Second) to replace the literal; apply
this change around the block referencing t.accessToken, t.cmd,
ReadCachedStackAPIToken and the cached variable so both comparisons use the same
timestamp and the grace period is named.

@sylr sylr merged commit 64dbb03 into main Feb 20, 2026
4 checks passed
@sylr sylr deleted the feat/cache-stack-token branch February 20, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants