yikart/AiToEarn · error · TwitterPlatformException
15009
15009
Error message
Refresh token failed
What it means
Thrown by TwitterService.refreshAccessToken when refreshing an expired access token via the SDK's oauth.refreshToken at POST /2/oauth2/token fails. The error is wrapped with fromSdkOAuthError using ResponseCode.ChannelRefreshTokenFailed (15009), categorized as Auth and non-retryable. Twitter's rejection reason is kept in cause.platformMessage/raw.
Source
Thrown at project/aitoearn-backend/apps/aitoearn-server/src/core/channels/platforms/twitter/twitter.service.ts:133
expiresAt: credential.expires_in ? new Date(Date.now() + credential.expires_in * 1000) : undefined,
scope: credential.scope,
}
}
async refreshAccessToken(refreshToken: string): Promise<{
accessToken: string
refreshToken?: string
expiresAt?: Date
scope?: string
}> {
let credential: Awaited<ReturnType<OAuth2['refreshToken']>>
try {
const oauth = this.createOAuth2Client()
credential = await oauth.refreshToken(refreshToken)
}
catch (error) {
if (error instanceof Error) {
throw TwitterPlatformException.fromSdkOAuthError(error, {
code: ResponseCode.ChannelRefreshTokenFailed,
context: { endpoint: 'POST /2/oauth2/token' },
})
}
throw error
}
return {
accessToken: credential.access_token,
refreshToken: credential.refresh_token ?? refreshToken,
expiresAt: credential.expires_in ? new Date(Date.now() + credential.expires_in * 1000) : undefined,
scope: credential.scope,
}
}
async revokeToken(accessToken: string): Promise<boolean> {
const params = new URLSearchParams({ token: accessToken })
if (!this.cfg.clientSecret) {View on GitHub (pinned to d3aa8bea5b)
Solutions
- Check cause.platformMessage: 'invalid_grant' means the refresh token is dead — mark the channel disconnected and require re-authorization.
- Always persist the NEW refresh_token returned by each refresh (rotation); never reuse the previous one.
- Verify clientId/clientSecret match the app that originally issued the token and the current environment.
- Wrap refresh in a transactional update: only swap stored tokens if the refresh succeeded, and log rotation failures.
- If refresh fails repeatedly, surface a reconnect flow to the user instead of retrying (this error is non-retryable).
Example fix
// before
const t = await this.twitter.refreshAccessToken(old.refreshToken)
// after: persist rotated refresh token
const t = await this.twitter.refreshAccessToken(old.refreshToken)
await this.channelRepo.updateByXxx(channelId, {
accessToken: t.accessToken,
refreshToken: t.refreshToken ?? old.refreshToken,
expiresAt: t.expiresAt,
}) Defensive patterns
Strategy: try-catch
Validate before calling
async function hasRotatableToken(channel: { refreshToken?: string | null; clientId: string }): Promise<boolean> {
return Boolean(channel.refreshToken)
&& channel.clientId === (await getCurrentTwitterClientId()) // token must belong to current app
} Type guard
function isRefreshFailure(e: unknown): e is ChannelPlatformException & { code: typeof ResponseCode.ChannelRefreshTokenFailed } {
return e instanceof ChannelPlatformException && e.code === ResponseCode.ChannelRefreshTokenFailed
} Try / catch
try {
const t = await twitterService.refreshAccessToken(channel.refreshToken!)
// persist ROTATED refresh token atomically
await channelRepo.updateByXxx(channel.id, { accessToken: t.accessToken, refreshToken: t.refreshToken ?? channel.refreshToken, expiresAt: t.expiresAt })
} catch (e) {
if (isRefreshFailure(e)) {
await channelRepo.markDisconnected(channel.id) // invalid_grant is terminal
throw new UnauthorizedException('Reconnect Twitter account')
}
throw e
} Prevention
- Always store the refresh_token returned by every refresh — Twitter rotates them.
- Refresh proactively before expiresAt instead of waiting for a 401.
- Mark the channel as needing reconnect after this non-retryable error; don't loop retries.
- Rotate clientSecret carefully and redeploy token-refresh jobs with matching credentials.
When it happens
Trigger: Calling refreshAccessToken with a refresh token that has been revoked (user disconnected the app in Twitter settings), already used (Twitter rotates refresh tokens; the old one is invalid after use), expired, issued to a different client, or when the token endpoint rejects confidential-client auth due to a wrong clientSecret.
Common situations: Persisting the OLD refresh token after rotation and trying to reuse it next cycle; user revoking the app; stale refresh tokens copied between environments; incorrect clientSecret after a credential rotation; background jobs refreshing many accounts and not updating rotated tokens on partial failure.
Related errors
- 刷新令牌失败: ${data.error_description}
- 15010
- ChannelRefreshTokenFailed
- ChannelAuthRefreshTokenMissing
- ChannelAuthRefreshTokenMissing
AI-assisted analysis of yikart/AiToEarn@d3aa8bea5b (2026-08-31).
Data as JSON: /api/errors/28bd047e2947a7e8.
Report an issue: GitHub.