OUT-3621: proactive token refresh with race-safe invalid_grant handling - #231
Conversation
…andling Replaces the reactive 401 approach with proactive refresh via a 10-min buffer (`getValidQbTokens`), removing the two duplicated `isExpired` blocks. On `invalid_grant`, `getRefreshedQbTokenInfo` distinguishes a cross-worker race (via `tokenSetTime` re-read) from genuine revocation and throws a typed `QBReconnectRequiredError` without mutating state. `AuthService` owns the paired disable-sync + notify-IU side effects so they stay together under a user context. Also renames the CI workflow file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryThis PR consolidates duplicated token-expiry checks into a single Confidence Score: 5/5Safe to merge — all findings are P2 style/efficiency suggestions; the race-detection and revocation-handling logic is correct and well-tested. The core No files require special attention; Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant getValidQbTokens
participant DB
participant getRefreshedQbTokenInfo
participant Intuit
participant handleInvalidGrant
Caller->>getValidQbTokens: portalId
getValidQbTokens->>DB: getPortalConnection
DB-->>getValidQbTokens: row
alt More than 10 min remaining on token
getValidQbTokens-->>Caller: stored tokens (no refresh)
else Within 10-min buffer or no tokenSetTime
getValidQbTokens->>getRefreshedQbTokenInfo: portalId
getRefreshedQbTokenInfo->>DB: getPortalConnection (2nd read, captures T1)
getRefreshedQbTokenInfo->>Intuit: getRefreshedQBToken
alt Intuit responds with new tokens
Intuit-->>getRefreshedQbTokenInfo: new access + refresh tokens
getRefreshedQbTokenInfo->>DB: UPDATE set tokenSetTime to now
getRefreshedQbTokenInfo-->>Caller: new tokens
else Intuit responds with invalid_grant
Intuit-->>getRefreshedQbTokenInfo: invalid_grant
getRefreshedQbTokenInfo->>handleInvalidGrant: portalId, startingTime T1
handleInvalidGrant->>DB: getPortalConnection (re-read, gets T2)
alt T2 is after T1 — concurrent worker already refreshed
handleInvalidGrant-->>Caller: winner tokens from DB, no write
else T2 equals T1 — genuine revocation
handleInvalidGrant-->>Caller: throws QBReconnectRequiredError
end
end
end
Note over Caller: AuthService.getQBPortalConnection catches QBReconnectRequiredError
Note over Caller: Queues IU notification via afterIfAvailable
Note over Caller: Awaits turnOffSync, returns empty tokens
Reviews (1): Last reviewed commit: "fix(OUT-3621): proactive token refresh w..." | Re-trigger Greptile |
| export async function getValidQbTokens( | ||
| portalId: string, | ||
| ): Promise<IntuitAPITokensType> { | ||
| const row = await getPortalConnection(portalId) | ||
| if (!row) { | ||
| throw new Error( | ||
| `getValidQbTokens | Portal connection not found for portalId: ${portalId}`, | ||
| ) | ||
| } | ||
| if (isTokenFresh(row)) return extractTokens(row) | ||
| return getRefreshedQbTokenInfo(portalId) | ||
| } |
There was a problem hiding this comment.
Extra DB read on the stale-token path
getValidQbTokens already fetches and checks the row on line 88, but when the token is stale it discards that result and calls getRefreshedQbTokenInfo(portalId) (line 95), which immediately does a second getPortalConnection call (line 111) to populate startingTokenSetTime. The two reads create a small race window: if another worker refreshes between them, the second read will capture that worker's tokenSetTime as startingTokenSetTime, causing this worker to attempt a redundant Intuit API call with the freshly rotated refresh token. The race-detection logic will eventually recover correctly, but the extra round-trip and the unnecessary Intuit call could be avoided entirely by accepting the pre-fetched row as a parameter and skipping the second DB query.
…loop intiateSync runs inside trigger.dev (no Vercel function timeout), so a long backlog of failed logs for one portal can outrun the 15-min proactive refresh buffer when we fetch tokens once upfront. Calling getQBPortalConnection per iteration keeps each log's processing inside a fresh-token window, and breaking on emptyTokens halts cleanly if a prior iteration's invalid_grant cascade disabled sync. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocation getRefreshedQbTokenInfo used to re-read the portal row to snapshot startingTokenSetTime. If a concurrent worker refreshed between the caller's freshness check and this re-read, the snapshot captured the winner's tokenSetTime — and a subsequent invalid_grant from Intuit would find tokenSetTime unchanged on re-read, misdiagnosing the race as a genuine revocation (wrongly flipping syncFlag=false and notifying the IU). Accept an optional prefetchedConnection parameter so callers that already read the row pass it through; the race-detection baseline now matches what they observed. Direct callers (CLI) fall back to a fresh read unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Widens the proactive-refresh window to better absorb long-running sync jobs, queue latency, and clock skew against Intuit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
isExpiredblocks with a singlegetValidQbTokens(portalId)helper that proactively refreshes when the stored token is withinREFRESH_BUFFER_SECONDS(15 min) of expiry.invalid_grant,getRefreshedQbTokenInfodistinguishes a cross-worker refresh race (DBtokenSetTimeadvanced) from genuine revocation by re-reading the row — clock-skew-free.QBReconnectRequiredErrorwithout mutating state.AuthService.getQBPortalConnectionowns the paired disable-sync + notify-IU side effects so they only fire under a Copilot user context.Test plan
yarn test— 50 tests across 3 files, all passing (10 new intokenRefresh.test.ts).yarn tsc --noEmit— clean (pre-existing unrelated errors filtered).syncFlagflips to false and the IU receives the reconnect notification once.checkForNonUsCompanyreturns false silently.Linear
OUT-3621
🤖 Generated with Claude Code