fix: harden topology sync against transient API server errors at startup#669
fix: harden topology sync against transient API server errors at startup#669AsadShahid04 wants to merge 3 commits into
Conversation
Wrap SynchronizeTopology in a bounded exponential-backoff retry loop (SynchronizeTopologyWithRetry) so that transient Kubernetes API server errors — 5xx, service-unavailable, request-timeout, rate-limiting — no longer cause the Grove operator process to exit at startup. Permanent errors (Forbidden, Unauthorized) are returned immediately without retrying. The default retry budget is six steps with doubling delay (approx. 63 s total), with 10% jitter to reduce thundering-herd load on a recovering control plane. main.go is updated to call SynchronizeTopologyWithRetry with the default backoff. Three unit tests are added to cover: - success after N transient failures - permanent errors not retried (single call only) - transient errors exhaust the retry budget and return the last error Closes ai-dynamo#654 Signed-off-by: OpenClaw Agent <agent@openclaw.local>
uuid must precede wait to satisfy goimports ordering enforced by CI check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: OpenClaw Agent <agent@openclaw.local>
| // backoff so that transient API-server errors (5xx, timeouts, service-unavailable) | ||
| // at operator startup do not crash the operator process. | ||
| // Permanent errors (Forbidden, Unauthorized) are returned immediately without retrying. | ||
| func SynchronizeTopologyWithRetry(ctx context.Context, cl client.Client, logger logr.Logger, backends map[string]scheduler.TopologyAwareBackend, backoff wait.Backoff) error { |
There was a problem hiding this comment.
Nice PR.
(lower priority / optional): SynchronizeTopologyWithRetry puts startup retry/backoff/classification into the clustertopology package, which is otherwise focused on the sync itself. Would it make sense to keep the retry orchestration in main.go or a small startup helper, ideally with a context-aware backoff so it cancels cleanly on SIGTERM? Happy either way — just a thought.
|
|
||
| // isTransientAPIError reports whether err is a transient Kubernetes API server error | ||
| // that is safe to retry. Permanent errors (Forbidden, Unauthorized) return false. | ||
| func isTransientAPIError(err error) bool { |
There was a problem hiding this comment.
Nice work on this
One thing on the transient classification: it currently only matches Kubernetes StatusErrors. But several of the recoverable startup cases in #654 — API server rolling restart, network partition, connection timeout — surface from the Go client as transport-level errors (e.g. connection refused, EOF, TLS handshake timeout, i/o timeout) rather than StatusErrors. Those go through reasonAndCodeForError as (Unknown, 0), so every apierrors.IsXxx returns false and the operator would still exit. Could we extend the predicate to cover transport-level transient errors (net.Error timeout, ECONNREFUSED, io.EOF) and add a unit test for at least one of them?
Several startup failures from issue ai-dynamo#654 (API-server rolling restart, network partition, connection timeout) surface as transport-level errors rather than Kubernetes StatusErrors. The Go client returns these as net.Error timeouts, io.EOF, or syscall.ECONNREFUSED before wrapping them in a StatusError, so all apierrors.IsXxx() calls return false and the operator would not retry them. Add transport-level checks to isTransientAPIError: - net.Error with Timeout() == true (dial/read timeouts) - io.EOF (connection dropped mid-request) - syscall.ECONNREFUSED (API server not yet accepting connections) Also adds TestIsTransientAPIError (table-driven) and TestSynchronizeTopologyWithRetry_TransportErrorRetried to cover the new paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: OpenClaw Agent <agent@openclaw.local>
|
Pushed fc5b7be addressing your feedback: Transport-level error coverage — Extended
These all surface before the client wraps the error as a Added Retry orchestration location — I'll leave |
| // Permanent errors (Forbidden, Unauthorized) are returned immediately without retrying. | ||
| func SynchronizeTopologyWithRetry(ctx context.Context, cl client.Client, logger logr.Logger, backends map[string]scheduler.TopologyAwareBackend, backoff wait.Backoff) error { |
There was a problem hiding this comment.
This Retry functionality will be useful in other places
I would like to make it more genric rather of hand coded wrapping each function
I would go with something more like
// Retrier holds retry configuration. Create one per logical operation or share
// a single instance across all calls with the same policy.
type Retrier struct {
backoff wait.Backoff
isRetryable func(error) bool
onRetry OnRetryFunc
}
// New creates a Retrier. Pass nil for onRetry to skip logging.
func New(backoff wait.Backoff, isRetryable func(error) bool, onRetry OnRetryFunc) *Retrier {
return &Retrier{backoff: backoff, isRetryable: isRetryable, onRetry: onRetry}
}
// Do retries fn using the configured backoff. Use a closure to capture any
// number of arguments — including ctx when it doesn't change between retries:
//
// err := r.Do(func() error {
// return SynchronizeTopology(ctx, cl, logger, backends)
// })
func (r *Retrier) Do(fn func() error) error {
attempt := 0
var lastErr error
return retry.OnError(r.backoff, r.isRetryable, func() error {
if attempt > 0 && r.onRetry != nil {
r.onRetry(attempt, lastErr)
}
attempt++
lastErr = fn()
return lastErr
})
}
----------
var topoRetrier = k8sretry.New(
clustertopology.DefaultSyncRetryBackoff,
k8sErrors.IsTransientAPIError,
func(n int, _ error) {
logger.Info("Retrying topology synchronization", "attempt", n+1)
},
)
err = topoRetrier.Do(func() error {
return clustertopology.SynchronizeTopology(ctx, cl, logger, schedRegistry.AllTopologyAware())
})
Summary
SynchronizeTopologyWithRetrywrappingSynchronizeTopologywith bounded exponential backoffmain.goupdated to call the retry wrapper with a sensible default backoff (~63 s budget)Problem
clustertopology.SynchronizeTopologyis called frommain.gobeforemgr.Start(). Any single transient error — a 5xx from a rolling API server, a webhook pod not yet ready, a brief etcd hiccup — causes the operator to exit viacli.ExitErrSynchronizeTopology. The only recovery path is kubelet restarting the pod, amplifying load on a control plane that is already under stress.See #654 for the full failure-mode analysis.
Solution
Introduce
SynchronizeTopologyWithRetry(ctx, cl, logger, backends, backoff)ininternal/clustertopology/clustertopology.go:k8s.io/client-go/util/retry.OnErrorwithwait.Backoff(already a transitive dependency).isTransientAPIErrorclassifies 5xx, service-unavailable, server-timeout, request-timeout, and 429 as retriable."Retrying topology synchronization after transient error"with the attempt number.main.gois updated to callSynchronizeTopologyWithRetrywithDefaultSyncRetryBackoff.Testing
go build ./...: passedTestSynchronizeTopologyWithRetry_SucceedsAfterTransientError— asserts success after 2 injected 500 errorsTestSynchronizeTopologyWithRetry_PermanentErrorNotRetried— asserts Forbidden stops immediately (1 call only)TestSynchronizeTopologyWithRetry_ExhaustsRetriesOnPersistentTransientError— asserts last transient error returned after budget exhaustedTestSynchronizeTopology*tests continue to pass (no behaviour change to the base function)Closes #654