167 lines
5.3 KiB
Go
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, ",")
|
|
}
|