grpc/grpc-go · error
balancergroup: already closed
Error message
balancergroup: already closed
What it means
Returned by BalancerGroup.AddWithClientConn when bg.outgoingClosed is true (balancergroup.go:283-285). Once Close() sets outgoingClosed (balancergroup.go:554), the BalancerGroup refuses to accept any new sub-balancers. This is a lifecycle guard to prevent adding children to a shut-down group.
Source
Thrown at internal/balancergroup/balancergroup.go:284
// AddWithClientConn adds a balancer with the given id to the group. The
// balancer is built with a balancer builder registered with balancerName. The
// given ClientConn is passed to the newly built balancer instead of the
// one passed to balancergroup.New().
//
// TODO: Get rid of the existing Add() API and replace it with this.
func (bg *BalancerGroup) AddWithClientConn(id, balancerName string, cc balancer.ClientConn) error {
bg.logger.Infof("Adding child policy of type %q for child %q", balancerName, id)
builder := balancer.Get(balancerName)
if builder == nil {
return fmt.Errorf("balancergroup: unregistered balancer name %q", balancerName)
}
// Store data in static map, and then check to see if bg is started.
bg.outgoingMu.Lock()
defer bg.outgoingMu.Unlock()
if bg.outgoingClosed {
return fmt.Errorf("balancergroup: already closed")
}
var sbc *subBalancerWrapper
// Skip searching the cache if disabled.
if bg.deletedBalancerCache != nil {
if old, ok := bg.deletedBalancerCache.Remove(id); ok {
if bg.logger.V(2) {
bg.logger.Infof("Removing and reusing child policy of type %q for child %q from the balancer cache", balancerName, id)
bg.logger.Infof("Number of items remaining in the balancer cache: %d", bg.deletedBalancerCache.Len())
}
sbc, _ = old.(*subBalancerWrapper)
if sbc != nil && sbc.builder != builder {
// If the sub-balancer in cache was built with a different
// balancer builder, don't use it, cleanup this old-balancer,
// and behave as sub-balancer is not found in cache.
//
// NOTE that this will also drop the cached addresses for this
// sub-balancer, which seems to be reasonable.View on GitHub (pinned to 0c51461d27)
Solutions
- Fix the lifecycle ordering so Close() is not called until all Add() operations are complete
- Guard against this error in concurrent code by checking the return value and treating it as expected during shutdown
- Synchronize the parent balancer's shutdown with in-flight config updates
Example fix
// before: race between close and add
// goroutine 1:
bg.Close()
// goroutine 2 (late config update):
bg.Add("child-1", builder) // error: already closed
// after: synchronize lifecycle
var wg sync.WaitGroup
wg.Add(1)
// goroutine 2 completes adds before close
go func() { defer wg.Done(); bg.Add("child-1", builder) }()
wg.Wait()
bg.Close() Defensive patterns
Strategy: validation
Try / catch
// Treat 'already closed' as expected during shutdown
if err := bg.AddWithClientConn(id, name, cc); err != nil {
if strings.Contains(err.Error(), "already closed") {
return // expected during teardown
}
return err
} Prevention
- Synchronize Close() with Add/AddWithClientConn calls using a mutex or channel
- In concurrent balancer implementations, treat 'already closed' as benign during teardown
- Establish a clear shutdown sequence: stop accepting config updates before calling Close()
When it happens
Trigger: Calling bg.Add() or bg.AddWithClientConn() after bg.Close() has been invoked. The Close() method sets outgoingClosed = true under outgoingMu (balancergroup.go:551-555), and AddWithClientConn checks this flag right after acquiring the same lock.
Common situations: A lifecycle race in the parent balancer where the group is closed (e.g., the parent LB policy is shutting down due to channel close) but a late resolver config update triggers a new Add. Common in priority or cluster resolver balancers during teardown.
Related errors
- could not switch to new child balancer: %w
- xds: the xDS client is closed
- balancergroup: unregistered balancer name %q
- provider instance is closed
- grpc: the server has been stopped
AI-assisted analysis of grpc/grpc-go@0c51461d27 (2026-08-11).
Data as JSON: /api/errors/96b21ac295f9b359.
Report an issue: GitHub.