This is an automated email from the ASF dual-hosted git repository.
AlexStocks pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/dubbo-go.git
The following commit(s) were added to refs/heads/develop by this push:
new a31acfb67 docs(metrics): improve comments and test coverage of metrics
options and rpc error classifier (#3665)
a31acfb67 is described below
commit a31acfb67e9feb0694bd11cbfaf24689aec9146a
Author: Yu <[email protected]>
AuthorDate: Sun Aug 16 12:22:02 2026 +0800
docs(metrics): improve comments and test coverage of metrics options and
rpc error classifier (#3665)
* chore(metrics): add godoc comments and tests for metrics options and rpc
error classifier
---
metrics/options.go | 119 ++++++++-
metrics/options_test.go | 349 ++++++++++++++++++--------
metrics/rpc/error_classifier.go | 2 +
metrics/rpc/error_classifier_test.go | 133 ++++++++++
protocol/triple/triple_protocol/code.go | 6 +-
protocol/triple/triple_protocol/error_test.go | 5 +
6 files changed, 503 insertions(+), 111 deletions(-)
diff --git a/metrics/options.go b/metrics/options.go
index d494e6a33..6807114bc 100644
--- a/metrics/options.go
+++ b/metrics/options.go
@@ -26,14 +26,29 @@ import (
"dubbo.apache.org/dubbo-go/v3/global"
)
+// Options holds the configuration of the metrics module.
+// It wraps the global MetricsConfig, and can be built programmatically
+// with NewOptions and a set of Option functions.
+//
+// The metrics module is disabled by default; use WithEnabled to turn it on.
+//
+// NewOptions returns zero values for untouched fields; the defaults
+// documented on the With* functions (e.g. port 9090, path "/metrics") only
+// take effect at instance (client/server) initialization.
type Options struct {
Metrics *global.MetricsConfig
}
+// defaultOptions wraps a fresh global.MetricsConfig; no option is applied,
+// so every field keeps its zero value until instance initialization.
func defaultOptions() *Options {
return &Options{Metrics: global.DefaultMetricsConfig()}
}
+// NewOptions wraps a fresh global.MetricsConfig and applies the given
+// options in order; later options win on conflicting fields. Fields left
+// untouched are zero-valued until filled with the defaults during instance
+// (client/server) initialization.
func NewOptions(opts ...Option) *Options {
MetricOptions := defaultOptions()
for _, opt := range opts {
@@ -42,8 +57,13 @@ func NewOptions(opts ...Option) *Options {
return MetricOptions
}
+// Option is a functional option used to customize the metrics Options.
type Option func(*Options)
+// WithAggregationEnabled enables metrics aggregation, such as the
+// time-window based aggregation for counters and rt metrics.
+//
+// Aggregation is disabled by default (applied at instance initialization).
func WithAggregationEnabled() Option {
return func(opts *Options) {
enabled := true
@@ -51,24 +71,44 @@ func WithAggregationEnabled() Option {
}
}
+// WithAggregationBucketNum sets the number of buckets used by metrics
+// aggregation. A larger bucket count gives finer time resolution inside
+// the time window at the cost of more memory.
+//
+// The default (10) is applied at instance initialization.
func WithAggregationBucketNum(num int) Option {
return func(opts *Options) {
opts.Metrics.Aggregation.BucketNum = num
}
}
+// WithAggregationTimeWindowSeconds sets the time window, in seconds,
+// of the metrics aggregation. Metrics older than the window are
+// discarded from the aggregation result.
+//
+// The default (120 seconds) is applied at instance initialization.
func WithAggregationTimeWindowSeconds(seconds int) Option {
return func(opts *Options) {
opts.Metrics.Aggregation.TimeWindowSeconds = seconds
}
}
+// WithPrometheus sets the metrics protocol to prometheus.
+//
+// Prometheus is the default protocol (applied at instance initialization),
+// so this option is only needed to switch back to it after another
+// protocol has been configured.
func WithPrometheus() Option {
return func(opts *Options) {
opts.Metrics.Protocol = "prometheus"
}
}
+// WithPrometheusExporterEnabled enables the prometheus exporter, which
+// exposes the collected metrics over the http endpoint configured by
+// WithPort and WithPath.
+//
+// The exporter is enabled by default (applied at instance initialization).
func WithPrometheusExporterEnabled() Option {
return func(opts *Options) {
enabled := true
@@ -76,6 +116,11 @@ func WithPrometheusExporterEnabled() Option {
}
}
+// WithPrometheusPushgatewayEnabled enables pushing metrics to the
+// prometheus pushgateway, so that they can be scraped by prometheus
+// even if the instance is short-lived or unreachable directly.
+//
+// Pushgateway is disabled by default (applied at instance initialization).
func WithPrometheusPushgatewayEnabled() Option {
return func(opts *Options) {
enabled := true
@@ -83,35 +128,64 @@ func WithPrometheusPushgatewayEnabled() Option {
}
}
+// WithPrometheusGatewayUrl sets the base url of the prometheus
+// pushgateway, e.g. "http://pushgateway:9091".
+//
+// The option has no default value: it is not filled in at instance
+// initialization and must be set when pushgateway is enabled.
func WithPrometheusGatewayUrl(url string) Option {
return func(opts *Options) {
opts.Metrics.Prometheus.Pushgateway.BaseUrl = url
}
}
+// WithPrometheusGatewayJob sets the job name reported to the prometheus
+// pushgateway. It is used to group the pushed metrics in prometheus.
+//
+// The default ("default_dubbo_job") is applied at instance initialization.
func WithPrometheusGatewayJob(job string) Option {
return func(opts *Options) {
opts.Metrics.Prometheus.Pushgateway.Job = job
}
}
+// WithPrometheusGatewayUsername sets the username for basic
+// authentication with the prometheus pushgateway.
+//
+// The default (empty username, i.e. no authentication) is applied at
+// instance initialization.
func WithPrometheusGatewayUsername(username string) Option {
return func(opts *Options) {
opts.Metrics.Prometheus.Pushgateway.Username = username
}
}
+// WithPrometheusGatewayPassword sets the password for basic
+// authentication with the prometheus pushgateway.
+//
+// The default (empty password, i.e. no authentication) is applied at
+// instance initialization.
func WithPrometheusGatewayPassword(password string) Option {
return func(opts *Options) {
opts.Metrics.Prometheus.Pushgateway.Password = password
}
}
+
+// WithPrometheusGatewayInterval sets the interval at which metrics are
+// pushed to the prometheus pushgateway.
+//
+// The default (30 seconds) is applied at instance initialization.
func WithPrometheusGatewayInterval(interval time.Duration) Option {
return func(opts *Options) {
opts.Metrics.Prometheus.Pushgateway.PushInterval =
int(interval.Seconds())
}
}
+// WithConfigCenterEnabled enables the config-center metrics, which
+// report the state of the dynamic configuration center (e.g. the
+// configuration that the instance has loaded or subscribed).
+//
+// Config-center metrics are disabled by default (applied at instance
initialization).
func WithConfigCenterEnabled() Option {
return func(opts *Options) {
b := true
@@ -119,6 +193,10 @@ func WithConfigCenterEnabled() Option {
}
}
+// WithMetadataEnabled enables the metadata metrics, which report the
+// operations of the metadata center (e.g. store provider metadata).
+//
+// Metadata metrics are disabled by default (applied at instance
initialization).
func WithMetadataEnabled() Option {
return func(opts *Options) {
b := true
@@ -126,6 +204,10 @@ func WithMetadataEnabled() Option {
}
}
+// WithRegistryEnabled enables the registry metrics, which report the
+// interactions with the service registry (e.g. register, subscribe).
+//
+// Registry metrics are disabled by default (applied at instance
initialization).
func WithRegistryEnabled() Option {
return func(opts *Options) {
b := true
@@ -133,7 +215,12 @@ func WithRegistryEnabled() Option {
}
}
-// WithEnabled this will enable rpc and tracing by default, config-center,
metadata and registry metrics will still be in disable state.
+// WithEnabled enables the metrics module. It enables the rpc metrics
+// by default, while config-center, metadata and registry metrics
+// are still in disable state and need WithConfigCenterEnabled,
+// WithMetadataEnabled and WithRegistryEnabled respectively.
+//
+// The metrics module is disabled by default (applied at instance
initialization).
func WithEnabled() Option {
return func(opts *Options) {
b := true
@@ -141,19 +228,29 @@ func WithEnabled() Option {
}
}
+// WithPort sets the port on which the metrics are exposed.
+//
+// The default (9090) is applied at instance initialization.
func WithPort(port int) Option {
return func(opts *Options) {
opts.Metrics.Port = strconv.Itoa(port)
}
}
+// WithPath sets the http path on which the metrics are exposed.
+//
+// The default ("/metrics") is applied at instance initialization.
func WithPath(path string) Option {
return func(opts *Options) {
opts.Metrics.Path = path
}
}
-// Below are options for probe
+// WithProbeEnabled enables the health probe endpoints
+// (liveness, readiness and startup), which are typically used
+// by Kubernetes for container health checks.
+//
+// Probe endpoints are disabled by default (applied at instance
initialization).
func WithProbeEnabled() Option {
return func(opts *Options) {
b := true
@@ -161,30 +258,48 @@ func WithProbeEnabled() Option {
}
}
+// WithProbePort sets the port on which the probe endpoints are served.
+//
+// The default (22222) is applied at instance initialization.
func WithProbePort(port int) Option {
return func(opts *Options) {
opts.Metrics.Probe.Port = strconv.Itoa(port)
}
}
+// WithProbeLivenessPath sets the http path of the liveness probe.
+//
+// The default ("/live") is applied at instance initialization.
func WithProbeLivenessPath(path string) Option {
return func(opts *Options) {
opts.Metrics.Probe.LivenessPath = path
}
}
+// WithProbeReadinessPath sets the http path of the readiness probe.
+//
+// The default ("/ready") is applied at instance initialization.
func WithProbeReadinessPath(path string) Option {
return func(opts *Options) {
opts.Metrics.Probe.ReadinessPath = path
}
}
+// WithProbeStartupPath sets the http path of the startup probe.
+//
+// The default ("/startup") is applied at instance initialization.
func WithProbeStartupPath(path string) Option {
return func(opts *Options) {
opts.Metrics.Probe.StartupPath = path
}
}
+// WithProbeUseInternalState sets whether the probe endpoints report the
+// internal state of the framework (e.g. whether the dubbo server has
+// started up and is ready to serve) as the probe result, instead of
+// answering only with the result of the registered custom checks.
+//
+// The default (true) is applied at instance initialization.
func WithProbeUseInternalState(use bool) Option {
return func(opts *Options) {
opts.Metrics.Probe.UseInternalState = &use
diff --git a/metrics/options_test.go b/metrics/options_test.go
index 7f7d2851a..ce5b662f6 100644
--- a/metrics/options_test.go
+++ b/metrics/options_test.go
@@ -30,122 +30,257 @@ func TestDefaultOptions(t *testing.T) {
opts := defaultOptions()
assert.NotNil(t, opts)
assert.NotNil(t, opts.Metrics)
+ // the metrics module and all sub-modules are disabled by default
+ assert.Nil(t, opts.Metrics.Enable)
+ assert.Nil(t, opts.Metrics.Aggregation.Enabled)
+ assert.Nil(t, opts.Metrics.Probe.Enabled)
+ assert.Nil(t, opts.Metrics.Prometheus.Pushgateway.Enabled)
+ assert.Empty(t, opts.Metrics.Port)
+ assert.Empty(t, opts.Metrics.Path)
+ assert.Empty(t, opts.Metrics.Protocol)
}
func TestNewOptions(t *testing.T) {
- t.Run("no options", func(t *testing.T) {
- opts := NewOptions()
- assert.NotNil(t, opts)
- assert.NotNil(t, opts.Metrics)
- })
-
- t.Run("with single option", func(t *testing.T) {
- opts := NewOptions(WithPrometheus())
- assert.NotNil(t, opts)
- assert.Equal(t, "prometheus", opts.Metrics.Protocol)
- })
-
- t.Run("with multiple options", func(t *testing.T) {
- opts := NewOptions(
- WithPrometheus(),
- WithPort(9090),
- WithPath("/metrics"),
- )
- assert.NotNil(t, opts)
- assert.Equal(t, "prometheus", opts.Metrics.Protocol)
- assert.Equal(t, "9090", opts.Metrics.Port)
- assert.Equal(t, "/metrics", opts.Metrics.Path)
- })
-}
-
-func TestWithAggregationEnabled(t *testing.T) {
- opts := NewOptions(WithAggregationEnabled())
- assert.NotNil(t, opts.Metrics.Aggregation.Enabled)
- assert.True(t, *opts.Metrics.Aggregation.Enabled)
-}
-
-func TestWithAggregationBucketNum(t *testing.T) {
- opts := NewOptions(WithAggregationBucketNum(20))
- assert.Equal(t, 20, opts.Metrics.Aggregation.BucketNum)
-}
-
-func TestWithAggregationTimeWindowSeconds(t *testing.T) {
- opts := NewOptions(WithAggregationTimeWindowSeconds(60))
- assert.Equal(t, 60, opts.Metrics.Aggregation.TimeWindowSeconds)
-}
-
-func TestWithPrometheus(t *testing.T) {
- opts := NewOptions(WithPrometheus())
- assert.Equal(t, "prometheus", opts.Metrics.Protocol)
-}
-
-func TestWithPrometheusExporterEnabled(t *testing.T) {
- opts := NewOptions(WithPrometheusExporterEnabled())
- assert.NotNil(t, opts.Metrics.Prometheus.Exporter.Enabled)
- assert.True(t, *opts.Metrics.Prometheus.Exporter.Enabled)
-}
-
-func TestWithPrometheusPushgatewayEnabled(t *testing.T) {
- opts := NewOptions(WithPrometheusPushgatewayEnabled())
- assert.NotNil(t, opts.Metrics.Prometheus.Pushgateway.Enabled)
- assert.True(t, *opts.Metrics.Prometheus.Pushgateway.Enabled)
-}
-
-func TestWithPrometheusGatewayUrl(t *testing.T) {
- opts := NewOptions(WithPrometheusGatewayUrl("http://localhost:9091"))
- assert.Equal(t, "http://localhost:9091",
opts.Metrics.Prometheus.Pushgateway.BaseUrl)
-}
-
-func TestWithPrometheusGatewayJob(t *testing.T) {
- opts := NewOptions(WithPrometheusGatewayJob("test-job"))
- assert.Equal(t, "test-job", opts.Metrics.Prometheus.Pushgateway.Job)
-}
-
-func TestWithPrometheusGatewayUsername(t *testing.T) {
- opts := NewOptions(WithPrometheusGatewayUsername("admin"))
- assert.Equal(t, "admin", opts.Metrics.Prometheus.Pushgateway.Username)
-}
-
-func TestWithPrometheusGatewayPassword(t *testing.T) {
- opts := NewOptions(WithPrometheusGatewayPassword("secret"))
- assert.Equal(t, "secret", opts.Metrics.Prometheus.Pushgateway.Password)
-}
-
-func TestWithPrometheusGatewayInterval(t *testing.T) {
- opts := NewOptions(WithPrometheusGatewayInterval(60 * time.Second))
- assert.Equal(t, 60, opts.Metrics.Prometheus.Pushgateway.PushInterval)
-}
-
-func TestWithConfigCenterEnabled(t *testing.T) {
- opts := NewOptions(WithConfigCenterEnabled())
- assert.NotNil(t, opts.Metrics.EnableConfigCenter)
- assert.True(t, *opts.Metrics.EnableConfigCenter)
-}
-
-func TestWithMetadataEnabled(t *testing.T) {
- opts := NewOptions(WithMetadataEnabled())
- assert.NotNil(t, opts.Metrics.EnableMetadata)
- assert.True(t, *opts.Metrics.EnableMetadata)
+ tests := []struct {
+ name string
+ options []Option
+ check func(*testing.T, *Options)
+ }{
+ {
+ name: "no options",
+ check: func(t *testing.T, opts *Options) {
+ assert.NotNil(t, opts)
+ assert.NotNil(t, opts.Metrics)
+ },
+ },
+ {
+ name: "single option",
+ options: []Option{WithPrometheus()},
+ check: func(t *testing.T, opts *Options) {
+ assert.Equal(t, "prometheus",
opts.Metrics.Protocol)
+ },
+ },
+ {
+ name: "multiple options",
+ options: []Option{WithPrometheus(), WithPort(9090),
WithPath("/metrics")},
+ check: func(t *testing.T, opts *Options) {
+ assert.Equal(t, "prometheus",
opts.Metrics.Protocol)
+ assert.Equal(t, "9090", opts.Metrics.Port)
+ assert.Equal(t, "/metrics", opts.Metrics.Path)
+ },
+ },
+ {
+ name: "later option wins",
+ options: []Option{WithPort(8080), WithPort(9090)},
+ check: func(t *testing.T, opts *Options) {
+ assert.Equal(t, "9090", opts.Metrics.Port)
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := NewOptions(tt.options...)
+ tt.check(t, opts)
+ })
+ }
}
-func TestWithRegistryEnabled(t *testing.T) {
- opts := NewOptions(WithRegistryEnabled())
- assert.NotNil(t, opts.Metrics.EnableRegistry)
- assert.True(t, *opts.Metrics.EnableRegistry)
+func TestBoolFlagOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ option Option
+ get func(*Options) *bool
+ }{
+ {
+ name: "WithAggregationEnabled",
+ option: WithAggregationEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.Aggregation.Enabled },
+ },
+ {
+ name: "WithPrometheusExporterEnabled",
+ option: WithPrometheusExporterEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.Prometheus.Exporter.Enabled },
+ },
+ {
+ name: "WithPrometheusPushgatewayEnabled",
+ option: WithPrometheusPushgatewayEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.Prometheus.Pushgateway.Enabled },
+ },
+ {
+ name: "WithConfigCenterEnabled",
+ option: WithConfigCenterEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.EnableConfigCenter },
+ },
+ {
+ name: "WithMetadataEnabled",
+ option: WithMetadataEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.EnableMetadata },
+ },
+ {
+ name: "WithRegistryEnabled",
+ option: WithRegistryEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.EnableRegistry },
+ },
+ {
+ name: "WithEnabled",
+ option: WithEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.Enable },
+ },
+ {
+ name: "WithProbeEnabled",
+ option: WithProbeEnabled(),
+ get: func(o *Options) *bool { return
o.Metrics.Probe.Enabled },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := NewOptions(tt.option)
+ assert.NotNil(t, opts.Metrics)
+ got := tt.get(opts)
+ assert.NotNil(t, got)
+ assert.True(t, *got)
+ })
+ }
}
-func TestWithEnabled(t *testing.T) {
- opts := NewOptions(WithEnabled())
- assert.NotNil(t, opts.Metrics.Enable)
- assert.True(t, *opts.Metrics.Enable)
+func TestStringFieldOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ option Option
+ want string
+ get func(*Options) string
+ }{
+ {
+ name: "WithPrometheus",
+ option: WithPrometheus(),
+ want: "prometheus",
+ get: func(o *Options) string { return
o.Metrics.Protocol },
+ },
+ {
+ name: "WithPrometheusGatewayUrl",
+ option:
WithPrometheusGatewayUrl("http://localhost:9091"),
+ want: "http://localhost:9091",
+ get: func(o *Options) string { return
o.Metrics.Prometheus.Pushgateway.BaseUrl },
+ },
+ {
+ name: "WithPrometheusGatewayJob",
+ option: WithPrometheusGatewayJob("test-job"),
+ want: "test-job",
+ get: func(o *Options) string { return
o.Metrics.Prometheus.Pushgateway.Job },
+ },
+ {
+ name: "WithPrometheusGatewayUsername",
+ option: WithPrometheusGatewayUsername("admin"),
+ want: "admin",
+ get: func(o *Options) string { return
o.Metrics.Prometheus.Pushgateway.Username },
+ },
+ {
+ name: "WithPrometheusGatewayPassword",
+ option: WithPrometheusGatewayPassword("secret"),
+ want: "secret",
+ get: func(o *Options) string { return
o.Metrics.Prometheus.Pushgateway.Password },
+ },
+ {
+ name: "WithPort",
+ option: WithPort(8080),
+ want: "8080",
+ get: func(o *Options) string { return o.Metrics.Port
},
+ },
+ {
+ name: "WithPath",
+ option: WithPath("/custom/metrics"),
+ want: "/custom/metrics",
+ get: func(o *Options) string { return o.Metrics.Path
},
+ },
+ {
+ name: "WithProbePort",
+ option: WithProbePort(12345),
+ want: "12345",
+ get: func(o *Options) string { return
o.Metrics.Probe.Port },
+ },
+ {
+ name: "WithProbeLivenessPath",
+ option: WithProbeLivenessPath("/custom/live"),
+ want: "/custom/live",
+ get: func(o *Options) string { return
o.Metrics.Probe.LivenessPath },
+ },
+ {
+ name: "WithProbeReadinessPath",
+ option: WithProbeReadinessPath("/custom/ready"),
+ want: "/custom/ready",
+ get: func(o *Options) string { return
o.Metrics.Probe.ReadinessPath },
+ },
+ {
+ name: "WithProbeStartupPath",
+ option: WithProbeStartupPath("/custom/startup"),
+ want: "/custom/startup",
+ get: func(o *Options) string { return
o.Metrics.Probe.StartupPath },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := NewOptions(tt.option)
+ assert.Equal(t, tt.want, tt.get(opts))
+ })
+ }
}
-func TestWithPort(t *testing.T) {
- opts := NewOptions(WithPort(8080))
- assert.Equal(t, "8080", opts.Metrics.Port)
+func TestIntFieldOptions(t *testing.T) {
+ tests := []struct {
+ name string
+ option Option
+ want int
+ get func(*Options) int
+ }{
+ {
+ name: "WithAggregationBucketNum",
+ option: WithAggregationBucketNum(20),
+ want: 20,
+ get: func(o *Options) int { return
o.Metrics.Aggregation.BucketNum },
+ },
+ {
+ name: "WithAggregationTimeWindowSeconds",
+ option: WithAggregationTimeWindowSeconds(60),
+ want: 60,
+ get: func(o *Options) int { return
o.Metrics.Aggregation.TimeWindowSeconds },
+ },
+ {
+ name: "WithPrometheusGatewayInterval",
+ option: WithPrometheusGatewayInterval(60 * time.Second),
+ want: 60,
+ get: func(o *Options) int { return
o.Metrics.Prometheus.Pushgateway.PushInterval },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := NewOptions(tt.option)
+ assert.Equal(t, tt.want, tt.get(opts))
+ })
+ }
}
-func TestWithPath(t *testing.T) {
- opts := NewOptions(WithPath("/custom/metrics"))
- assert.Equal(t, "/custom/metrics", opts.Metrics.Path)
+func TestWithProbeUseInternalState(t *testing.T) {
+ tests := []struct {
+ name string
+ use bool
+ want bool
+ }{
+ {
+ name: "use internal state",
+ use: true,
+ want: true,
+ },
+ {
+ name: "not use internal state",
+ use: false,
+ want: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ opts := NewOptions(WithProbeUseInternalState(tt.use))
+ assert.NotNil(t, opts.Metrics.Probe.UseInternalState)
+ assert.Equal(t, tt.want,
*opts.Metrics.Probe.UseInternalState)
+ })
+ }
}
diff --git a/metrics/rpc/error_classifier.go b/metrics/rpc/error_classifier.go
index bc87dd9b3..2a63409f1 100644
--- a/metrics/rpc/error_classifier.go
+++ b/metrics/rpc/error_classifier.go
@@ -102,6 +102,8 @@ const (
// [ErrorTypeServiceUnavailable]. The application-layer CodeBizError maps to
// [ErrorTypeBusinessFailed]. Plain Go errors (those that do not carry a Triple
// status code) and every unhandled code fall back to [ErrorTypeUnknown].
+// A typed nil [*triple_protocol.Error] (or one hidden behind wrapping) also
+// falls back to [ErrorTypeUnknown], which is handled by
[triple_protocol.CodeOf].
//
// [ErrorTypeNetworkFailure] (network layer) and [ErrorTypeCodec] (codec layer)
// are reserved but not yet produced: some transport-layer faults and codec
diff --git a/metrics/rpc/error_classifier_test.go
b/metrics/rpc/error_classifier_test.go
index 378991ba0..becf7ac02 100644
--- a/metrics/rpc/error_classifier_test.go
+++ b/metrics/rpc/error_classifier_test.go
@@ -18,7 +18,9 @@
package rpc
import (
+ "context"
"errors"
+ "fmt"
"testing"
)
@@ -198,3 +200,134 @@ func TestClassifyError_AllErrorTypesClassification(t
*testing.T) {
})
}
}
+
+// noUnwrapError is an error type that hides the wrapped error behind its
+// Error() method and does not implement Unwrap, so errors.As cannot see
+// through it.
+type noUnwrapError struct {
+ cause error
+}
+
+func (e *noUnwrapError) Error() string {
+ return fmt.Sprintf("no unwrap: %v", e.cause)
+}
+
+func TestClassifyError_Nil(t *testing.T) {
+ var typedNil *triple_protocol.Error
+ tests := []struct {
+ name string
+ err error
+ }{
+ {
+ name: "untyped nil",
+ err: nil,
+ },
+ {
+ name: "typed nil triple error",
+ err: typedNil,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, ErrorTypeUnknown, classifyError(tt.err))
+ })
+ }
+}
+
+func TestClassifyError_Boundary(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want ErrorType
+ }{
+ {
+ name: "empty standard error",
+ err: errors.New(""),
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "context deadline exceeded",
+ err: context.DeadlineExceeded,
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "context canceled",
+ err: context.Canceled,
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "plain error wrapping a triple error without
Unwrap",
+ err: &noUnwrapError{cause:
triple_protocol.NewError(triple_protocol.CodeDeadlineExceeded,
errors.New("timeout"))},
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "CodeInternal not mapped to a dedicated type yet",
+ err:
triple_protocol.NewError(triple_protocol.CodeInternal, errors.New("internal")),
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "CodeDataLoss",
+ err:
triple_protocol.NewError(triple_protocol.CodeDataLoss, errors.New("data loss")),
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "CodeUnauthenticated",
+ err:
triple_protocol.NewError(triple_protocol.CodeUnauthenticated,
errors.New("unauthenticated")),
+ want: ErrorTypeUnknown,
+ },
+ {
+ name: "empty message triple error is classified by its
code",
+ err:
triple_protocol.NewError(triple_protocol.CodeUnavailable, nil),
+ want: ErrorTypeServiceUnavailable,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, classifyError(tt.err))
+ })
+ }
+}
+
+func TestClassifyError_Wrapped(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want ErrorType
+ }{
+ {
+ name: "single level wrap of timeout",
+ err: fmt.Errorf("rpc failed: %w",
triple_protocol.NewError(triple_protocol.CodeDeadlineExceeded,
errors.New("timeout"))),
+ want: ErrorTypeTimeout,
+ },
+ {
+ name: "single level wrap of limit",
+ err: fmt.Errorf("rpc failed: %w",
triple_protocol.NewError(triple_protocol.CodeResourceExhausted,
errors.New("limit"))),
+ want: ErrorTypeLimit,
+ },
+ {
+ name: "single level wrap of service unavailable",
+ err: fmt.Errorf("rpc failed: %w",
triple_protocol.NewError(triple_protocol.CodeUnavailable,
errors.New("unavailable"))),
+ want: ErrorTypeServiceUnavailable,
+ },
+ {
+ name: "single level wrap of business failed",
+ err: fmt.Errorf("rpc failed: %w",
triple_protocol.NewError(triple_protocol.CodeBizError, errors.New("biz
error"))),
+ want: ErrorTypeBusinessFailed,
+ },
+ {
+ name: "multi level wrap",
+ err: fmt.Errorf("outer: %w", fmt.Errorf("inner: %w",
triple_protocol.NewError(triple_protocol.CodeDeadlineExceeded,
errors.New("timeout")))),
+ want: ErrorTypeTimeout,
+ },
+ {
+ name: "wrap of plain error stays unknown",
+ err: fmt.Errorf("rpc failed: %w", errors.New("plain
error")),
+ want: ErrorTypeUnknown,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.want, classifyError(tt.err))
+ })
+ }
+}
diff --git a/protocol/triple/triple_protocol/code.go
b/protocol/triple/triple_protocol/code.go
index 8bd65bc9d..b854dfde7 100644
--- a/protocol/triple/triple_protocol/code.go
+++ b/protocol/triple/triple_protocol/code.go
@@ -223,9 +223,11 @@ func (c *Code) UnmarshalText(data []byte) error {
}
// CodeOf returns the error's status code if it is or wraps an [*Error] and
-// [CodeUnknown] otherwise.
+// [CodeUnknown] otherwise. A typed nil [*Error] (e.g. one assigned to an
+// [error] interface and returned as nil) is not caught by err == nil and
+// also yields [CodeUnknown] instead of panicking.
func CodeOf(err error) Code {
- if tripleErr, ok := asError(err); ok {
+ if tripleErr, ok := asError(err); ok && tripleErr != nil {
return tripleErr.Code()
}
return CodeUnknown
diff --git a/protocol/triple/triple_protocol/error_test.go
b/protocol/triple/triple_protocol/error_test.go
index 7b2615d67..1de35f6ad 100644
--- a/protocol/triple/triple_protocol/error_test.go
+++ b/protocol/triple/triple_protocol/error_test.go
@@ -86,6 +86,11 @@ func TestCodeOf(t *testing.T) {
CodeUnavailable,
)
assert.Equal(t, CodeOf(errors.New("foo")), CodeUnknown)
+ // a typed nil *Error assigned to the error interface escapes err ==
nil,
+ // but must not panic and must fall back to CodeUnknown
+ var typedNil *Error
+ assert.Equal(t, CodeOf(typedNil), CodeUnknown)
+ assert.Equal(t, CodeOf(fmt.Errorf("wrapped: %w", typedNil)),
CodeUnknown)
}
func TestErrorDetails(t *testing.T) {