Watch for changes
Watch does the same fail-fast initial load as Load, then keeps the config reconciled in the background. It returns once the initial config is resolved; OnChange fires only on later changes. Read the current config any time with Get().
w, err := mamori.Watch[Config](ctx,
mamori.WithPollInterval(30*time.Second),
mamori.OnChange(func(ev mamori.Change[Config]) {
if ev.Changed("DBPassword") {
pool.Rotate(ev.New.DBPassword.Reveal())
}
for _, f := range ev.Fields {
log.Printf("%s changed: %s -> %s", f.Path, f.OldVersion, f.NewVersion)
}
}),
mamori.OnError(func(err error) { metrics.Inc("config_error") }),
)
if err != nil {
log.Fatal(err)
}
defer w.Close()
cfg := w.Get() // lock-free atomic snapshot; always the last valid config
Read the current config with Get
Get() returns a lock-free atomic snapshot of the last valid config. It is safe to call from any goroutine on every request; there is no need to cache the result.
React to a field with Change and Changed
OnChange receives a Change[T] carrying Old and New full snapshots plus Fields []FieldChange{Path, OldVersion, NewVersion}. Use Changed(path string) bool to react to one field:
A WithDerive-declared field appears in Fields like any other when its rebuilt value changes. Its versions are content hashes of the value rather than provider revisions, since it has no ref.
mamori.OnChange(func(ev mamori.Change[Config]) {
if ev.Changed("DBPassword") {
pool.Rotate(ev.New.DBPassword.Reveal())
}
})
Handle errors with OnError
OnError receives every runtime failure without stopping the watcher. Use it for metrics and alerting; Get() keeps serving the last good config.
Five typed errors reach it, and they mean different things, so classify rather than counting them together: *ProviderError (a ref failed to resolve), *ValidationError (the candidate failed validate: tags), *DeriveError (a WithDerive hook returned an error), *PreApplyError (a PreApply gate refused the candidate, including on timeout), and *StaleError (a ref went unrefreshed past WithStale).
mamori.OnError(func(err error) {
var verr *mamori.ValidationError
var derr *mamori.DeriveError
switch {
case errors.As(err, &verr):
metrics.Inc("config_validation_error")
case errors.As(err, &derr):
metrics.Inc("config_derive_error")
default:
metrics.Inc("config_error")
}
})
The middle three all mean the same thing operationally: a candidate was built and rejected, so the config you are serving is older than the backend’s. *ProviderError means a resolve failed. *StaleError means a ref has gone too long without a fresh value, not that it never had one: the last good value is still being served, which is exactly why the age is worth alerting on.
What you can rely on
These behaviors are guaranteed and covered by the conformance kit.
- Validated, all-or-nothing updates.
OnChangefires with a fully re-validated snapshot. If a new value fails validation the update is rejected:Get()keeps returning the last good config andOnErrorreceives a*ValidationError. - A gate before the swap, if you install one. A candidate is built, then any
WithDerivehooks run, then it is validated, then - ifPreApplywas installed - handed to it for a check that needs I/O (a rotated password actually opens a connection). Only after that gate passes does the atomic swap happen andOnChangefire. See Rotation safety and Derived fields. - OnChange is called one at a time. Callbacks are serialized, so your callback never runs concurrently with itself. The dispatch queue absorbs bursts, so a slow callback just delays the next event; only once a handler stays slower than the change rate for long enough to fill the queue does it overflow, and the oldest queued event is dropped. See Tuning the dispatch queue below.
- Coalesced events. Field changes within a debounce window (default 500ms, override per field with
?debounce=) produce a singleChange. A JSON secret with five keys rotating is one event, not five. See the Options reference for this and every other tuning knob’s default. - Last-good on failure. On a runtime resolve failure the last-good value is retained,
OnErrorreceives a*ProviderError, and the ref keeps being retried - on the poll interval by default, or with per-ref exponential backoff if you opt into it withWithBackoff.WithStale(maxAge)escalates prolonged staleness to a hard*StaleError. - Clean shutdown.
Close()cancels provider watches, drains the callback queue, and returns. OnChangeis type-checked againstWatch’s ownT.OnChange[T]has to match theTpassed toWatch[T]/Load[T]; a mismatch (a type alias, a renamed config struct, a generic helper passing the wrong type through) failsWatch/Loadoutright with an error wrappingErrInvalidthat names both types, rather than compiling clean and then never calling the callback.
Retry backoff
A polled ref that fails to resolve is retried on the poll interval. WithBackoff(base, max) replaces that cadence with per-ref exponential backoff for as long as the ref keeps failing: the first retry is delayed by base, each further consecutive failure doubles the delay, and it holds at max. Any successful round trip with the backend resets it and the ref returns to the normal poll interval.
w, err := mamori.Watch[Config](ctx,
mamori.WithPollInterval(30*time.Second),
mamori.WithBackoff(30*time.Second, 5*time.Minute),
)
Backoff is off by default. Without WithBackoff a failing ref is retried on WithPollInterval, unchanged. Turn it on deliberately, and note that a base below your poll interval makes a just-failed backend get hit sooner than a healthy one - usually you want base at or above the poll interval, as above.
Three things to know before choosing a window:
- It does not reach providers with a native watch. A push provider - Kubernetes informers, Consul blocking queries, Postgres
LISTEN/NOTIFY, themamori://SSE client - owns its own stream and its own reconnect cadence. mamori polls nothing on its behalf, so there is no attempt forWithBackoffto delay; reconnect behavior is provider-internal and documented on each provider’s page. The one exception is a native watch that fails to start: mamori falls back to polling for that ref, and backoff governs it from then on.WithBackoffis a polling knob, and setting it does not make a push provider back off. - A not-found is not a failure.
ErrNotFoundmeans the backend answered and the ref is absent - ordinarydefault:/optional:territory. It ends a backoff streak rather than extending one, so a ref you provision after the process starts is still picked up on the normal poll interval. - It interacts with
WithStale. The*StaleErrorhanded toOnErroris escalated on the first failed attempt aftermaxAgeelapses, and backoff is what pushes that attempt out - so a largemaxdelays that callback by up to one backoff step.Status()andHealth()are unaffected: they recomputeAgeandStaleat read time from the last success, so a readiness probe still flips at exactlymaxAge. Keepmaxwell under yourWithStalethreshold if theOnErrortiming matters.
Jitter applies to backoff too. WithJitter randomizes each backoff delay by the same fraction it randomizes the poll interval, which matters more here than it does for ordinary polling: a shared backend failing synchronizes every client’s failure instant, and un-jittered backoff would have the whole fleet retry in lockstep against a backend that is already unhealthy.
Tuning the dispatch queue
OnChange dispatch goes through a bounded queue. WithQueueDepth(n) sets its depth (default 16); when a slow consumer fills it, the oldest queued event is dropped rather than blocking the reconciler. Because each Change carries full Old / New snapshots, a dropped notification never leaves Get() wrong.
See also
- Source chains for comma-separated precedence and
onfail. - Snapshots and pinning for
Status, history, and pinning. - Observability for
Status,Health, and the HTTP surface.