t8y2/dbx · error

ETCD_CAS_CONFLICT

ETCD_CAS_CONFLICT

Error message

ETCD_CAS_CONFLICT: key changed after it was loaded

What it means

The etcd2 driver performs compare-and-swap writes by sending prevIndex/prevExist parameters to the etcd v2 HTTP API. When etcd rejects the write because the key's modifiedIndex no longer matches what the caller loaded, the client maps the 412 Precondition Failed response to this error. It signals a lost optimistic-concurrency race: another writer modified the key between your read and your put.

Source

Thrown at agents/drivers/etcd2-go/kv.go:232

	form := url.Values{}
	form.Set("value", value)
	if hasTtl {
		form.Set("ttl", strconv.FormatInt(*ttlValue, 10))
	}
	if expectedModRevision != nil {
		form.Set("prevIndex", strconv.FormatInt(*expectedModRevision, 10))
	}
	if expectedCreateRevision != nil && *expectedCreateRevision == 0 {
		form.Set("prevExist", "false")
	}

	ctx, cancel := s.beginOperation()
	defer s.endOperation(cancel)
	body, _, err := client.do(ctx, http.MethodPut, v2KeyPath(key), form.Encode(), nil)
	if err != nil {
		if isCompareFailed(err) {
			return nil, errors.New("ETCD_CAS_CONFLICT: key changed after it was loaded")
		}
		return nil, err
	}
	var parsed v2KeysResponse
	if err := json.Unmarshal(body, &parsed); err != nil {
		return nil, err
	}
	var revision int64
	if parsed.Node != nil {
		revision = parsed.Node.ModifiedIndex
	}
	return map[string]any{"revision": longString(revision)}, nil
}

func (s *etcd2Session) delete(params map[string]json.RawMessage) (any, error) {
	client, err := s.activeClient()
	if err != nil {
		return nil, err

View on GitHub (pinned to c0390bff16)

Solutions

  1. Re-read the key to get its current modRevision, apply your change to the fresh value, and retry the put with the new expectedModRevision.
  2. If the write is create-only (expectedCreateRevision=0 / prevExist=false), first GET the key to check whether it already exists, or accept the conflict and treat the key as existing.
  3. Reduce the read-modify-write window or serialize writes for that key through a single writer (queue/leader).
  4. Check your expectedModRevision source — you may be caching an old revision; always take it from the most recent get result.

Example fix

// before
result, err := agent.Put(ctx, key, newValue, WithExpectedModRevision(staleRev))
// after
rev, err := refreshRevision(agent, key) // re-GET the key, recompute value from fresh state
if err != nil { return err }
result, err := agent.Put(ctx, key, recompute(newValue), WithExpectedModRevision(rev))
Defensive patterns

Strategy: retry

Validate before calling

node, err := agent.Get(ctx, key)
if err != nil { return err }
if node.ModRevision == 0 { return errors.New("key has no revision yet") }
// use node.ModRevision as expectedModRevision immediately after this read

Type guard

func isCasConflict(err error) bool {
    return err != nil && strings.Contains(err.Error(), "ETCD_CAS_CONFLICT")
}

Try / catch

err := agent.Put(ctx, key, val, WithExpectedModRevision(rev))
if isCasConflict(err) {
    node, gerr := agent.Get(ctx, key)
    if gerr != nil { return gerr }
    rev = node.ModRevision
    // recompute value from fresh state and retry, with backoff/attempt cap
}

Prevention

When it happens

Trigger: Calling put with expectedModRevision (or prevExist=false for create-only) while a concurrent client modified or created the key, causing etcd to return a 412 compare-failed response.

Common situations: Two workers read the same config key and both try to write it back; a leader-election loop where multiple instances race to update a lock value; a stale client holding an old revision after a network retry; automated scripts and manual etcdctl edits hitting the same key.

Related errors


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