grpc/grpc-go · critical

The stream server interceptor was already set and may not be

Error message

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

What it means

grpc.StreamInterceptor (server.go:506) installs a single non-chained streaming server interceptor and panics if one is already set (o.streamInt != nil), for the same anti-silent-overwrite reason as UnaryInterceptor. The panic occurs at NewServer construction time when the option function runs.

Source

Thrown at server.go:509

	})
}

// 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 {
	return newFuncServerOption(func(o *serverOptions) {
		if o.streamInt != nil {
			panic("The stream server interceptor was already set and may not be reset.")
		}
		o.streamInt = i
	})
}

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

// InTapHandle returns a ServerOption that sets the tap handle for all the server
// transport to be created. Only one can be installed.
//

View on GitHub (pinned to 03255a9237)

Solutions

  1. Use grpc.ChainStreamInterceptor(a, b, ...) once instead of multiple StreamInterceptor calls.
  2. Deduplicate StreamInterceptor entries across all merged option slices.
  3. Have framework code expose a chaining slot rather than calling StreamInterceptor itself.

Example fix

// before
srv := grpc.NewServer(
    grpc.StreamInterceptor(recoveryStreamInt),
    grpc.StreamInterceptor(metricsStreamInt), // panics: already set
)

// after
srv := grpc.NewServer(
    grpc.ChainStreamInterceptor(recoveryStreamInt, metricsStreamInt),
)
Defensive patterns

Strategy: validation

Validate before calling

// Standardize stream interceptor assembly through chaining
streamInts := []grpc.StreamServerInterceptor{recoveryInt, metricsInt}
opts := []grpc.ServerOption{grpc.ChainStreamInterceptor(streamInts...)}
srv := grpc.NewServer(opts...)

Prevention

When it happens

Trigger: Passing grpc.StreamInterceptor(...) more than once in NewServer's options, or merging option slices that each contain a StreamInterceptor.

Common situations: A base option set (from a shared server builder) already registers a stream interceptor and the caller adds another; refactoring interceptors without removing the old single-interceptor registration.

Related errors


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