diff --git a/Makefile b/Makefile index 3683a60..cbbc576 100644 --- a/Makefile +++ b/Makefile @@ -4,9 +4,10 @@ build: test: go test -race ./... + cd examples && go test -race ./... doc: - go tool gomarkdoc --output '{{.Dir}}/README.md' ./... + go tool gomarkdoc --exclude-dirs ./examples --output '{{.Dir}}/README.md' ./... lint: go tool golangci-lint run diff --git a/README.md b/README.md index 9e900b0..e07764b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Interceptor configuration functions \(AddUnaryServerInterceptor, SetFilterFunc, - [func DefaultStreamInterceptors\(\) \[\]grpc.StreamServerInterceptor](<#DefaultStreamInterceptors>) - [func DefaultTimeoutInterceptor\(\) grpc.UnaryServerInterceptor](<#DefaultTimeoutInterceptor>) - [func DoHTTPtoGRPC\(ctx context.Context, svr any, handler func\(ctx context.Context, req any\) \(any, error\), in any\) \(any, error\)](<#DoHTTPtoGRPC>) +- [func ExecutorClientInterceptor\(defaultOpts ...grpc.CallOption\) grpc.UnaryClientInterceptor](<#ExecutorClientInterceptor>) - [func FilterMethodsFunc\(ctx context.Context, fullMethodName string\) bool](<#FilterMethodsFunc>) - [func GRPCClientInterceptor\(\_ ...any\) grpc.UnaryClientInterceptor](<#GRPCClientInterceptor>) - [func GetDebugLogHeaderName\(\) string](<#GetDebugLogHeaderName>) @@ -53,6 +54,7 @@ Interceptor configuration functions \(AddUnaryServerInterceptor, SetFilterFunc, - [func ServerErrorStreamInterceptor\(\) grpc.StreamServerInterceptor](<#ServerErrorStreamInterceptor>) - [func SetClientMetricsOptions\(opts ...grpcprom.ClientMetricsOption\)](<#SetClientMetricsOptions>) - [func SetDebugLogHeaderName\(name string\)](<#SetDebugLogHeaderName>) +- [func SetDefaultExecutor\(e Executor\)](<#SetDefaultExecutor>) - [func SetDefaultRateLimit\(rps float64, burst int\)](<#SetDefaultRateLimit>) - [func SetDefaultTimeout\(d time.Duration\)](<#SetDefaultTimeout>) - [func SetDisableDebugLogInterceptor\(disable bool\)](<#SetDisableDebugLogInterceptor>) @@ -68,6 +70,7 @@ Interceptor configuration functions \(AddUnaryServerInterceptor, SetFilterFunc, - [func TraceIdInterceptor\(\) grpc.UnaryServerInterceptor](<#TraceIdInterceptor>) - [func UseColdBrewClientInterceptors\(ctx context.Context, flag bool\)](<#UseColdBrewClientInterceptors>) - [func UseColdBrewServerInterceptors\(ctx context.Context, flag bool\)](<#UseColdBrewServerInterceptors>) +- [type Executor](<#Executor>) - [type FilterFunc](<#FilterFunc>) @@ -94,7 +97,7 @@ var ( ``` -## func [AddStreamClientInterceptor]() +## func [AddStreamClientInterceptor]() ```go func AddStreamClientInterceptor(ctx context.Context, i ...grpc.StreamClientInterceptor) @@ -103,7 +106,7 @@ func AddStreamClientInterceptor(ctx context.Context, i ...grpc.StreamClientInter AddStreamClientInterceptor adds a client stream interceptor to default client stream interceptors. Must be called during initialization, before any RPCs are made. Not safe for concurrent use. -## func [AddStreamServerInterceptor]() +## func [AddStreamServerInterceptor]() ```go func AddStreamServerInterceptor(ctx context.Context, i ...grpc.StreamServerInterceptor) @@ -112,7 +115,7 @@ func AddStreamServerInterceptor(ctx context.Context, i ...grpc.StreamServerInter AddStreamServerInterceptor adds a server interceptor to default server interceptors. Must be called during initialization, before the server starts. Not safe for concurrent use. -## func [AddUnaryClientInterceptor]() +## func [AddUnaryClientInterceptor]() ```go func AddUnaryClientInterceptor(ctx context.Context, i ...grpc.UnaryClientInterceptor) @@ -121,7 +124,7 @@ func AddUnaryClientInterceptor(ctx context.Context, i ...grpc.UnaryClientInterce AddUnaryClientInterceptor adds a client interceptor to default client interceptors. Must be called during initialization, before any RPCs are made. Not safe for concurrent use. -## func [AddUnaryServerInterceptor]() +## func [AddUnaryServerInterceptor]() ```go func AddUnaryServerInterceptor(ctx context.Context, i ...grpc.UnaryServerInterceptor) @@ -153,7 +156,7 @@ func DebugLoggingInterceptor() grpc.UnaryServerInterceptor DebugLoggingInterceptor is the interceptor that logs all request/response from a handler -## func [DefaultClientInterceptor]() +## func [DefaultClientInterceptor]() ```go func DefaultClientInterceptor(defaultOpts ...any) grpc.UnaryClientInterceptor @@ -171,7 +174,7 @@ func DefaultClientInterceptors(defaultOpts ...any) []grpc.UnaryClientInterceptor DefaultClientInterceptors are the set of default interceptors that should be applied to all client calls -## func [DefaultClientStreamInterceptor]() +## func [DefaultClientStreamInterceptor]() ```go func DefaultClientStreamInterceptor(defaultOpts ...any) grpc.StreamClientInterceptor @@ -180,7 +183,7 @@ func DefaultClientStreamInterceptor(defaultOpts ...any) grpc.StreamClientInterce DefaultClientStreamInterceptor are the set of default interceptors that should be applied to all stream client calls -## func [DefaultClientStreamInterceptors]() +## func [DefaultClientStreamInterceptors]() ```go func DefaultClientStreamInterceptors(defaultOpts ...any) []grpc.StreamClientInterceptor @@ -241,6 +244,19 @@ func (s *svc) echo(ctx context.Context, req *proto.EchoRequest) (*proto.EchoResp } ``` + +## func [ExecutorClientInterceptor]() + +```go +func ExecutorClientInterceptor(defaultOpts ...grpc.CallOption) grpc.UnaryClientInterceptor +``` + +ExecutorClientInterceptor returns a unary client interceptor that wraps each RPC in an [Executor](<#Executor>). The executor provides resilience logic such as circuit breaking, retries, or bulkheading. + +If no executor is configured \(neither via [SetDefaultExecutor](<#SetDefaultExecutor>) nor per\-call \[WithExecutor\]\), the RPC is invoked directly as a passthrough. + +Excluded errors and codes \(set via \[WithExcludedErrors\] / \[WithExcludedCodes\]\) are reported as nil to the executor, preventing them from tripping circuit breakers or retry logic. The original error is still returned to the caller. + ## func [FilterMethodsFunc]() @@ -251,7 +267,7 @@ func FilterMethodsFunc(ctx context.Context, fullMethodName string) bool FilterMethodsFunc is the default implementation of Filter function -## func [GRPCClientInterceptor]() +## func [GRPCClientInterceptor]() ```go func GRPCClientInterceptor(_ ...any) grpc.UnaryClientInterceptor @@ -260,7 +276,7 @@ func GRPCClientInterceptor(_ ...any) grpc.UnaryClientInterceptor Deprecated: GRPCClientInterceptor is no longer needed. gRPC tracing is now handled by google.golang.org/grpc/stats/opentelemetry, configured via opentelemetry.DialOption\(\) at the client level. This function is retained for backwards compatibility but returns a no\-op interceptor. -## func [GetDebugLogHeaderName]() +## func [GetDebugLogHeaderName]() ```go func GetDebugLogHeaderName() string @@ -269,17 +285,15 @@ func GetDebugLogHeaderName() string GetDebugLogHeaderName returns the current debug log header name. -## func [HystrixClientInterceptor]() +## func [HystrixClientInterceptor]() ```go func HystrixClientInterceptor(defaultOpts ...grpc.CallOption) grpc.UnaryClientInterceptor ``` -HystrixClientInterceptor returns a unary client interceptor that executes the RPC inside a Hystrix command. +Deprecated: HystrixClientInterceptor wraps the unmaintained hystrix\-go library. Use [SetDefaultExecutor](<#SetDefaultExecutor>) with a failsafe\-go executor instead. Will be removed in v1. -Note: This interceptor wraps github.com/afex/hystrix\-go which has been unmaintained since 2018. Consider migrating to github.com/failsafe\-go/failsafe\-go for circuit breaker functionality. - -The interceptor applies provided default and per\-call client options to configure Hystrix behavior \(for example the command name, disabled flag, excluded errors, and excluded gRPC status codes\). If Hystrix is disabled via options, the RPC is invoked directly. If the underlying RPC returns an error that matches any configured excluded error or whose gRPC status code matches any configured excluded code, Hystrix fallback is skipped and the RPC error is returned. Panics raised during the RPC invocation are captured and reported to the notifier before being converted into an error. If the RPC itself returns an error, that error is returned; otherwise any error produced by Hystrix is returned. +See [ExecutorClientInterceptor](<#ExecutorClientInterceptor>) for the replacement. ## func [NRHttpTracer]() @@ -291,7 +305,7 @@ func NRHttpTracer(pattern string, h http.HandlerFunc) (string, http.HandlerFunc) NRHttpTracer adds newrelic tracing to this http function -## func [NewRelicClientInterceptor]() +## func [NewRelicClientInterceptor]() ```go func NewRelicClientInterceptor() grpc.UnaryClientInterceptor @@ -390,7 +404,7 @@ func ServerErrorStreamInterceptor() grpc.StreamServerInterceptor ServerErrorStreamInterceptor intercepts server errors for stream RPCs and reports them to the error notifier. -## func [SetClientMetricsOptions]() +## func [SetClientMetricsOptions]() ```go func SetClientMetricsOptions(opts ...grpcprom.ClientMetricsOption) @@ -399,7 +413,7 @@ func SetClientMetricsOptions(opts ...grpcprom.ClientMetricsOption) SetClientMetricsOptions appends gRPC client metrics options. Must be called during initialization, before any RPCs are made. Not safe for concurrent use. -## func [SetDebugLogHeaderName]() +## func [SetDebugLogHeaderName]() ```go func SetDebugLogHeaderName(name string) @@ -407,8 +421,17 @@ func SetDebugLogHeaderName(name string) SetDebugLogHeaderName sets the gRPC metadata header name that triggers per\-request log level override. Default is "x\-debug\-log\-level". The header value should be a valid log level \(e.g., "debug"\). Empty names are ignored. Must be called during initialization. + +## func [SetDefaultExecutor]() + +```go +func SetDefaultExecutor(e Executor) +``` + +SetDefaultExecutor sets the default [Executor](<#Executor>) used by [ExecutorClientInterceptor](<#ExecutorClientInterceptor>) for all outbound unary RPCs. When set, ExecutorClientInterceptor replaces [HystrixClientInterceptor](<#HystrixClientInterceptor>) in the default client interceptor chain. Must be called during initialization, before any RPCs are made. Not safe for concurrent use. + -## func [SetDefaultRateLimit]() +## func [SetDefaultRateLimit]() ```go func SetDefaultRateLimit(rps float64, burst int) @@ -417,7 +440,7 @@ func SetDefaultRateLimit(rps float64, burst int) SetDefaultRateLimit configures the built\-in token bucket rate limiter. rps is requests per second, burst is the maximum burst size. This is a per\-pod in\-memory limit — with N pods, the effective cluster\-wide limit is N × rps. For distributed rate limiting, use SetRateLimiter\(\) with a custom implementation \(e.g., Redis\-backed\). Must be called during initialization. -## func [SetDefaultTimeout]() +## func [SetDefaultTimeout]() ```go func SetDefaultTimeout(d time.Duration) @@ -426,7 +449,7 @@ func SetDefaultTimeout(d time.Duration) SetDefaultTimeout sets the default timeout applied to incoming unary RPCs that arrive without a deadline. When set to \<= 0, the timeout interceptor is disabled \(pass\-through\). Default is 60s. Must be called during initialization, before the server starts. Not safe for concurrent use. -## func [SetDisableDebugLogInterceptor]() +## func [SetDisableDebugLogInterceptor]() ```go func SetDisableDebugLogInterceptor(disable bool) @@ -435,7 +458,7 @@ func SetDisableDebugLogInterceptor(disable bool) SetDisableDebugLogInterceptor disables the DebugLogInterceptor in the default interceptor chain. Must be called during initialization, before the server starts. -## func [SetDisableProtoValidate]() +## func [SetDisableProtoValidate]() ```go func SetDisableProtoValidate(disable bool) @@ -444,7 +467,7 @@ func SetDisableProtoValidate(disable bool) SetDisableProtoValidate disables the protovalidate interceptor in the default chain. Must be called during init\(\) — not safe for concurrent use. -## func [SetDisableRateLimit]() +## func [SetDisableRateLimit]() ```go func SetDisableRateLimit(disable bool) @@ -453,7 +476,7 @@ func SetDisableRateLimit(disable bool) SetDisableRateLimit disables the rate limiting interceptor in the default interceptor chain. Must be called during initialization. -## func [SetFilterFunc]() +## func [SetFilterFunc]() ```go func SetFilterFunc(ctx context.Context, ff FilterFunc) @@ -471,7 +494,7 @@ func SetFilterMethods(ctx context.Context, methods []string) SetFilterMethods sets the list of method substrings to exclude from tracing/logging. It rebuilds the internal cache. Must be called during initialization, before the server starts. Not safe for concurrent use. -## func [SetProtoValidateOptions]() +## func [SetProtoValidateOptions]() ```go func SetProtoValidateOptions(opts ...protovalidate.ValidatorOption) @@ -480,7 +503,7 @@ func SetProtoValidateOptions(opts ...protovalidate.ValidatorOption) SetProtoValidateOptions configures custom protovalidate options \(e.g., custom constraints\). Must be called during init\(\) — not safe for concurrent use. Follows ColdBrew's init\-only config pattern. -## func [SetRateLimiter]() +## func [SetRateLimiter]() ```go func SetRateLimiter(limiter ratelimit_middleware.Limiter) @@ -489,7 +512,7 @@ func SetRateLimiter(limiter ratelimit_middleware.Limiter) SetRateLimiter sets a custom rate limiter implementation. This overrides the built\-in token bucket limiter. Must be called during initialization. -## func [SetResponseTimeLogErrorOnly]() +## func [SetResponseTimeLogErrorOnly]() ```go func SetResponseTimeLogErrorOnly(errorOnly bool) @@ -498,7 +521,7 @@ func SetResponseTimeLogErrorOnly(errorOnly bool) SetResponseTimeLogErrorOnly when set to true, only logs response time when the request returns an error. Successful requests are not logged. Must be called during initialization, before the server starts. Not safe for concurrent use. -## func [SetResponseTimeLogLevel]() +## func [SetResponseTimeLogLevel]() ```go func SetResponseTimeLogLevel(ctx context.Context, level loggers.Level) @@ -507,7 +530,7 @@ func SetResponseTimeLogLevel(ctx context.Context, level loggers.Level) SetResponseTimeLogLevel sets the log level for response time logging. Default is InfoLevel. Must be called during initialization, before the server starts. Not safe for concurrent use. -## func [SetServerMetricsOptions]() +## func [SetServerMetricsOptions]() ```go func SetServerMetricsOptions(opts ...grpcprom.ServerMetricsOption) @@ -525,7 +548,7 @@ func TraceIdInterceptor() grpc.UnaryServerInterceptor TraceIdInterceptor allows injecting trace id from request objects -## func [UseColdBrewClientInterceptors]() +## func [UseColdBrewClientInterceptors]() ```go func UseColdBrewClientInterceptors(ctx context.Context, flag bool) @@ -534,7 +557,7 @@ func UseColdBrewClientInterceptors(ctx context.Context, flag bool) UseColdBrewClientInterceptors allows enabling/disabling coldbrew client interceptors. When set to false, the coldbrew client interceptors will not be used. Must be called during initialization, before any RPCs are made. Not safe for concurrent use. -## func [UseColdBrewServerInterceptors]() +## func [UseColdBrewServerInterceptors]() ```go func UseColdBrewServerInterceptors(ctx context.Context, flag bool) @@ -542,6 +565,15 @@ func UseColdBrewServerInterceptors(ctx context.Context, flag bool) UseColdBrewServerInterceptors allows enabling/disabling coldbrew server interceptors. When set to false, the coldbrew server interceptors will not be used. Must be called during initialization, before the server starts. Not safe for concurrent use. + +## type [Executor]() + +Executor wraps an RPC invocation with resilience logic \(circuit breaking, retries, bulkheading, etc.\). The method parameter is the full gRPC method name \(e.g., "/package.Service/Method"\), enabling per\-method state such as per\-method circuit breakers. The executor is responsible for invoking fn and may call it multiple times when implementing retries. + +```go +type Executor func(ctx context.Context, method string, fn func(ctx context.Context) error) error +``` + ## type [FilterFunc]() diff --git a/client.go b/client.go index 6b71608..cc49cdb 100644 --- a/client.go +++ b/client.go @@ -24,17 +24,21 @@ func DefaultClientInterceptors(defaultOpts ...any) []grpc.UnaryClientInterceptor ints = append(ints, defaultConfig.unaryClientInterceptors...) } if defaultConfig.useCBClientInterceptors { - hystrixOptions := make([]grpc.CallOption, 0) + callOptions := make([]grpc.CallOption, 0) for _, opt := range defaultOpts { if opt == nil { continue } if o, ok := opt.(grpc.CallOption); ok { - hystrixOptions = append(hystrixOptions, o) + callOptions = append(callOptions, o) } } + if defaultConfig.defaultExecutor != nil { + ints = append(ints, ExecutorClientInterceptor(callOptions...)) + } else { + ints = append(ints, HystrixClientInterceptor(callOptions...)) + } ints = append(ints, - HystrixClientInterceptor(hystrixOptions...), grpc_retry.UnaryClientInterceptor(), NewRelicClientInterceptor(), getClientMetrics().UnaryClientInterceptor(), @@ -97,14 +101,76 @@ func GRPCClientInterceptor(_ ...any) grpc.UnaryClientInterceptor { } } -// HystrixClientInterceptor returns a unary client interceptor that executes the RPC inside a Hystrix command. +// ExecutorClientInterceptor returns a unary client interceptor that wraps each +// RPC in an [Executor]. The executor provides resilience logic such as circuit +// breaking, retries, or bulkheading. +// +// If no executor is configured (neither via [SetDefaultExecutor] nor per-call +// [WithExecutor]), the RPC is invoked directly as a passthrough. // -// Note: This interceptor wraps github.com/afex/hystrix-go which has been unmaintained since 2018. -// Consider migrating to github.com/failsafe-go/failsafe-go for circuit breaker functionality. +// Excluded errors and codes (set via [WithExcludedErrors] / [WithExcludedCodes]) +// are reported as nil to the executor, preventing them from tripping circuit +// breakers or retry logic. The original error is still returned to the caller. +func ExecutorClientInterceptor(defaultOpts ...grpc.CallOption) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + o := clientOptions{} + for _, opt := range defaultOpts { + if opt != nil { + if co, ok := opt.(clientOption); ok { + co.process(&o) + } + } + } + for _, opt := range opts { + if opt != nil { + if co, ok := opt.(clientOption); ok { + co.process(&o) + } + } + } + + // Resolve executor: per-call > global > nil (passthrough) + exec := defaultConfig.defaultExecutor + if o.hasExecutor { + exec = o.executor + } + if exec == nil { + return invoker(ctx, method, req, reply, cc, opts...) + } + + var invokerErr error + executorErr := exec(ctx, method, func(execCtx context.Context) (err error) { + defer func() { + if r := recover(); r != nil { + err = errors.Wrap(fmt.Errorf("panic in executor method: %s", method), "Executor") + log.Error(execCtx, "panic", r, "method", method) + } + }() + defer notifier.NotifyOnPanic(execCtx, method) + invokerErr = invoker(execCtx, method, req, reply, cc, opts...) + for _, excludedErr := range o.excludedErrors { + if stdError.Is(invokerErr, excludedErr) { + return nil + } + } + if st, ok := status.FromError(invokerErr); ok { + if slices.Contains(o.excludedCodes, st.Code()) { + return nil + } + } + return invokerErr + }) + if invokerErr != nil { + return invokerErr + } + return executorErr + } +} + +// Deprecated: HystrixClientInterceptor wraps the unmaintained hystrix-go library. +// Use [SetDefaultExecutor] with a failsafe-go executor instead. Will be removed in v1. // -// The interceptor applies provided default and per-call client options to configure Hystrix behavior (for example the command name, disabled flag, excluded errors, and excluded gRPC status codes). -// If Hystrix is disabled via options, the RPC is invoked directly. If the underlying RPC returns an error that matches any configured excluded error or whose gRPC status code matches any configured excluded code, Hystrix fallback is skipped and the RPC error is returned. -// Panics raised during the RPC invocation are captured and reported to the notifier before being converted into an error. If the RPC itself returns an error, that error is returned; otherwise any error produced by Hystrix is returned. +// See [ExecutorClientInterceptor] for the replacement. func HystrixClientInterceptor(defaultOpts ...grpc.CallOption) grpc.UnaryClientInterceptor { return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { options := clientOptions{ diff --git a/config.go b/config.go index d01a6fc..54bbce7 100644 --- a/config.go +++ b/config.go @@ -47,6 +47,9 @@ type interceptorConfig struct { rateLimiter ratelimit_middleware.Limiter defaultRateLimit rate.Limit defaultRateBurst int + + // Executor for resilience (circuit breaking, etc.) + defaultExecutor Executor } var defaultConfig = newDefaultConfig() @@ -203,3 +206,11 @@ func SetDefaultRateLimit(rps float64, burst int) { } defaultConfig.defaultRateBurst = burst } + +// SetDefaultExecutor sets the default [Executor] used by [ExecutorClientInterceptor] +// for all outbound unary RPCs. When set, ExecutorClientInterceptor replaces +// [HystrixClientInterceptor] in the default client interceptor chain. +// Must be called during initialization, before any RPCs are made. Not safe for concurrent use. +func SetDefaultExecutor(e Executor) { + defaultConfig.defaultExecutor = e +} diff --git a/examples/failsafe_test.go b/examples/failsafe_test.go new file mode 100644 index 0000000..30411c4 --- /dev/null +++ b/examples/failsafe_test.go @@ -0,0 +1,139 @@ +package examples_test + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/failsafe-go/failsafe-go" + "github.com/failsafe-go/failsafe-go/bulkhead" + "github.com/failsafe-go/failsafe-go/circuitbreaker" + "github.com/go-coldbrew/interceptors" +) + +// ExampleSetDefaultExecutor demonstrates setting up a circuit breaker for +// specific gRPC methods using failsafe-go. The executor receives the method +// name, so you can filter which methods get circuit breaking. +func ExampleSetDefaultExecutor() { + cb := circuitbreaker.NewBuilder[any](). + WithFailureThreshold(5). + WithDelay(5 * time.Second). + WithSuccessThreshold(2). + Build() + + // Only apply circuit breaking to specific methods + protected := map[string]bool{ + "/payment.Service/Charge": true, + "/payment.Service/Refund": true, + } + + interceptors.SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + if !protected[method] { + return fn(ctx) // passthrough for non-protected methods + } + return failsafe.With[any](cb).WithContext(ctx).Run(func() error { + return fn(ctx) + }) + }) + + fmt.Println("method-filtered circuit breaker configured") + // Output: method-filtered circuit breaker configured +} + +// ExampleSetDefaultExecutor_perMethod demonstrates per-method circuit breakers +// with different limits. Each method gets its own circuit breaker with +// tuning appropriate for that method's characteristics. +func ExampleSetDefaultExecutor_perMethod() { + type cbConfig struct { + failureThreshold uint + delay time.Duration + } + + // Different limits per method + configs := map[string]cbConfig{ + "/payment.Service/Charge": {failureThreshold: 3, delay: 10 * time.Second}, // sensitive — trip fast, recover slow + "/payment.Service/Refund": {failureThreshold: 3, delay: 10 * time.Second}, + "/user.Service/GetUser": {failureThreshold: 10, delay: 5 * time.Second}, // tolerant — allow more failures + "/feed.Service/GetFeed": {failureThreshold: 10, delay: 5 * time.Second}, + } + + var ( + mu sync.Mutex + breakers = make(map[string]circuitbreaker.CircuitBreaker[any]) + ) + + interceptors.SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + cfg, ok := configs[method] + if !ok { + return fn(ctx) // no circuit breaker for unconfigured methods + } + + mu.Lock() + cb, exists := breakers[method] + if !exists { + cb = circuitbreaker.NewBuilder[any](). + WithFailureThreshold(cfg.failureThreshold). + WithDelay(cfg.delay). + Build() + breakers[method] = cb + } + mu.Unlock() + + return failsafe.With[any](cb).WithContext(ctx).Run(func() error { + return fn(ctx) + }) + }) + + fmt.Println("per-method circuit breakers configured") + // Output: per-method circuit breakers configured +} + +// ExampleSetDefaultExecutor_bulkhead demonstrates composing a circuit breaker +// with a bulkhead (concurrency limiter) using failsafe-go. +func ExampleSetDefaultExecutor_bulkhead() { + cb := circuitbreaker.NewBuilder[any](). + WithFailureThreshold(5). + WithDelay(5 * time.Second). + Build() + + bh := bulkhead.New[any](200) + + // Policies execute right-to-left: bulkhead limits concurrency, + // circuit breaker wraps the result. + interceptors.SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + return failsafe.With[any](cb, bh).WithContext(ctx).Run(func() error { + return fn(ctx) + }) + }) + + fmt.Println("circuit breaker + bulkhead configured") + // Output: circuit breaker + bulkhead configured +} + +// ExampleWithoutExecutor demonstrates disabling the executor for specific RPCs. +// This is useful for health checks or internal loopback connections that should +// not be circuit-broken. +func ExampleWithoutExecutor() { + _ = interceptors.WithoutExecutor() + fmt.Println("executor disabled for this call") + // Output: executor disabled for this call +} + +// ExampleWithExecutor demonstrates setting a custom per-service executor +// with different circuit breaker tuning. +func ExampleWithExecutor() { + paymentCB := circuitbreaker.NewBuilder[any](). + WithFailureThreshold(3). // more sensitive + WithDelay(10 * time.Second). // longer recovery + Build() + + _ = interceptors.WithExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + return failsafe.With[any](paymentCB).WithContext(ctx).Run(func() error { + return fn(ctx) + }) + }) + + fmt.Println("per-service executor configured") + // Output: per-service executor configured +} diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..68cb263 --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,62 @@ +module github.com/go-coldbrew/interceptors/examples + +go 1.25.9 + +require ( + github.com/failsafe-go/failsafe-go v0.9.6 + github.com/go-coldbrew/interceptors v0.1.25 +) + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 // indirect + buf.build/go/protovalidate v1.1.3 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/adhocore/gronx v1.19.6 // indirect + github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 // indirect + github.com/airbrake/gobrake/v5 v5.6.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.4 // indirect + github.com/caio/go-tdigest/v4 v4.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect + github.com/go-coldbrew/errors v0.2.14 // indirect + github.com/go-coldbrew/log v0.3.2 // indirect + github.com/go-coldbrew/options v0.3.0 // indirect + github.com/go-coldbrew/tracing v0.2.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/cel-go v0.27.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gopherjs/gopherjs v1.20.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/k2io/hookingo v1.0.6 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/newrelic/go-agent/v3 v3.42.0 // indirect + github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.4.7 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/rollbar/rollbar-go v1.4.8 // indirect + github.com/smarty/assertions v1.16.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace github.com/go-coldbrew/interceptors => ../ diff --git a/examples/go.sum b/examples/go.sum new file mode 100644 index 0000000..ecbe998 --- /dev/null +++ b/examples/go.sum @@ -0,0 +1,181 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 h1:PMmTMyvHScV9Mn8wc6ASge9uRcHy0jtqPd+fM35LmsQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.1.3 h1:m2GVEgQWd7rk+vIoAZ+f0ygGjvQTuqPQapBBdcpWVPE= +buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= +github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw= +github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= +github.com/airbrake/gobrake/v5 v5.6.2 h1:/LLjm0B3Jy3gqg17VpeGEnSTRqiIElGDy3RZy9s7+MU= +github.com/airbrake/gobrake/v5 v5.6.2/go.mod h1:7lOWiGlpBnOnmWdz7BJzY/shByCkuAIL4LvHbGBKeZs= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE= +github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/caio/go-tdigest/v4 v4.0.1 h1:sx4ZxjmIEcLROUPs2j1BGe2WhOtHD6VSe6NNbBdKYh4= +github.com/caio/go-tdigest/v4 v4.0.1/go.mod h1:Wsa+f0EZnV2gShdj1adgl0tQSoXRxtM0QioTgukFw8U= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/failsafe-go/failsafe-go v0.9.6 h1:vPSH2cry0Ee5cnR9wc9qshCDO6jdrMA9elBJNwyo4Uk= +github.com/failsafe-go/failsafe-go v0.9.6/go.mod h1:IeRpglkcwzKagjDMh90ZhN2l4Ovt3+jemQBUbThag54= +github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= +github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/go-coldbrew/errors v0.2.14 h1:SQcV9Kw+hNfNGXjvu4fWl5uXw5NRD6lW+rkHgtzAYZE= +github.com/go-coldbrew/errors v0.2.14/go.mod h1:f9eGGKKF9KmyCpSWZRSqqV4HRWqbzmh1E9lyL8jyL+Y= +github.com/go-coldbrew/log v0.3.2 h1:CoHa0PGX7a7o/Cv/ke7PdQfq4LKtbPVypUf3uXcRLMs= +github.com/go-coldbrew/log v0.3.2/go.mod h1:tumRNCmLWRep5wnhS/vzDQ7UMinF6OZ7WW8K/qlXAzc= +github.com/go-coldbrew/options v0.3.0 h1:JwyVntb9bzBeFdaHFK6yGVVz30G3aVlqJJ6uVyYQfCc= +github.com/go-coldbrew/options v0.3.0/go.mod h1:8JlmgVJXFoY1KiDLsyMmR//q1U1aBItCexvTrVT2Y60= +github.com/go-coldbrew/tracing v0.2.2 h1:pvRMSwla5txZgtQOi18OqsuJhtsqPbfeC1arH9tJMys= +github.com/go-coldbrew/tracing v0.2.2/go.mod h1:mMYoCOqxFN28fEPMFbufwK0w/6rCiSjGftSr0KrZ0T0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU= +github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0= +github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= +github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v1.20.1 h1:22uLWFvVcxhJ+j3dJ99NNfwGyHynxCmjhYsrcwqbY60= +github.com/gopherjs/gopherjs v1.20.1/go.mod h1:h+FTmmLgbXMmmtuZFp9bUqXciN429Wx0sJEJuMnpyfM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/influxdata/tdigest v0.0.1 h1:XpFptwYmnEKUqmkcDjrzffswZ3nvNeevbUSLPP/ZzIY= +github.com/influxdata/tdigest v0.0.1/go.mod h1:Z0kXnxzbTC2qrx4NaIzYkE1k66+6oEDQTvL95hQFh5Y= +github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= +github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k2io/hookingo v1.0.6 h1:HBSKd1tNbW5BCj8VLNqemyBKjrQ8g0HkXcbC/DEHODE= +github.com/k2io/hookingo v1.0.6/go.mod h1:2L1jdNjdB3NkbzSVv9Q5fq7SJhRkWyAhe65XsAp5iXk= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353 h1:X/79QL0b4YJVO5+OsPH9rF2u428CIrGL/jLmPsoOQQ4= +github.com/leesper/go_rng v0.0.0-20190531154944-a612b043e353/go.mod h1:N0SVk0uhy+E1PZ3C9ctsPRlvOPAFPkCNlcPBDkt0N3U= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/newrelic/csec-go-agent v1.6.0 h1:OCShRZgiE+kg37jk+QXHw9e9EQ9BvLOeQTk+ovJhnrE= +github.com/newrelic/csec-go-agent v1.6.0/go.mod h1:LiLGm6a+q+hkmTnrxrYw1ToToirThOHydjrrLMtci5M= +github.com/newrelic/go-agent/v3 v3.42.0 h1:aA2Ea1RT5eD59LtOS1KGFXSmaDs6kM3Jeqo7PpuQoFQ= +github.com/newrelic/go-agent/v3 v3.42.0/go.mod h1:sCgxDCVydoKD/C4S8BFxDtmFHvdWHtaIz/a3kiyNB/k= +github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.4.7 h1:QE9VlVJZBWmFr5/LxbOegDXW5QZMmk1lK5eu48Dyn1M= +github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.4.7/go.mod h1:CFe6nTKFh4CznvfJ23tb7wBplhXJjFr53s9Au5AcUq8= +github.com/newrelic/go-agent/v3/integrations/nrsecurityagent v1.1.0 h1:gqkTDYUHWUyiG+u0PJQCRh98rcHLxP/w7GtIbJDVULY= +github.com/newrelic/go-agent/v3/integrations/nrsecurityagent v1.1.0/go.mod h1:3wugGvRmOVYov/08y+D8tB1uYIZds5bweVdr5vo4Gbs= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo/v2 v2.7.0 h1:/XxtEV3I3Eif/HobnVx9YmJgk8ENdRsuUmM+fLCFNow= +github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= +github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE= +github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/rollbar/rollbar-go v1.4.8 h1:SAKy97CHXSFZjxQUxmuBnQmfzCjX54kvQGEQZHEqwuQ= +github.com/rollbar/rollbar-go v1.4.8/go.mod h1:I/jSI5yHNj7Uy8oxntmCeBSZ1ILvypqRKlFQvZTINgA= +github.com/rollbar/rollbar-go/errors v1.0.0/go.mod h1:Ie0xEc1Cyj+T4XMO8s0Vf7pMfvSAAy1sb4AYc8aJsao= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= +github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE= +golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/interceptors_test.go b/interceptors_test.go index 84988b3..8c94917 100644 --- a/interceptors_test.go +++ b/interceptors_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "sync" "sync/atomic" "testing" @@ -393,6 +394,295 @@ func TestHystrixClientInterceptor(t *testing.T) { }) } +func TestExecutorClientInterceptor(t *testing.T) { + ctx := context.Background() + + t.Run("no executor passthrough", func(t *testing.T) { + resetGlobals() + interceptor := ExecutorClientInterceptor() + invokerCalled := false + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + invokerCalled = true + return nil + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !invokerCalled { + t.Error("invoker was not called") + } + }) + + t.Run("global executor", func(t *testing.T) { + resetGlobals() + executorCalled := false + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + executorCalled = true + return fn(ctx) + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invokerCalled := false + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + invokerCalled = true + return nil + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !executorCalled { + t.Error("executor was not called") + } + if !invokerCalled { + t.Error("invoker was not called") + } + }) + + t.Run("per-call executor overrides global", func(t *testing.T) { + resetGlobals() + globalCalled := false + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + globalCalled = true + return fn(ctx) + }) + defer resetGlobals() + + perCallCalled := false + perCallExec := func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + perCallCalled = true + return fn(ctx) + } + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return nil + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker, WithExecutor(perCallExec)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if globalCalled { + t.Error("global executor should not be called when per-call is set") + } + if !perCallCalled { + t.Error("per-call executor was not called") + } + }) + + t.Run("WithoutExecutor disables global", func(t *testing.T) { + resetGlobals() + executorCalled := false + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + executorCalled = true + return fn(ctx) + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invokerCalled := false + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + invokerCalled = true + return nil + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker, WithoutExecutor()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if executorCalled { + t.Error("executor should not be called when WithoutExecutor is used") + } + if !invokerCalled { + t.Error("invoker was not called") + } + }) + + t.Run("excluded errors", func(t *testing.T) { + resetGlobals() + errExpected := errors.New("expected error") + var executorSawErr error + + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + executorSawErr = fn(ctx) + return executorSawErr + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return errExpected + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker, WithExcludedErrors(errExpected)) + if err == nil { + t.Fatal("expected error to be returned to caller") + } + if !errors.Is(err, errExpected) { + t.Fatalf("expected %v, got %v", errExpected, err) + } + if executorSawErr != nil { + t.Error("executor should have seen nil error for excluded error") + } + }) + + t.Run("excluded codes", func(t *testing.T) { + resetGlobals() + var executorSawErr error + + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + executorSawErr = fn(ctx) + return executorSawErr + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return status.Error(codes.NotFound, "not found") + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker, WithExcludedCodes(codes.NotFound)) + if err == nil { + t.Fatal("expected error to be returned to caller") + } + if s, ok := status.FromError(err); !ok || s.Code() != codes.NotFound { + t.Fatalf("expected NotFound, got %v", err) + } + if executorSawErr != nil { + t.Error("executor should have seen nil error for excluded code") + } + }) + + t.Run("panic recovery", func(t *testing.T) { + resetGlobals() + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + return fn(ctx) + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + panic("test panic") + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker) + if err == nil { + t.Fatal("expected error from panic recovery") + } + if !strings.Contains(err.Error(), "panic in executor method") { + t.Fatalf("expected wrapped panic error containing 'panic in executor method', got: %v", err) + } + }) + + t.Run("executor error returned", func(t *testing.T) { + resetGlobals() + errCircuitOpen := errors.New("circuit open") + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + _ = fn(ctx) + return errCircuitOpen + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return nil + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker) + if !errors.Is(err, errCircuitOpen) { + t.Fatalf("expected circuit open error, got %v", err) + } + }) + + t.Run("invoker error takes priority over executor error", func(t *testing.T) { + resetGlobals() + errInvoker := errors.New("invoker error") + errExecutor := errors.New("executor error") + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + fn(ctx) //nolint:errcheck + return errExecutor + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return errInvoker + } + + err := interceptor(ctx, "/test/Method", nil, nil, nil, invoker) + if !errors.Is(err, errInvoker) { + t.Fatalf("expected invoker error, got %v", err) + } + }) + + t.Run("executor receives method name", func(t *testing.T) { + resetGlobals() + var receivedMethod string + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + receivedMethod = method + return fn(ctx) + }) + defer resetGlobals() + + interceptor := ExecutorClientInterceptor() + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return nil + } + + err := interceptor(ctx, "/my.package/MyMethod", nil, nil, nil, invoker) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedMethod != "/my.package/MyMethod" { + t.Fatalf("expected method /my.package/MyMethod, got %s", receivedMethod) + } + }) +} + +func TestDefaultClientInterceptors_ExecutorBranching(t *testing.T) { + t.Run("uses hystrix when no executor", func(t *testing.T) { + resetGlobals() + defer resetGlobals() + + ints := DefaultClientInterceptors() + // Should have interceptors (hystrix path) + if len(ints) == 0 { + t.Fatal("expected interceptors in default chain") + } + }) + + t.Run("uses executor when set", func(t *testing.T) { + resetGlobals() + executorCalled := false + SetDefaultExecutor(func(ctx context.Context, method string, fn func(ctx context.Context) error) error { + executorCalled = true + return fn(ctx) + }) + defer resetGlobals() + + ints := DefaultClientInterceptors() + if len(ints) == 0 { + t.Fatal("expected interceptors in default chain") + } + // Call the first interceptor (should be executor, not hystrix) + invoker := func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + return nil + } + err := ints[0](context.Background(), "/test/Method", nil, nil, nil, invoker) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !executorCalled { + t.Error("executor interceptor should have been used, not hystrix") + } + }) +} + func TestChainUnaryServer(t *testing.T) { var order []int makeInterceptor := func(id int) grpc.UnaryServerInterceptor { diff --git a/options.go b/options.go index 74bba80..a382bd9 100644 --- a/options.go +++ b/options.go @@ -1,10 +1,19 @@ package interceptors import ( + "context" + "google.golang.org/grpc" "google.golang.org/grpc/codes" ) +// Executor wraps an RPC invocation with resilience logic (circuit breaking, +// retries, bulkheading, etc.). The method parameter is the full gRPC method +// name (e.g., "/package.Service/Method"), enabling per-method state such as +// per-method circuit breakers. The executor is responsible for invoking fn +// and may call it multiple times when implementing retries. +type Executor func(ctx context.Context, method string, fn func(ctx context.Context) error) error + type clientOption interface { grpc.CallOption process(*clientOptions) @@ -13,6 +22,8 @@ type clientOption interface { type clientOptions struct { hystrixName string disableHystrix bool + executor Executor + hasExecutor bool excludedErrors []error excludedCodes []codes.Code } @@ -26,8 +37,8 @@ func (h *optionCarrier) process(co *clientOptions) { h.processor(co) } -// WithHystrixName creates a clientOption that sets the Hystrix command name used by client interceptors. -// If name is empty, the existing Hystrix name is left unchanged. +// Deprecated: WithHystrixName sets the Hystrix command name. Use [WithExecutor] with a +// per-method executor instead. Will be removed in v1. func WithHystrixName(name string) clientOption { return &optionCarrier{ processor: func(co *clientOptions) { @@ -38,16 +49,20 @@ func WithHystrixName(name string) clientOption { } } -// WithoutHystrix disables hystrix +// Deprecated: WithoutHystrix disables hystrix and the executor. +// Use [WithoutExecutor] instead. Will be removed in v1. func WithoutHystrix() clientOption { return &optionCarrier{ processor: func(co *clientOptions) { co.disableHystrix = true + co.hasExecutor = true + co.executor = nil }, } } -// WithHystrix enables hystrix +// Deprecated: WithHystrix enables hystrix. The executor is enabled by default +// when configured via [SetDefaultExecutor]. Will be removed in v1. func WithHystrix() clientOption { return &optionCarrier{ processor: func(co *clientOptions) { @@ -56,9 +71,50 @@ func WithHystrix() clientOption { } } -// WithHystrixExcludedErrors returns a clientOption that adds the provided errors to the list of errors -// excluded from the Hystrix circuit breaker. +// Deprecated: WithHystrixExcludedErrors adds errors excluded from the Hystrix circuit breaker. +// Use [WithExcludedErrors] instead. Will be removed in v1. +// +//go:fix inline func WithHystrixExcludedErrors(errors ...error) clientOption { + return WithExcludedErrors(errors...) +} + +// Deprecated: WithHystrixExcludedCodes appends gRPC codes excluded from the Hystrix circuit breaker. +// Use [WithExcludedCodes] instead. Will be removed in v1. +// +//go:fix inline +func WithHystrixExcludedCodes(codes ...codes.Code) clientOption { + return WithExcludedCodes(codes...) +} + +// WithExecutor returns a clientOption that sets a custom [Executor] for resilience +// logic (circuit breaking, retries, etc.). The executor wraps the RPC invocation. +// This overrides the global executor set via [SetDefaultExecutor] for this call. +func WithExecutor(e Executor) clientOption { + return &optionCarrier{ + processor: func(co *clientOptions) { + co.executor = e + co.hasExecutor = true + }, + } +} + +// WithoutExecutor returns a clientOption that disables the executor for this call, +// even if a global executor is set via [SetDefaultExecutor]. The RPC is invoked +// directly as a passthrough. +func WithoutExecutor() clientOption { + return &optionCarrier{ + processor: func(co *clientOptions) { + co.executor = nil + co.hasExecutor = true + }, + } +} + +// WithExcludedErrors returns a clientOption that adds the provided errors to the +// exclusion list. Excluded errors are reported as success to the executor (not +// tripping circuit breakers), but the original error is still returned to the caller. +func WithExcludedErrors(errors ...error) clientOption { return &optionCarrier{ processor: func(co *clientOptions) { co.excludedErrors = append(co.excludedErrors, errors...) @@ -66,8 +122,11 @@ func WithHystrixExcludedErrors(errors ...error) clientOption { } } -// WithHystrixExcludedCodes returns a clientOption that appends the provided gRPC codes to the list of codes excluded from the Hystrix circuit breaker. -func WithHystrixExcludedCodes(codes ...codes.Code) clientOption { +// WithExcludedCodes returns a clientOption that appends the provided gRPC status +// codes to the exclusion list. Excluded codes are reported as success to the +// executor (not tripping circuit breakers), but the original error is still +// returned to the caller. +func WithExcludedCodes(codes ...codes.Code) clientOption { return &optionCarrier{ processor: func(co *clientOptions) { co.excludedCodes = append(co.excludedCodes, codes...)