{"record":{"id":"28bd047e2947a7e8","repo":"yikart/AiToEarn","slug":"15009","errorCode":"15009","errorMessage":"Refresh token failed","messagePattern":"Refresh token failed","errorType":"exception","errorClass":"TwitterPlatformException","httpStatus":null,"severity":"error","filePath":"project/aitoearn-backend/apps/aitoearn-server/src/core/channels/platforms/twitter/twitter.service.ts","lineNumber":133,"sourceCode":"      expiresAt: credential.expires_in ? new Date(Date.now() + credential.expires_in * 1000) : undefined,\n      scope: credential.scope,\n    }\n  }\n\n  async refreshAccessToken(refreshToken: string): Promise<{\n    accessToken: string\n    refreshToken?: string\n    expiresAt?: Date\n    scope?: string\n  }> {\n    let credential: Awaited<ReturnType<OAuth2['refreshToken']>>\n    try {\n      const oauth = this.createOAuth2Client()\n      credential = await oauth.refreshToken(refreshToken)\n    }\n    catch (error) {\n      if (error instanceof Error) {\n        throw TwitterPlatformException.fromSdkOAuthError(error, {\n          code: ResponseCode.ChannelRefreshTokenFailed,\n          context: { endpoint: 'POST /2/oauth2/token' },\n        })\n      }\n      throw error\n    }\n\n    return {\n      accessToken: credential.access_token,\n      refreshToken: credential.refresh_token ?? refreshToken,\n      expiresAt: credential.expires_in ? new Date(Date.now() + credential.expires_in * 1000) : undefined,\n      scope: credential.scope,\n    }\n  }\n\n  async revokeToken(accessToken: string): Promise<boolean> {\n    const params = new URLSearchParams({ token: accessToken })\n    if (!this.cfg.clientSecret) {","sourceCodeStart":115,"sourceCodeEnd":151,"githubUrl":"https://github.com/yikart/AiToEarn/blob/d3aa8bea5b146a8675607cf0144d891aad3e9683/project/aitoearn-backend/apps/aitoearn-server/src/core/channels/platforms/twitter/twitter.service.ts#L115-L151","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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)."],"exampleFix":"// before\nconst t = await this.twitter.refreshAccessToken(old.refreshToken)\n// after: persist rotated refresh token\nconst t = await this.twitter.refreshAccessToken(old.refreshToken)\nawait this.channelRepo.updateByXxx(channelId, {\n  accessToken: t.accessToken,\n  refreshToken: t.refreshToken ?? old.refreshToken,\n  expiresAt: t.expiresAt,\n})","handlingStrategy":"try-catch","validationCode":"async function hasRotatableToken(channel: { refreshToken?: string | null; clientId: string }): Promise<boolean> {\n  return Boolean(channel.refreshToken)\n    && channel.clientId === (await getCurrentTwitterClientId()) // token must belong to current app\n}","typeGuard":"function isRefreshFailure(e: unknown): e is ChannelPlatformException & { code: typeof ResponseCode.ChannelRefreshTokenFailed } {\n  return e instanceof ChannelPlatformException && e.code === ResponseCode.ChannelRefreshTokenFailed\n}","tryCatchPattern":"try {\n  const t = await twitterService.refreshAccessToken(channel.refreshToken!)\n  // persist ROTATED refresh token atomically\n  await channelRepo.updateByXxx(channel.id, { accessToken: t.accessToken, refreshToken: t.refreshToken ?? channel.refreshToken, expiresAt: t.expiresAt })\n} catch (e) {\n  if (isRefreshFailure(e)) {\n    await channelRepo.markDisconnected(channel.id) // invalid_grant is terminal\n    throw new UnauthorizedException('Reconnect Twitter account')\n  }\n  throw e\n}","preventionTips":["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."],"tags":["oauth2","twitter","refresh-token","token-rotation"],"backgroundTag":"refresh-token-invalid","analyzedSha":"d3aa8bea5b146a8675607cf0144d891aad3e9683","analyzedAt":"2026-08-31T14:19:24.185Z","schemaVersion":2},"datasetVersion":"2026-08-31T19:17:28.585Z"}