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

  1. Check cause.platformMessage: 'invalid_grant' means the refresh token is dead — mark the channel disconnected and require re-authorization.
  2. Always persist the NEW refresh_token returned by each refresh (rotation); never reuse the previous one.
  3. Verify clientId/clientSecret match the app that originally issued the token and the current environment.
  4. Wrap refresh in a transactional update: only swap stored tokens if the refresh succeeded, and log rotation failures.
  5. 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

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


AI-assisted analysis of yikart/AiToEarn@d3aa8bea5b (2026-08-31). Data as JSON: /api/errors/28bd047e2947a7e8. Report an issue: GitHub.