redis/node-redis · error · Error

TokenManager is not running, but refresh was called

Error message

TokenManager is not running, but refresh was called

What it means

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.

Source

Thrown at packages/client/lib/authx/token-manager.ts:216

    if (this.retryAttempt >= maxAttempts) {
      return false;
    }

    if (isRetryable) {
      return isRetryable(error, this.retryAttempt);
    }

    return false;
  }

  public isRunning(): boolean {
    return this.listener !== null;
  }

  private async refresh(): Promise<void> {
    if (!this.listener) {
      throw new Error('TokenManager is not running, but refresh was called');
    }

    try {
      await this.identityProvider.requestToken().then(this.handleNewToken);
      this.retryAttempt = 0;
    } catch (error) {

      if (this.shouldRetry(error)) {
        this.retryAttempt++;
        const retryDelay = this.calculateRetryDelay();
        this.notifyError(`Token refresh failed (attempt ${this.retryAttempt}), retrying in ${retryDelay}ms: ${error}`, true)
        this.scheduleNextRefresh(retryDelay);
      } else {
        this.notifyError(error, false);
        this.stop();
      }
    }
  }

View on GitHub (pinned to bb5beb5657)

Solutions

  1. Ensure you only dispose the TokenManager after outstanding token requests settle, or tolerate the resulting rejection.
  2. Avoid calling start() and dispose() concurrently from different async contexts; serialize lifecycle transitions.
  3. If you see this outside tests, treat it as a lifecycle bug — confirm stop()/clearTimeout is reached on every dispose path.
  4. Upgrade the client; if reproducible, report it with the exact start/dispose interleaving.
Defensive patterns

Strategy: try-catch

Validate before calling

if (!tokenManager.isRunning()) {
  // do not trigger a refresh path; the manager is stopped
}

Try / catch

try {
  // ... code that may race with dispose
} catch (err) {
  if (err instanceof Error && /not running, but refresh was called/.test(err.message)) {
    // benign teardown race; ignore
  } else throw err;
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

Related errors


AI-assisted analysis of redis/node-redis@bb5beb5657 (2026-08-03). Data as JSON: /data/errors/b85993fb596a6e0a.json. Report an issue: GitHub.