t8y2/dbx · error

ETCD_WATCH_NOT_FOUND

ETCD_WATCH_NOT_FOUND

Error message

ETCD_WATCH_NOT_FOUND: watch does not exist

What it means

watchPoll is called with a watchId that has no entry in the session's watches map, so there is nothing to poll. This happens when the watch was never started, already terminated (terminal results remove the watch), or the session was reused after the watch was cleaned up. The library deletes watches from its map once they return a terminal event, making subsequent polls invalid.

Source

Thrown at agents/drivers/etcd2-go/watch.go:379

	if action == "delete" || action == "expire" || action == "compareAndDelete" {
		return "delete"
	}
	return "put"
}

func (w *watchState) hasTerminal() bool {
	w.mu.Lock()
	defer w.mu.Unlock()
	return w.terminalReason != ""
}

func (s *etcd2Session) watchPoll(params map[string]json.RawMessage) (any, error) {
	watchID := stringOrDefault(params, "watchId", "")
	s.watchesMu.Lock()
	state := s.watches[watchID]
	s.watchesMu.Unlock()
	if state == nil {
		return nil, errors.New("ETCD_WATCH_NOT_FOUND: watch does not exist")
	}
	result := state.poll()
	if _, terminal := result["terminal"]; terminal {
		if removed := s.removeWatch(watchID); removed != nil {
			removed.close()
		}
	}
	return result, nil
}

func (s *etcd2Session) watchStop(params map[string]json.RawMessage) (any, error) {
	if state := s.removeWatch(stringOrDefault(params, "watchId", "")); state != nil {
		state.close()
	}
	return map[string]bool{"stopped": true}, nil
}

View on GitHub (pinned to c0390bff16)

Solutions

  1. Treat the watch as ended: re-create it with watchStart and use the fresh watchId.
  2. Stop polling after any poll result containing 'terminal' — the library removes the watch automatically.
  3. Check that the watchId you pass is the exact id returned by watchStart from the same session.
  4. Guard against empty watchId: only poll when the start call succeeded and you persisted its id.

Example fix

// before
res, _ := session.watchPoll(map[string]any{"watchId": cachedID})
// after
res, err := session.watchPoll(map[string]any{"watchId": currentID})
if err != nil {
    currentID = restartWatch(params) // watch no longer exists; recreate it
}
Defensive patterns

Strategy: retry

Validate before calling

func canPoll(id string) error {
    if id == "" { return errors.New("empty watchId") }
    return nil
}

Type guard

func hasWatchID(params map[string]json.RawMessage) bool {
    var id string
    return json.Unmarshal(params["watchId"], &id) == nil && id != ""
}

Try / catch

res, err := session.watchPoll(params)
if err != nil && strings.Contains(err.Error(), "ETCD_WATCH_NOT_FOUND") {
    currentID, err = session.watchStart(watchParams) // recreate and retry
}

Prevention

When it happens

Trigger: Polling a watchId after a previous poll returned a terminal result; polling an id from a different or restarted session; passing an empty watchId (default of stringOrDefault) when the caller never stored the id returned by watchStart; a race where two pollers hit the same watch and one removes it.

Common situations: Client code caching watch ids across reconnects, retry loops that keep polling after the watch ended, and single-shot pollers that do not expect watch lifecycle termination.

Related errors


AI-assisted analysis of t8y2/dbx@c0390bff16 (2026-09-05). Data as JSON: /api/errors/5986b57dd6dcbe84. Report an issue: GitHub.