grpc/grpc-go · error
xds: the xDS client is closed
Error message
xds: the xDS client is closed
What it means
After `XDSClient.Close()` (xdsclient.go:177) fires the internal `done` event, any subsequent attempt to acquire a channel for ADS — via `getChannelForADS` (xdsclient.go:224) — returns this error at line 225-226. It signals that the client has been torn down and can no longer service watches or stream resources. The check is `c.done.HasFired()`.
Source
Thrown at internal/xds/clients/xdsclient/xdsclient.go:226
<-c.serializer.Done()
c.logger.Infof("Shutdown")
}
// getChannelForADS returns an xdsChannel for the given server configuration.
//
// If an xdsChannel exists for the given server configuration, it is returned.
// Else a new one is created. It also ensures that the calling authority is
// added to the set of interested authorities for the returned channel.
//
// It returns the xdsChannel and a function to release the calling authority's
// reference on the channel. The caller must call the cancel function when it is
// no longer interested in this channel.
//
// A non-nil error is returned if an xdsChannel was not created.
func (c *XDSClient) getChannelForADS(serverConfig *ServerConfig, callingAuthority *authority) (*xdsChannel, func(), error) {
if c.done.HasFired() {
return nil, nil, errors.New("xds: the xDS client is closed")
}
initLocked := func(s *channelState) {
if c.logger.V(2) {
c.logger.Infof("Adding authority %q to the set of interested authorities for channel [%p]", callingAuthority.name, s.channel)
}
s.interestedAuthorities[callingAuthority] = true
}
deInitLocked := func(s *channelState) {
if c.logger.V(2) {
c.logger.Infof("Removing authority %q from the set of interested authorities for channel [%p]", callingAuthority.name, s.channel)
}
delete(s.interestedAuthorities, callingAuthority)
}
return c.getOrCreateChannel(serverConfig, initLocked, deInitLocked)
}
View on GitHub (pinned to 0c51461d27)
Solutions
- Coordinate shutdown: cancel all watchers (call the cancel funcs returned by WatchResource) before calling Close(), and ensure no goroutine can call WatchResource after Close.
- Check whether the client is still alive (or wrap Close in a sync.Once gate) before issuing new watches from background goroutines.
- Treat this error as terminal for the relevant goroutine — do not retry against the same closed client.
- Use a context that is canceled together with Close so background loops exit before touching the client.
Example fix
// before
go func() {
// ... periodic re-watch loop
client.WatchResource(typeURL, name, w) // races with Close()
}()
client.Close()
// after
go func() {
select {
case <-ctx.Done(): return // stop before Close()
default:
}
client.WatchResource(typeURL, name, w)
}()
// on shutdown:
cancel()
client.Close() Defensive patterns
Strategy: try-catch
Validate before calling
// Track Close() so background goroutines can stop before touching the client.
type SafeXDSClient struct {
c *xdsclient.XDSClient
closed atomic.Bool
cancel context.CancelFunc
ctx context.Context
}
func (s *SafeXDSClient) Watch(typeURL, name string, w xdsclient.ResourceWatcher) (cancel func()) {
if s.closed.Load() {
return func() {}
}
return s.c.WatchResource(typeURL, name, w)
}
func (s *SafeXDSClient) Close() {
if s.closed.Swap(true) { return }
s.cancel()
s.c.Close()
} Try / catch
// When a watch attempt might race with Close, treat this specific error as terminal.
cancel := client.WatchResource(typeURL, name, w)
// in error-handling paths that see "xds: the xDS client is closed":
if errors.Is(err, errClientClosed) || strings.Contains(err.Error(), "xDS client is closed") {
return // stop the goroutine; do not retry against this client
} Prevention
- Cancel all watchers and stop background goroutines before calling Close().
- Wrap Close() in sync.Once and set an atomic flag background loops can read.
- Bind a context.Context to the client's lifetime so loops exit when the context is canceled.
When it happens
Trigger: Triggered when a caller invokes WatchResource (or any other API that internally calls getChannelForADS) on an XDSClient whose Close() has already been called. This commonly occurs in shutdown races: one goroutine closes the client while another is still trying to register or re-register a watch.
Common situations: Concurrent shutdown where Close() races with watch registration; deferred Close() firing before a late-arriving watcher; a long-lived goroutine that outlives the client and attempts a new watch; a reconnection/retry loop that fires after the user closed the client.
Related errors
- xdsclient: resource types map is nil
- xdsclient: transport builder is nil
- xdsclient: no servers or authorities specified
- could not switch to new child balancer: %w
- balancergroup: already closed
AI-assisted analysis of grpc/grpc-go@0c51461d27 (2026-08-11).
Data as JSON: /api/errors/370021ad77ce354c.
Report an issue: GitHub.