{"id":"b85993fb596a6e0a","repo":"redis/node-redis","slug":"tokenmanager-is-not-running-but-refresh-was-calle","errorCode":null,"errorMessage":"TokenManager is not running, but refresh was called","messagePattern":"TokenManager is not running, but refresh was called","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/client/lib/authx/token-manager.ts","lineNumber":216,"sourceCode":"\n    if (this.retryAttempt >= maxAttempts) {\n      return false;\n    }\n\n    if (isRetryable) {\n      return isRetryable(error, this.retryAttempt);\n    }\n\n    return false;\n  }\n\n  public isRunning(): boolean {\n    return this.listener !== null;\n  }\n\n  private async refresh(): Promise<void> {\n    if (!this.listener) {\n      throw new Error('TokenManager is not running, but refresh was called');\n    }\n\n    try {\n      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  }","sourceCodeStart":198,"sourceCodeEnd":234,"githubUrl":"https://github.com/redis/node-redis/blob/bb5beb56578573910e2ee8f39681edc214c41398/packages/client/lib/authx/token-manager.ts#L198-L234","documentation":"TokenManager.refresh() is the private method invoked by the scheduled refresh timeout. It asserts `this.listener` is non-null because refresh is only meaningful while the manager is running. The guard fires if a refresh fires after stop()/dispose() — i.e. a race where a pending setTimeout callback invokes refresh() after the listener was cleared. The error indicates the lifecycle bookkeeping allowed a late refresh to slip through.","triggerScenarios":"Calling dispose() on the Disposable returned by start() while a refresh is already in-flight or scheduled, and the timeout fires before/after the listener is nulled; a bug in scheduleNextRefresh not canceling the prior timeout on stop (the code does clearTimeout, so hitting this normally implies an unexpected code path or a manual test invocation of refresh).","commonSituations":"Aggressive teardown in tests or on process shutdown disposing the manager while a token request is mid-flight; double-dispose; a fork/worker race where two actors manage the same TokenManager; direct unit-test invocation of the private refresh path after stop.","solutions":["Ensure you only dispose the TokenManager after outstanding token requests settle, or tolerate the resulting rejection.","Avoid calling start() and dispose() concurrently from different async contexts; serialize lifecycle transitions.","If you see this outside tests, treat it as a lifecycle bug — confirm stop()/clearTimeout is reached on every dispose path.","Upgrade the client; if reproducible, report it with the exact start/dispose interleaving."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"if (!tokenManager.isRunning()) {\n  // do not trigger a refresh path; the manager is stopped\n}","typeGuard":null,"tryCatchPattern":"try {\n  // ... code that may race with dispose\n} catch (err) {\n  if (err instanceof Error && /not running, but refresh was called/.test(err.message)) {\n    // benign teardown race; ignore\n  } else throw err;\n}","preventionTips":["Do not dispose the TokenManager while a refresh is in flight; drain or await it first.","Serialize start/dispose transitions from a single owner.","In tests, await outstanding operations before teardown."],"tags":["authx","token-manager","lifecycle","race-condition"],"analyzedSha":"bb5beb56578573910e2ee8f39681edc214c41398","analyzedAt":"2026-08-03T19:09:15.686Z","schemaVersion":2}