fix(accounts): preserve refresh errors during usage queries

This commit is contained in:
wucm667
2026-09-19 17:22:39 +08:00
parent 1a9d49e16f
commit b252821c59
2 changed files with 84 additions and 5 deletions
@@ -359,11 +359,9 @@ func (s *AccountUsageService) getUsageForAccount(ctx context.Context, account *A
}
if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
usage, err := s.getOpenAIUsage(ctx, account, forceProbe)
if err == nil {
s.tryClearRecoverableAccountError(ctx, account)
}
return usage, err
// Usage can come from a stored snapshot even when a probe fails. Neither
// that nor a working access token proves a rejected refresh token recovered.
return s.getOpenAIUsage(ctx, account, forceProbe)
}
if account.Platform == PlatformGemini {
@@ -189,3 +189,84 @@ func TestAccountUsageService_GetUsageBatch_BestEffortByAccount(t *testing.T) {
t.Fatalf("expected API key account error to be preserved, got %q", errorsByAccount[7003])
}
}
// Model ClearError's persisted effect so this regression catches both the write
// and mutations of the account returned by the repository.
type usageErrorAccountRepo struct {
stubOpenAIAccountRepo
clearCalls int
}
func (r *usageErrorAccountRepo) ClearError(ctx context.Context, id int64) error {
r.clearCalls++
account, err := r.GetByID(ctx, id)
if err != nil {
return err
}
account.Status = StatusActive
account.ErrorMessage = ""
return nil
}
func TestAccountUsageService_OpenAIQueriesPreserveRefreshError(t *testing.T) {
for _, scenario := range []string{"cached_expired_access", "cached_valid_access_invalid_refresh", "failed_probe_missing_access", "probe_throttled"} {
for _, query := range []string{"usage", "usage_batch", "forced_usage_batch", "today", "today_batch"} {
t.Run(scenario+"/"+query, func(t *testing.T) {
const message = "Token refresh failed (non-retryable): refresh_token_invalidated"
account := Account{
ID: 7358, Platform: PlatformOpenAI, Type: AccountTypeOAuth,
Status: StatusError, ErrorMessage: message,
Credentials: map[string]any{"refresh_token": "invalid-refresh-token"},
}
cache := NewUsageCache()
if strings.HasPrefix(scenario, "cached_") {
account.Credentials["access_token"] = "cached-access-token"
expiresAt := time.Now().Add(-time.Hour)
if scenario == "cached_valid_access_invalid_refresh" {
expiresAt = time.Now().Add(time.Hour)
}
account.Credentials["expires_at"] = expiresAt.Format(time.RFC3339)
account.Extra = map[string]any{"codex_5h_used_percent": 18.0, "codex_7d_used_percent": 34.0}
}
if scenario == "probe_throttled" {
cache.openAIProbeCache.Store(account.ID, time.Now())
}
// Forced requests take the probe path, but fail locally without
// credentials; no external network is needed for this regression.
if query == "forced_usage_batch" {
delete(account.Credentials, "access_token")
}
repo := &usageErrorAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}}
svc := &AccountUsageService{accountRepo: repo, usageLogRepo: &usageBatchLogRepoStub{}, cache: cache}
ctx := context.Background()
switch query {
case "usage":
usage, err := svc.GetUsage(ctx, account.ID)
if err != nil || usage == nil {
t.Fatalf("GetUsage: usage=%v err=%v", usage, err)
}
case "usage_batch", "forced_usage_batch":
usage, failures, err := svc.GetUsageBatch(ctx, []int64{account.ID}, query == "forced_usage_batch")
if err != nil || len(failures) != 0 || usage[account.ID] == nil {
t.Fatalf("GetUsageBatch: usage=%v failures=%v err=%v", usage, failures, err)
}
case "today":
if _, err := svc.GetTodayStats(ctx, account.ID); err != nil {
t.Fatal(err)
}
case "today_batch":
if _, err := svc.GetTodayStatsBatch(ctx, []int64{account.ID}); err != nil {
t.Fatal(err)
}
}
stored, err := repo.GetByID(ctx, account.ID)
if err != nil {
t.Fatal(err)
}
if repo.clearCalls != 0 || stored.Status != StatusError || stored.ErrorMessage != message {
t.Fatalf("query erased refresh error: clearCalls=%d status=%q error=%q", repo.clearCalls, stored.Status, stored.ErrorMessage)
}
})
}
}
}