// Package grpcclient provides one production dial policy for internal gRPC // clients. The policy keeps transport behavior out of business handlers. package grpcclient import ( "context" "fmt" "strings" "time" "google.golang.org/grpc" "google.golang.org/grpc/backoff" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/keepalive" ) // Config controls connection backoff, keepalive, retry and per-RPC deadlines. type Config struct { DefaultTimeout time.Duration ConnectTimeout time.Duration KeepaliveTime time.Duration KeepaliveTimeout time.Duration RetryMaxAttempts int RetryInitialBackoff time.Duration RetryMaxBackoff time.Duration RetryBackoffMultiplier float64 RetryableStatusCodes []string } // DefaultConfig is conservative: retries are limited to one retry on // UNAVAILABLE and must complete inside the caller's deadline. func DefaultConfig() Config { return Config{ DefaultTimeout: 5 * time.Second, ConnectTimeout: 2 * time.Second, KeepaliveTime: 30 * time.Second, KeepaliveTimeout: 10 * time.Second, RetryMaxAttempts: 2, RetryInitialBackoff: 100 * time.Millisecond, RetryMaxBackoff: 500 * time.Millisecond, RetryBackoffMultiplier: 2, RetryableStatusCodes: []string{"UNAVAILABLE"}, } } // Normalize fills safe transport defaults and clamps retry attempts to grpc-go's // supported range. A value below two disables explicit retry. func Normalize(cfg Config) Config { defaults := DefaultConfig() if cfg.DefaultTimeout <= 0 { cfg.DefaultTimeout = defaults.DefaultTimeout } if cfg.ConnectTimeout <= 0 { cfg.ConnectTimeout = defaults.ConnectTimeout } if cfg.KeepaliveTime <= 0 { cfg.KeepaliveTime = defaults.KeepaliveTime } if cfg.KeepaliveTimeout <= 0 { cfg.KeepaliveTimeout = defaults.KeepaliveTimeout } if cfg.RetryMaxAttempts < 0 { cfg.RetryMaxAttempts = 0 } if cfg.RetryMaxAttempts == 0 { cfg.RetryMaxAttempts = defaults.RetryMaxAttempts } if cfg.RetryMaxAttempts > 5 { cfg.RetryMaxAttempts = 5 } if cfg.RetryInitialBackoff <= 0 { cfg.RetryInitialBackoff = defaults.RetryInitialBackoff } if cfg.RetryMaxBackoff <= 0 { cfg.RetryMaxBackoff = defaults.RetryMaxBackoff } if cfg.RetryMaxBackoff < cfg.RetryInitialBackoff { cfg.RetryMaxBackoff = cfg.RetryInitialBackoff } if cfg.RetryBackoffMultiplier <= 0 { cfg.RetryBackoffMultiplier = defaults.RetryBackoffMultiplier } if len(cfg.RetryableStatusCodes) == 0 { cfg.RetryableStatusCodes = defaults.RetryableStatusCodes } return cfg } // Dial creates a ClientConn with deadlines, TCP reconnect backoff, keepalive and // a minimal retry policy. Startup does not block on the remote endpoint; ready // probes are responsible for proving the connection can become READY. func Dial(target string, cfg Config, opts ...grpc.DialOption) (*grpc.ClientConn, error) { cfg = Normalize(cfg) dialOptions := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithConnectParams(grpc.ConnectParams{ Backoff: backoff.Config{ BaseDelay: 100 * time.Millisecond, Multiplier: 1.6, Jitter: 0.2, MaxDelay: 5 * time.Second, }, MinConnectTimeout: cfg.ConnectTimeout, }), grpc.WithKeepaliveParams(keepalive.ClientParameters{ Time: cfg.KeepaliveTime, Timeout: cfg.KeepaliveTimeout, PermitWithoutStream: true, }), grpc.WithUnaryInterceptor(timeoutUnaryInterceptor(cfg.DefaultTimeout)), } if serviceConfig := defaultServiceConfig(cfg); serviceConfig != "" { // The retry budget is intentionally transport-level and narrow. Business // handlers still rely on command IDs/idempotency for mutation safety. dialOptions = append(dialOptions, grpc.WithDefaultServiceConfig(serviceConfig)) } dialOptions = append(dialOptions, opts...) return grpc.Dial(strings.TrimSpace(target), dialOptions...) } func timeoutUnaryInterceptor(timeout time.Duration) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req any, reply any, conn *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { if timeout > 0 { if _, ok := ctx.Deadline(); !ok { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, timeout) defer cancel() } } return invoker(ctx, method, req, reply, conn, opts...) } } func defaultServiceConfig(cfg Config) string { if cfg.RetryMaxAttempts < 2 { return `{"methodConfig":[{"name":[{}],"waitForReady":true}]}` } return fmt.Sprintf( `{"methodConfig":[{"name":[{}],"waitForReady":true,"retryPolicy":{"maxAttempts":%d,"initialBackoff":"%s","maxBackoff":"%s","backoffMultiplier":%.2f,"retryableStatusCodes":[%s]}}]}`, cfg.RetryMaxAttempts, durationString(cfg.RetryInitialBackoff), durationString(cfg.RetryMaxBackoff), cfg.RetryBackoffMultiplier, statusCodeList(cfg.RetryableStatusCodes), ) } func durationString(value time.Duration) string { return fmt.Sprintf("%.3fs", value.Seconds()) } func statusCodeList(codes []string) string { quoted := make([]string, 0, len(codes)) for _, code := range codes { code = strings.ToUpper(strings.TrimSpace(code)) if code == "" { continue } quoted = append(quoted, fmt.Sprintf("%q", code)) } if len(quoted) == 0 { return `"UNAVAILABLE"` } return strings.Join(quoted, ",") }