etcd-io/etcd · error

unexpected lease in watch

Error message

unexpected lease in watch

What it means

OpWatch panics with 'unexpected lease in watch' when WithLease is applied to a watch. Attaching a lease to a key is a write-time concept (the key expires with the lease); a watch cannot carry a lease, so OpWatch rejects the option at construction time. The panic occurs synchronously in OpWatch, before any gRPC stream is opened.

Source

Thrown at client/v3/op.go:343

	}
	return ret
}

// OpTxn returns "txn" operation based on given transaction conditions.
func OpTxn(cmps []Cmp, thenOps []Op, elseOps []Op) Op {
	clonedCmps := make([]Cmp, len(cmps))
	for i := range cmps {
		clonedCmps[i] = cmps[i].Clone()
	}
	return Op{t: tTxn, cmps: clonedCmps, thenOps: thenOps, elseOps: elseOps}
}

func OpWatch(key string, opts ...OpOption) Op {
	ret := Op{t: tRange, key: []byte(key)}
	ret.applyOpts(opts)
	switch {
	case ret.leaseID != 0:
		panic("unexpected lease in watch")
	case ret.limit != 0:
		panic("unexpected limit in watch")
	case ret.sort != nil:
		panic("unexpected sort in watch")
	case ret.serializable:
		panic("unexpected serializable in watch")
	case ret.countOnly:
		panic("unexpected countOnly in watch")
	case ret.minModRev != 0, ret.maxModRev != 0:
		panic("unexpected mod revision filter in watch")
	case ret.minCreateRev != 0, ret.maxCreateRev != 0:
		panic("unexpected create revision filter in watch")
	}
	return ret
}

func (op *Op) applyOpts(opts []OpOption) {
	for _, opt := range opts {

View on GitHub (pinned to f744d457f4)

Solutions

  1. Remove WithLease from the watch call; leases belong on OpPut via WithLease(leaseID)
  2. Create/attach the lease on the put side: Grant the lease, then OpPut(key, val, WithLease(id)), then Watch(key) with watch-only options
  3. Split option builders per operation type so lease options cannot leak into watches
  4. Add a small test that constructs every watch your code creates; this panic family will catch regressions

Example fix

// before
op := clientv3.OpWatch("k", clientv3.WithLease(sessLease))

// after
leaseResp, _ := cli.Grant(ctx, 30)
_ = clientv3.OpPut("k", "v", clientv3.WithLease(leaseResp.ID))
wop := clientv3.OpWatch("k")
Defensive patterns

Strategy: validation

Validate before calling

// Lease belongs on the write; the watch is separate.
l, _ := cli.Grant(ctx, 30)
_ = clientv3.OpPut("k", "v", clientv3.WithLease(l.ID))
wop := clientv3.OpWatch("k", clientv3.WithPrefix()) // no lease

Prevention

When it happens

Trigger: Calling clientv3.OpWatch(key, clientv3.WithLease(id)) or passing WithLease in the options of an op later converted to a watch request. Common when a helper that attaches a lease to written keys is reused for the watch that observes those keys.

Common situations: Session/lease-based code (e.g. services using WithLease for ephemeral keys via concurrency package) that also watches those keys and shares option assembly; copy-paste between put and watch setup; feature flags that swap a put for a watch without pruning options.

Related errors


AI-assisted analysis of etcd-io/etcd@f744d457f4 (2026-08-15). Data as JSON: /api/errors/1f65a19dbc620326. Report an issue: GitHub.