fix: surface fetch errors behind cached data and break 429 loop on dead refresh - #11
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The popover cache (shipped in #10) silently hid every post-launch fetch failure:
PopoverViewrendersUsageDetailViewwheneverusage != nil, so theelse if let errorbranch was unreachable once the cache hydrated. Combined with a deadrefresh_tokengrant 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 fromrefresh_tokengrant, clear the stored credential so the next read falls through to file-based sources (or surfacesnoCredential).TokenRefreshingClient.fetchUsage: if the credential is expired, refresh fails, and the re-read returns the same expired value, throwunauthorizedimmediately instead of shipping a known-dead token to the API.KeychainReader: addCredentialStore.clearStoredCredential()— removes the keychain entry without settingisSignedOut(user didn't sign out).PopoverView: addStaleDataBannerrendered aboveUsageDetailViewwheneverusage != nil && error != nil, with contextual copy (session expired / rate limited / offline / generic) and a Sign in or Retry action. Reorder view selection soauthFlow.isAwaitingCodetakes 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
noCredentialsurfaces.Test plan
swift test— all 143 tests pass locallyExpired token + failed refresh + same credential → fails fast as unauthorizedasserts we never ship a dead token to the API (apiCounter.value == 1, refresh only).Provider returns same expired token twice with no refresh token — fails fastnow asserts the new bailout instead of the old "try the API anyway" behavior that enabled the 429 loop..appagainst a dead-credential keychain — confirm banner renders and Sign in flow completes.