2026-05-12 21:51:39 +08:00

167 lines
5.3 KiB
Go

// 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, ",")
}