fix(account): keep refreshing OAuth tokens for paused accounts

Paused (schedulable=false) active accounts were excluded from the
background token refresh candidates, so their stored access_token
silently expired. The admin usage-window quota probe uses the stored
token verbatim (no token provider / no refresh), so the probe then gets
HTTP 401 UNAUTHENTICATED and the dashboard reports a false "needs
re-auth" for a perfectly healthy account, while Test Connection and live
traffic (which refresh on demand via the token provider) keep working.

Pause is not permanent rejection: error accounts are already excluded by
the status = 'active' filter, and accounts whose refresh genuinely fails
are rate-limited by the retry-cooldown exclusion (10-minute retry-
exhausted cooldown). Refresh success only persists credentials and never
touches schedulable, so a paused account cannot be silently un-paused.

Drop the schedulable filter and update the SQL-filter test, the
integration pagination test, and the service-level candidate fake.
This commit is contained in:
bitnom
2026-09-15 10:00:26 -04:00
parent 881f320269
commit 8e34ca5e31
4 changed files with 25 additions and 14 deletions
+10 -1
View File
@@ -1211,11 +1211,20 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o
// NOT (a AND b) 在 PG 三值逻辑下会把 a 或 b 为 NULL 的行(即绝大多数
// 健康账号:temp_unschedulable_until=NULL)也排除,导致后台 token
// 刷新工作器漏掉所有正常账号 → access_token 到期后请求开始 401。
//
// Deliberately NO `schedulable = TRUE` filter here: paused accounts
// (schedulable=false, status=active) still hold valid refresh tokens and
// their stored access_token must keep working for the admin usage-window
// probe. Excluding them lets the token silently expire, after which the
// dashboard reports a false "needs re-auth" even though Test Connection
// (which refreshes on demand) succeeds. Permanent rejection is already
// covered by the status = 'active' filter (error accounts drop out), and
// accounts whose refresh actually fails are rate-limited by the
// ExcludeRetryCooldown clause below.
query := `
SELECT id
FROM accounts
WHERE deleted_at IS NULL
AND schedulable = TRUE
AND platform = ANY($1)
AND id > $2`
if options.ActiveOnly {
@@ -327,14 +327,16 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
"expires_at": now.Add(30 * time.Minute).Format(time.RFC3339),
},
})
unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-oauth-unschedulable-excluded",
// Paused but active OAuth accounts (schedulable=false) must remain refresh
// candidates so their stored access_token does not silently expire.
paused := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-oauth-paused-included",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{"refresh_token": "refresh-unschedulable"},
Credentials: map[string]any{"refresh_token": "refresh-paused"},
})
s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx))
s.Require().NoError(s.client.Account.UpdateOneID(paused.ID).SetSchedulable(false).Exec(s.ctx))
mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-api-key-excluded",
Platform: service.PlatformGrok,
@@ -393,15 +395,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
s.Require().NoError(err)
first := firstPage.Accounts
s.Require().Len(first, 2)
s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID})
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID)
s.Require().Equal([]int64{valid1.ID, paused.ID}, []int64{first[0].ID, first[1].ID})
options.AfterID = first[len(first)-1].ID
secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options)
s.Require().NoError(err)
second := secondPage.Accounts
s.Require().Len(second, 1)
s.Require().Equal(valid3.ID, second[0].ID)
s.Require().Len(second, 2)
s.Require().Equal([]int64{valid2.ID, valid3.ID}, []int64{second[0].ID, second[1].ID})
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, second[0].ID)
}
@@ -288,8 +288,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T)
normalized := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, normalized, "deleted_at IS NULL")
require.Contains(t, normalized, "schedulable = TRUE",
"permanently unschedulable accounts must not remain OAuth refresh candidates")
require.NotContains(t, normalized, "schedulable = TRUE",
"paused (schedulable=false, status=active) accounts must remain OAuth refresh candidates: excluding them lets their stored access_token expire and the usage-window probe then reports a false 'needs re-auth'; permanent rejection is already covered by status = 'active' and refresh failures are bounded by the retry-cooldown exclusion")
require.Contains(t, normalized, "status = 'active'")
// setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
@@ -51,7 +51,6 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont
}
}
if options.ActiveOnly && account.Status != StatusActive ||
!account.Schedulable ||
account.Type != AccountTypeOAuth ||
!platformAllowed ||
options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" ||
@@ -176,7 +175,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: false,
Credentials: map[string]any{"refresh_token": "permanently-rejected-token"},
Credentials: map[string]any{"refresh_token": "paused-account-token"},
},
},
}
@@ -195,7 +194,9 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing
svc.processRefresh()
require.Zero(t, repo.listActiveCalls, "TokenRefreshService should not use the broad active-account query")
require.ElementsMatch(t, []int64{1, 6}, repo.updatedCredentialIDs)
// Account 7 is paused (schedulable=false) but active: it must still be
// refreshed so its stored access_token does not silently expire.
require.ElementsMatch(t, []int64{1, 6, 7}, repo.updatedCredentialIDs)
require.Equal(t, 1, repo.clearTempCalls, "successful refresh should clear the OAuth 401 temp-unschedulable state")
}