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

  1. Fix the lifecycle ordering so Close() is not called until all Add() operations are complete
  2. Guard against this error in concurrent code by checking the return value and treating it as expected during shutdown
  3. 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

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


AI-assisted analysis of grpc/grpc-go@0c51461d27 (2026-08-11). Data as JSON: /api/errors/96b21ac295f9b359. Report an issue: GitHub.