grpc/grpc-go · critical

The unary server interceptor was already set and may not be

Error message

The unary server interceptor was already set and may not be reset.

What it means

grpc.UnaryInterceptor (server.go:485) installs a single non-chained unary server interceptor. gRPC forbids setting it twice because a second call would silently overwrite the first (a security/observability footgun), so it panics if o.unaryInt is already non-nil. This option is applied during server construction, so the panic happens at NewServer time.

Source

Thrown at server.go:488

	return newFuncServerOption(func(o *serverOptions) {
		o.maxConcurrentStreams = n
	})
}

// Creds returns a ServerOption that sets credentials for server connections.
func Creds(c credentials.TransportCredentials) ServerOption {
	return newFuncServerOption(func(o *serverOptions) {
		o.creds = c
	})
}

// UnaryInterceptor returns a ServerOption that sets the UnaryServerInterceptor for the
// server. Only one unary interceptor can be installed. The construction of multiple
// interceptors (e.g., chaining) can be implemented at the caller.
func UnaryInterceptor(i UnaryServerInterceptor) ServerOption {
	return newFuncServerOption(func(o *serverOptions) {
		if o.unaryInt != nil {
			panic("The unary server interceptor was already set and may not be reset.")
		}
		o.unaryInt = i
	})
}

// ChainUnaryInterceptor returns a ServerOption that specifies the chained interceptor
// for unary RPCs. The first interceptor will be the outer most,
// while the last interceptor will be the inner most wrapper around the real call.
// All unary interceptors added by this method will be chained.
func ChainUnaryInterceptor(interceptors ...UnaryServerInterceptor) ServerOption {
	return newFuncServerOption(func(o *serverOptions) {
		o.chainUnaryInts = append(o.chainUnaryInts, interceptors...)
	})
}

// StreamInterceptor returns a ServerOption that sets the StreamServerInterceptor for the
// server. Only one stream interceptor can be installed.
func StreamInterceptor(i StreamServerInterceptor) ServerOption {

View on GitHub (pinned to 03255a9237)

Solutions

  1. Replace multiple grpc.UnaryInterceptor calls with a single grpc.ChainUnaryInterceptor(a, b, ...) which supports many interceptors.
  2. Search the combined option slice passed to NewServer for duplicate UnaryInterceptor entries and keep only one.
  3. If a framework injects one, disable its interceptor or use its chaining hook instead of adding your own UnaryInterceptor.

Example fix

// before
srv := grpc.NewServer(
    grpc.UnaryInterceptor(loggingInterceptor),
    grpc.UnaryInterceptor(authInterceptor), // panics: already set
)

// after
srv := grpc.NewServer(
    grpc.ChainUnaryInterceptor(loggingInterceptor, authInterceptor),
)
Defensive patterns

Strategy: validation

Validate before calling

// Detect duplicate UnaryInterceptor in assembled options before NewServer
func hasUnaryInterceptor(opts []grpc.ServerOption) bool {
    // grpc.ServerOption is opaque; instead, manage interceptors through a
    // single ChainUnaryInterceptor so duplicates are structurally impossible.
    return false
}
// Prefer: collect interceptors in a slice, register once via ChainUnaryInterceptor.

Prevention

When it happens

Trigger: Passing grpc.UnaryInterceptor(...) twice in the same NewServer(opts...) call, or a helper that bundles a UnaryInterceptor option being combined with an explicit UnaryInterceptor at the call site, so that o.unaryInt is set on the second invocation.

Common situations: A shared option list already contains a unary interceptor (e.g. from a framework) and application code adds another; migrating from single to chained interceptors but forgetting to remove the old UnaryInterceptor call; merging two option slices that each carry one.

Related errors


AI-assisted analysis of grpc/grpc-go@03255a9237 (2026-08-07). Data as JSON: /api/errors/bc8df2e38a2ff546. Report an issue: GitHub.