Skip to content

transport/grpc: streamClientInterceptor does not apply WithTimeout, unlike unaryClientInterceptor #3833

Description

@akpradheeph

Summary

transport/grpc/client.go has an asymmetry between unary and streaming client interceptors:

// dial() — timeout passed to unary, but NOT to streaming
ints  := []grpc.UnaryClientInterceptor{
    unaryClientInterceptor(options.middleware, options.timeout, options.filters),  // ✅ timeout applied
}
sints := []grpc.StreamClientInterceptor{
    streamClientInterceptor(options.streamMiddleware, options.filters),            // ❌ timeout ignored
}

The streamClientInterceptor signature does not accept a timeout parameter, and the configured WithTimeout value is silently dropped for all streaming RPCs. As a result, streaming calls run indefinitely — bounded only by any deadline already present on the incoming context, not by the client-configured timeout.

Impact

Any service using streaming RPCs (bidirectional, server-streaming, client-streaming) via a go-kratos gRPC client will NOT have the WithTimeout deadline applied. The default unary timeout (2000ms) is effectively ignored for streams.

This becomes critical when a gRPC proxy (e.g. Easegress, Envoy) sits between the client and backend. If the proxy has a connection pool degradation issue — causing it to hold the stream open waiting for a backend connection — the streaming call has no bounded timeout and will hang until the parent context expires or the process is restarted.

In contrast, the same call as a unary RPC would be cancelled after WithTimeout milliseconds.

Current behaviour

func streamClientInterceptor(ms []middleware.Middleware, filters []selector.NodeFilter) grpc.StreamClientInterceptor {
    return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string,
        streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
        // No timeout applied — ctx used as-is
        ...
        clientStream, err := streamer(ctx, desc, cc, method, opts...)
        ...
    }
}

Expected behaviour

Streaming calls should respect WithTimeout the same way unary calls do:

func streamClientInterceptor(ms []middleware.Middleware, timeout time.Duration, filters []selector.NodeFilter) grpc.StreamClientInterceptor {
    return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string,
        streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
        if timeout > 0 {
            var cancel context.CancelFunc
            ctx, cancel = context.WithTimeout(ctx, timeout)
            defer cancel()
        }
        ...
        clientStream, err := streamer(ctx, desc, cc, method, opts...)
        ...
    }
}

And dial() updated to pass the timeout:

sints := []grpc.StreamClientInterceptor{
    streamClientInterceptor(options.streamMiddleware, options.timeout, options.filters),
}

Fix

A PR is available: #[to be linked]

go-kratos version

v2.9.1 (confirmed in transport/grpc/client.go). The gap exists from the initial streaming interceptor implementation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions