{"id":"899988cc93b0b762","repo":"redis/node-redis","slug":"tokenmanager-is-not-running-but-a-new-token-was-r","errorCode":null,"errorMessage":"TokenManager is not running, but a new token was received","messagePattern":"TokenManager is not running, but a new token was received","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/client/lib/authx/token-manager.ts","lineNumber":238,"sourceCode":"      await this.identityProvider.requestToken().then(this.handleNewToken);\n      this.retryAttempt = 0;\n    } catch (error) {\n\n      if (this.shouldRetry(error)) {\n        this.retryAttempt++;\n        const retryDelay = this.calculateRetryDelay();\n        this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, true)\n        this.scheduleNextRefresh(retryDelay);\n      } else {\n        this.notifyError(error, false);\n        this.stop();\n      }\n    }\n  }\n\n  private handleNewToken = async ({ token: nativeToken, ttlMs }: TokenResponse<T>): Promise<void> => {\n    if (!this.listener) {\n      throw new Error('TokenManager is not running, but a new token was received');\n    }\n    const token = this.wrapAndSetCurrentToken(nativeToken, ttlMs);\n    this.listener.onNext(token);\n\n    this.scheduleNextRefresh(this.calculateRefreshTime(token));\n  }\n\n  /**\n   * Creates a Token object from a native token and sets it as the current token.\n   *\n   * @param nativeToken - The raw token received from the identity provider\n   * @param ttlMs - Time-to-live in milliseconds for the token\n   *\n   * @returns A new Token instance containing the wrapped native token and expiration details\n   *\n   */\n  public wrapAndSetCurrentToken(nativeToken: T, ttlMs: number): Token<T> {\n    const now = Date.now();","sourceCodeStart":220,"sourceCodeEnd":256,"githubUrl":"https://github.com/redis/node-redis/blob/bb5beb56578573910e2ee8f39681edc214c41398/packages/client/lib/authx/token-manager.ts#L220-L256","documentation":"handleNewToken is the async continuation attached to identityProvider.requestToken().then(...). It asserts the listener is still present when the token arrives. If the manager was stopped/disposed between issuing the request and receiving the token, the listener is null and the newly arrived token has nowhere to go — so it throws rather than silently scheduling a refresh against a dead manager.","triggerScenarios":"Calling dispose() while a requestToken() promise is pending; the token resolves after stop() has nulled the listener. Internal race: the refresh timeout callback starts a request, and dispose runs before the request settles.","commonSituations":"Short-lived processes disposing the token manager before the IDP responds; tests that tear down the manager without awaiting in-flight requests; an IDP with high latency combined with rapid start/stop cycling.","solutions":["Await or otherwise drain in-flight token requests before calling dispose() (track the outstanding promise and await it).","Tolerate the rejection in your error handler — it indicates a benign teardown race, not a token problem.","Avoid start/dispose ping-ponging; keep the TokenManager alive for the process lifetime.","If reproducible deterministically, report it as a lifecycle bug with the interleaving."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":null,"typeGuard":null,"tryCatchPattern":"try {\n  // operation during which dispose() may run\n} catch (err) {\n  if (err instanceof Error && /not running, but a new token was received/.test(err.message)) {\n    // token arrived after dispose; safe to ignore\n  } else throw err;\n}","preventionTips":["Track and await in-flight requestToken() promises before disposing.","Keep the TokenManager alive for the process lifetime rather than churning start/stop."],"tags":["authx","token-manager","lifecycle","race-condition"],"analyzedSha":"bb5beb56578573910e2ee8f39681edc214c41398","analyzedAt":"2026-08-03T19:09:15.686Z","schemaVersion":2}