nexu-io/open-design · warning · WorkspaceBillingInterestError

stale_generation

stale_generation

Error message

stale_generation

What it means

Thrown by setClientInterests when the client sends a generation number lower than the one the coordinator already has tracked for that clientId. This is an optimistic concurrency guard: each client must monotonically increase its generation with every interest-set mutation. A stale generation indicates the client's local state is behind — likely because a previous mutation succeeded server-side but the client didn't record the new generation (e.g., a race between two concurrent calls from the same client). The error includes the server's current accepted generation as the second argument so the client can re-sync.

Source

Thrown at apps/daemon/src/collab/workspace-billing-runtime.ts:265

    this.interestSweepTimer = this.scheduler.setInterval(() => {
      this.sweepExpiredInterests();
    }, Math.max(1, options.interestSweepIntervalMs ?? DEFAULT_INTEREST_SWEEP_INTERVAL_MS));
    this.interestSweepTimer.unref?.();
  }

  setClientInterests(
    input: WorkspaceBillingRuntimeInterestSet,
  ): WorkspaceBillingRuntimeInterestLease {
    this.assertUsable();
    const clientId = input.clientId.trim();
    const generationText = input.clientGeneration.trim();
    if (!clientId || !/^(?:0|[1-9]\d*)$/.test(generationText)) {
      throw new WorkspaceBillingInterestError('invalid_generation');
    }
    const generation = BigInt(generationText);
    const current = this.clients.get(clientId);
    if (current && generation < current.generation) {
      throw new WorkspaceBillingInterestError(
        'stale_generation',
        current.generation.toString(),
      );
    }
    const keys = new Map<string, WorkspaceBillingRuntimeKey>();
    for (const interest of input.interests) {
      const key = normalizeKey(interest);
      keys.set(runtimeKey(key), key);
    }
    if (keys.size > this.maxInterestsPerClient) {
      throw new WorkspaceBillingInterestError('interest_capacity_exceeded');
    }
    if (current && generation === current.generation) {
      if (!sameStringSet(current.keys, new Set(keys.keys()))) {
        throw new WorkspaceBillingInterestError(
          'generation_payload_mismatch',
          current.generation.toString(),
        );

View on GitHub (pinned to 5be4028344)

Solutions

  1. Read the acceptedGeneration from the error and set the client-side counter to max(local, acceptedGeneration) before retrying.
  2. Serialize interest-set mutations per clientId so concurrent calls cannot interleave generations.
  3. On reconnect, always fetch the current accepted generation from the server before submitting new interests.
  4. Use a single in-flight request guard: do not send a new interest set until the previous one's lease is confirmed.

Example fix

// before — fire-and-forget with no generation sync
client.sendInterests(generation++, keys);

// after — reconcile on stale_generation
try {
  const lease = await runtime.setClientInterests({ clientId, clientGeneration: String(gen), interests });
  gen = BigInt(lease.acceptedGeneration);
} catch (error) {
  if (error instanceof WorkspaceBillingInterestError && error.code === 'stale_generation') {
    gen = BigInt(error.acceptedGeneration!) + 1n;
    await runtime.setClientInterests({ clientId, clientGeneration: String(gen), interests });
  } else throw error;
}
Defensive patterns

Strategy: retry

Type guard

export function isStaleGenerationError(
  error: unknown,
): error is WorkspaceBillingInterestError {
  return (
    error instanceof WorkspaceBillingInterestError &&
    error.code === 'stale_generation'
  );
}

Try / catch

try {
  const lease = await coordinator.setClientInterests({
    clientId,
    clientGeneration: String(generation),
    interests,
  });
} catch (error) {
  if (error instanceof WorkspaceBillingInterestError &&
      error.code === 'stale_generation' &&
      error.acceptedGeneration) {
    // Reconcile: adopt the server's generation, bump, and retry
    generation = BigInt(error.acceptedGeneration) + 1n;
    const lease = await coordinator.setClientInterests({
      clientId,
      clientGeneration: String(generation),
      interests,
    });
  } else throw error;
}

Prevention

When it happens

Trigger: Client A sends generation 3, which succeeds. Client A's local counter is still at 2 (didn't process the ack) and sends generation 2 again. Or two concurrent requests from the same clientId race: the second commit (generation 3) lands first, then the first commit (generation 2) arrives and is rejected as stale.

Common situations: Two browser tabs sharing the same clientId send interest updates concurrently. A reconnect after network loss replays a stale request from an old connection. The client increments its counter locally but the HTTP response is lost, so on retry it sends the same generation.

Related errors


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/55cb3fab1791aa25. Report an issue: GitHub.