Skip to content

fix: surface fetch errors behind cached data and break 429 loop on dead refresh - #11

Merged
nkootstra merged 1 commit into
mainfrom
fix/stale-cache-hides-auth-errors
Apr 24, 2026
Merged

fix: surface fetch errors behind cached data and break 429 loop on dead refresh#11
nkootstra merged 1 commit into
mainfrom
fix/stale-cache-hides-auth-errors

Conversation

@nkootstra

Copy link
Copy Markdown
Owner

Summary

The popover cache (shipped in #10) silently hid every post-launch fetch failure: PopoverView renders UsageDetailView whenever usage != nil, so the else if let error branch was unreachable once the cache hydrated. Combined with a dead refresh_token grant returning 400 and the usage endpoint returning 429 for stale access tokens, the app got stuck in a 120s backoff loop with no user-visible signal — refresh button, poll interval changes, and the menu bar percentage all appeared frozen, while the footer showed a stale "last updated" 13h ago.

Changes

  • TokenRefreshingClient.refreshOwnToken: on 400/401 from refresh_token grant, clear the stored credential so the next read falls through to file-based sources (or surfaces noCredential).
  • TokenRefreshingClient.fetchUsage: if the credential is expired, refresh fails, and the re-read returns the same expired value, throw unauthorized immediately instead of shipping a known-dead token to the API.
  • KeychainReader: add CredentialStore.clearStoredCredential() — removes the keychain entry without setting isSignedOut (user didn't sign out).
  • PopoverView: add StaleDataBanner rendered above UsageDetailView whenever usage != nil && error != nil, with contextual copy (session expired / rate limited / offline / generic) and a Sign in or Retry action. Reorder view selection so authFlow.isAwaitingCode takes priority over cached usage — otherwise the code entry field was hidden behind the detail view when sign-in was started from the banner.

Recovery path for users already stuck

  1. Launch the new build — cached data still renders immediately.
  2. First fetch attempt: refresh returns 400 → stored credential cleared → noCredential surfaces.
  3. Orange banner appears above the cached view: "Session expired — data may be stale" with a Sign in button.
  4. Click Sign in → OAuth flow → polling resumes.

Test plan

  • swift test — all 143 tests pass locally
  • New test: Expired token + failed refresh + same credential → fails fast as unauthorized asserts we never ship a dead token to the API (apiCounter.value == 1, refresh only).
  • Updated test: Provider returns same expired token twice with no refresh token — fails fast now asserts the new bailout instead of the old "try the API anyway" behavior that enabled the 429 loop.
  • Smoke test the rebuilt .app against a dead-credential keychain — confirm banner renders and Sign in flow completes.

…ad refresh

When the popover cache hydrates on launch, any subsequent fetch failure was
silently hidden: PopoverView renders UsageDetailView whenever usage != nil,
so the error branch was never reached. Combined with a dead refresh_token
grant (400) and the usage endpoint returning 429 for stale access tokens,
the app got stuck in a 120s backoff loop with no user-visible signal — the
refresh button, interval changes, and the menu bar percentage all appeared
frozen.

- TokenRefreshingClient: when refresh_token grant returns 400/401, clear
  the stored credential so next read falls through to file-based sources
  or surfaces noCredential.
- TokenRefreshingClient: if the credential is expired, refresh failed, and
  the re-read returns the same expired value, throw unauthorized instead
  of shipping a known-dead token to the API.
- KeychainReader: add CredentialStore.clearStoredCredential() that removes
  the entry without setting isSignedOut (user did not sign out).
- PopoverView: add StaleDataBanner rendered above UsageDetailView whenever
  usage != nil && error != nil, with contextual copy and a Sign in / Retry
  action. Reorder view selection so authFlow.isAwaitingCode takes priority
  over cached usage — otherwise the code entry field was hidden behind the
  detail view when sign-in was started from the banner.
@nkootstra
nkootstra merged commit 5c7a616 into main Apr 24, 2026
3 checks passed
@nkootstra
nkootstra deleted the fix/stale-cache-hides-auth-errors branch April 24, 2026 19:50
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