{"record":{"id":"1f65a19dbc620326","repo":"etcd-io/etcd","slug":"unexpected-lease-in-watch","errorCode":null,"errorMessage":"unexpected lease in watch","messagePattern":"unexpected lease in watch","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"error","filePath":"client/v3/op.go","lineNumber":343,"sourceCode":"\t}\n\treturn ret\n}\n\n// OpTxn returns \"txn\" operation based on given transaction conditions.\nfunc OpTxn(cmps []Cmp, thenOps []Op, elseOps []Op) Op {\n\tclonedCmps := make([]Cmp, len(cmps))\n\tfor i := range cmps {\n\t\tclonedCmps[i] = cmps[i].Clone()\n\t}\n\treturn Op{t: tTxn, cmps: clonedCmps, thenOps: thenOps, elseOps: elseOps}\n}\n\nfunc OpWatch(key string, opts ...OpOption) Op {\n\tret := Op{t: tRange, key: []byte(key)}\n\tret.applyOpts(opts)\n\tswitch {\n\tcase ret.leaseID != 0:\n\t\tpanic(\"unexpected lease in watch\")\n\tcase ret.limit != 0:\n\t\tpanic(\"unexpected limit in watch\")\n\tcase ret.sort != nil:\n\t\tpanic(\"unexpected sort in watch\")\n\tcase ret.serializable:\n\t\tpanic(\"unexpected serializable in watch\")\n\tcase ret.countOnly:\n\t\tpanic(\"unexpected countOnly in watch\")\n\tcase ret.minModRev != 0, ret.maxModRev != 0:\n\t\tpanic(\"unexpected mod revision filter in watch\")\n\tcase ret.minCreateRev != 0, ret.maxCreateRev != 0:\n\t\tpanic(\"unexpected create revision filter in watch\")\n\t}\n\treturn ret\n}\n\nfunc (op *Op) applyOpts(opts []OpOption) {\n\tfor _, opt := range opts {","sourceCodeStart":325,"sourceCodeEnd":361,"githubUrl":"https://github.com/etcd-io/etcd/blob/f744d457f484e9f748a0700b48ef96dcf792df33/client/v3/op.go#L325-L361","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Remove WithLease from the watch call; leases belong on OpPut via WithLease(leaseID)","Create/attach the lease on the put side: Grant the lease, then OpPut(key, val, WithLease(id)), then Watch(key) with watch-only options","Split option builders per operation type so lease options cannot leak into watches","Add a small test that constructs every watch your code creates; this panic family will catch regressions"],"exampleFix":"// before\nop := clientv3.OpWatch(\"k\", clientv3.WithLease(sessLease))\n\n// after\nleaseResp, _ := cli.Grant(ctx, 30)\n_ = clientv3.OpPut(\"k\", \"v\", clientv3.WithLease(leaseResp.ID))\nwop := clientv3.OpWatch(\"k\")","handlingStrategy":"validation","validationCode":"// Lease belongs on the write; the watch is separate.\nl, _ := cli.Grant(ctx, 30)\n_ = clientv3.OpPut(\"k\", \"v\", clientv3.WithLease(l.ID))\nwop := clientv3.OpWatch(\"k\", clientv3.WithPrefix()) // no lease","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Attach WithLease only at the put site (or via concurrency session)","Never reuse lease-put option slices for watches","Keep session/lease management in one module"],"tags":["etcd","clientv3","panic","api-misuse","watch","lease"],"backgroundTag":null,"analyzedSha":"f744d457f484e9f748a0700b48ef96dcf792df33","analyzedAt":"2026-08-15T09:39:50.079Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}