diff --git a/pkg/foundation/cerrors/conduiterr/conduiterr.go b/pkg/foundation/cerrors/conduiterr/conduiterr.go new file mode 100644 index 000000000..59e720bdc --- /dev/null +++ b/pkg/foundation/cerrors/conduiterr/conduiterr.go @@ -0,0 +1,195 @@ +// Copyright © 2026 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package conduiterr defines ConduitError, Conduit's uniform, machine-actionable +// error type. A ConduitError carries a stable Code, a human message, and optional +// fields an agent or UI can act on (failing config path, a suggestion, and a +// structured Fix). It composes with cerrors: it wraps a cause and preserves stack +// traces, so errors.As/Is work as usual. +// +// This package is the Go representation. The wire representation (google.rpc.Status +// with a google.rpc.ErrorInfo detail) and the mapping between the two live in +// status.go, with a round-trip test that keeps the two encodings from drifting. +// +// Construction is only via New or Wrap; a bare &ConduitError{} literal carries no +// stack trace (ConduitError is not itself an xerrors value) and is forbidden. +package conduiterr + +import ( + "sort" + + "github.com/conduitio/conduit/pkg/foundation/cerrors" + "google.golang.org/grpc/codes" +) + +// Code is a stable error identity plus the gRPC status category it maps to. The +// category is set explicitly at registration, never inferred from the Reason +// string (substring-matching "not_found" is not naming discipline). +type Code struct { + reason string + grpcCode codes.Code +} + +// Reason is the stable, dotted identifier (e.g. "connector.plugin_not_found"). It +// is the source of truth for docs, llms.txt, and UI rendering. +func (c Code) Reason() string { return c.reason } + +// GRPCCode is the gRPC status category this Code maps to at the API boundary. +func (c Code) GRPCCode() codes.Code { return c.grpcCode } + +func (c Code) String() string { return c.reason } + +// IsZero reports whether c is the zero Code (unregistered). +func (c Code) IsZero() bool { return c.reason == "" } + +var registry = map[string]Code{} + +// Register adds a Code to the global registry and returns it. Each boundary package +// registers the codes it owns, so the registry is the single source of truth for +// docs, llms.txt, and the UI. It panics on a duplicate reason so collisions are +// caught at init, not in production. +// +// Register MUST be called only from a package-level var initializer (as the +// foundational codes below are). The registry is not synchronized; Go's package +// initialization is single-threaded, so init-time registration is race-free, but +// calling Register after program start or from a goroutine is a data race. +func Register(reason string, grpcCode codes.Code) Code { + if _, ok := registry[reason]; ok { + panic("conduiterr: duplicate code reason " + reason) + } + c := Code{reason: reason, grpcCode: grpcCode} + registry[reason] = c + return c +} + +// LookupCode returns the registered Code for a reason, and whether it was found. +func LookupCode(reason string) (Code, bool) { + c, ok := registry[reason] + return c, ok +} + +// Codes returns every registered Code, sorted by reason. Used to generate the +// error-code reference for docs, llms.txt, and the UI. +func Codes() []Code { + out := make([]Code, 0, len(registry)) + for _, c := range registry { + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].reason < out[j].reason }) + return out +} + +// Foundational codes. More are registered by the packages that own each boundary +// as migration proceeds; these seed the registry and cover the fallbacks. +var ( + // CodeUnknown is the fallback when an un-coded error reaches a boundary. + CodeUnknown = Register("internal.unknown", codes.Internal) + // CodeInternal is a coded internal failure. + CodeInternal = Register("internal.error", codes.Internal) + // CodeInvalidArgument is a generic invalid-input error. + CodeInvalidArgument = Register("common.invalid_argument", codes.InvalidArgument) + // CodeNotFound is a generic not-found error. + CodeNotFound = Register("common.not_found", codes.NotFound) + // CodeConnectorPluginNotFound is raised when a referenced connector plugin + // cannot be located. + CodeConnectorPluginNotFound = Register("connector.plugin_not_found", codes.NotFound) +) + +// Fix is a structured, machine-appliable change that resolves an error. The same +// Fix is applied by the CLI `repair` command and the MCP `repair` tool, so a human +// and an agent get the identical remediation rather than divergent capabilities. +type Fix struct { + ConfigPath string `json:"configPath,omitempty"` // JSON-pointer to the field to change + Op string `json:"op,omitempty"` // "set" | "remove" | "add" + Value string `json:"value,omitempty"` // new value, for set/add +} + +// ConduitError is Conduit's uniform user-facing error. Construct with New or Wrap. +type ConduitError struct { + Code Code // stable identity + gRPC category + Message string // human-readable, no secrets + ConfigPath string // JSON-pointer into the offending config, optional + Suggestion string // human-readable fix hint, optional + Fix *Fix // structured, machine-appliable fix, optional + DocsURL string // optional + + err error // wrapped cause, always non-nil after construction +} + +// Error returns this error's Message only, not the wrapped cause's text. Use +// Unwrap (or errors.Is/As) to reach the chain. +func (e *ConduitError) Error() string { return e.Message } + +// Unwrap returns the wrapped cause so errors.Is/As and the cerrors stack-trace +// walk traverse the chain. +func (e *ConduitError) Unwrap() error { return e.err } + +// New creates a leaf ConduitError with no wrapped cause. It guarantees a non-empty +// stack trace so the error is never trace-less. +// +// Caveat: because cerrors.New attributes the frame to its immediate caller, the +// captured frame points into this constructor, not the site that called New. For +// accurate call-site attribution, build the cause with cerrors.New/Errorf at the +// real origination point and pass it to Wrap. A skip-aware helper in cerrors that +// would let New capture the true caller is tracked as a follow-up. +func New(code Code, msg string) *ConduitError { + return &ConduitError{Code: code, Message: msg, err: cerrors.New(msg)} +} + +// Wrap creates a ConduitError around an existing cause, adding context. +// +// Message replaces (does not concatenate) the cause's text — Error() returns msg, +// and the cause remains reachable via Unwrap. So msg should be the boundary-level, +// user-facing description, not a "context: %w" fragment. +// +// Invariant: a boundary must not shadow an inner code. If cause already carries a +// *ConduitError, the returned error takes that inner Code (and inherits its +// structured fields where the wrap does not set them), so errors.As never surfaces +// a code that hides a more specific inner one. Note this means Wrap's own code +// argument is ignored when an inner ConduitError is present — an explicit-override +// escape hatch is a tracked follow-up. +func Wrap(code Code, msg string, cause error) *ConduitError { + e := &ConduitError{Code: code, Message: msg, err: cause} + + var inner *ConduitError + if cause != nil && cerrors.As(cause, &inner) { + e.Code = inner.Code // pass the inner code through, do not shadow it + if e.ConfigPath == "" { + e.ConfigPath = inner.ConfigPath + } + if e.Suggestion == "" { + e.Suggestion = inner.Suggestion + } + if e.Fix == nil { + e.Fix = inner.Fix + } + if e.DocsURL == "" { + e.DocsURL = inner.DocsURL + } + } + + if e.err == nil { + e.err = cerrors.New(msg) // guarantee a frame even if cause was nil + } + return e +} + +// Get returns the first *ConduitError in err's chain, if any. +func Get(err error) (*ConduitError, bool) { + var ce *ConduitError + if cerrors.As(err, &ce) { + return ce, true + } + return nil, false +} diff --git a/pkg/foundation/cerrors/conduiterr/conduiterr_test.go b/pkg/foundation/cerrors/conduiterr/conduiterr_test.go new file mode 100644 index 000000000..81aae9ba1 --- /dev/null +++ b/pkg/foundation/cerrors/conduiterr/conduiterr_test.go @@ -0,0 +1,99 @@ +// Copyright © 2026 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conduiterr_test + +import ( + "testing" + + "github.com/conduitio/conduit/pkg/foundation/cerrors" + "github.com/conduitio/conduit/pkg/foundation/cerrors/conduiterr" + "github.com/matryer/is" +) + +func frameCount(err error) int { + frames, _ := cerrors.GetStackTrace(err).([]cerrors.Frame) + return len(frames) +} + +// TestNew_CapturesStackTrace is the guard for the design's crux: a leaf +// ConduitError with no wrapped cause must still carry a stack trace. Without the +// cerrors.New in the constructor it would carry none (ConduitError is not an +// xerrors value), silently breaking observability. +func TestNew_CapturesStackTrace(t *testing.T) { + is := is.New(t) + err := conduiterr.New(conduiterr.CodeInvalidArgument, "id is required") + is.True(frameCount(err) > 0) + is.Equal(err.Error(), "id is required") +} + +// TestBareLiteral_HasNoStackTrace documents why New/Wrap are mandatory: a raw +// struct literal carries no frame at all. +func TestBareLiteral_HasNoStackTrace(t *testing.T) { + is := is.New(t) + err := &conduiterr.ConduitError{Code: conduiterr.CodeInvalidArgument, Message: "x"} + is.Equal(frameCount(err), 0) +} + +func TestWrap_PreservesStackTraceFromCause(t *testing.T) { + is := is.New(t) + cause := cerrors.New("boom") + err := conduiterr.Wrap(conduiterr.CodeInternal, "while doing x", cause) + is.True(frameCount(err) > 0) +} + +func TestWrap_GuaranteesFrameWithNilCause(t *testing.T) { + is := is.New(t) + err := conduiterr.Wrap(conduiterr.CodeInternal, "no cause", nil) + is.True(frameCount(err) > 0) +} + +// TestWrap_DoesNotShadowInnerCode enforces the no-nested-code invariant: wrapping +// an inner ConduitError with a more generic code must not hide the inner code, and +// unset structured fields are inherited from the inner error. +func TestWrap_DoesNotShadowInnerCode(t *testing.T) { + is := is.New(t) + inner := conduiterr.New(conduiterr.CodeConnectorPluginNotFound, "no such plugin") + inner.ConfigPath = "/connectors/0/plugin" + + outer := conduiterr.Wrap(conduiterr.CodeInternal, "provisioning failed", inner) + + is.Equal(outer.Code.Reason(), conduiterr.CodeConnectorPluginNotFound.Reason()) + is.Equal(outer.Code.GRPCCode(), conduiterr.CodeConnectorPluginNotFound.GRPCCode()) + is.Equal(outer.ConfigPath, "/connectors/0/plugin") +} + +func TestGet_FindsConduitErrorThroughWrapping(t *testing.T) { + is := is.New(t) + base := conduiterr.New(conduiterr.CodeNotFound, "missing") + wrapped := cerrors.Errorf("context: %w", base) + + ce, ok := conduiterr.Get(wrapped) + is.True(ok) + is.Equal(ce.Code.Reason(), conduiterr.CodeNotFound.Reason()) +} + +func TestGet_ReturnsFalseForPlainError(t *testing.T) { + is := is.New(t) + _, ok := conduiterr.Get(cerrors.New("plain")) + is.True(!ok) +} + +func TestRegistry_FoundationalCodesPresent(t *testing.T) { + is := is.New(t) + c, ok := conduiterr.LookupCode("connector.plugin_not_found") + is.True(ok) + is.Equal(c.Reason(), "connector.plugin_not_found") + is.True(len(conduiterr.Codes()) >= 5) +} diff --git a/pkg/foundation/cerrors/conduiterr/status.go b/pkg/foundation/cerrors/conduiterr/status.go new file mode 100644 index 000000000..575981e18 --- /dev/null +++ b/pkg/foundation/cerrors/conduiterr/status.go @@ -0,0 +1,136 @@ +// Copyright © 2026 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conduiterr + +import ( + "strings" + + "github.com/conduitio/conduit/pkg/foundation/cerrors" + json "github.com/goccy/go-json" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/status" +) + +// errorDomain is the google.rpc.ErrorInfo domain for all Conduit error codes. +const errorDomain = "conduit" + +// valid coerces s to valid UTF-8. proto string fields (the status message and the +// ErrorInfo metadata) require valid UTF-8; a single stray byte from config-derived +// input would otherwise fail the detail's marshal and silently drop the entire +// structured error. Replacing invalid bytes with U+FFFD keeps the error intact. +func valid(s string) string { return strings.ToValidUTF8(s, "�") } + +// metadata keys carried on the ErrorInfo detail. +const ( + mdConfigPath = "configPath" + mdSuggestion = "suggestion" + mdDocsURL = "docsUrl" + mdFix = "fix" +) + +// ToStatus encodes a ConduitError as a gRPC *status.Status: the top-level code is +// the code's gRPC category and the message is the error message (unchanged from +// today's shape), plus an additive google.rpc.ErrorInfo detail carrying the stable +// reason and the structured fields. grpc-gateway preserves the detail into the +// HTTP JSON body via protojson, so HTTP consumers see it too. +func ToStatus(e *ConduitError) *status.Status { + st := status.New(e.Code.GRPCCode(), valid(e.Message)) + + md := map[string]string{} + if e.ConfigPath != "" { + md[mdConfigPath] = valid(e.ConfigPath) + } + if e.Suggestion != "" { + md[mdSuggestion] = valid(e.Suggestion) + } + if e.DocsURL != "" { + md[mdDocsURL] = valid(e.DocsURL) + } + if e.Fix != nil { + fix := Fix{ConfigPath: valid(e.Fix.ConfigPath), Op: valid(e.Fix.Op), Value: valid(e.Fix.Value)} + if b, err := json.Marshal(fix); err == nil { + md[mdFix] = string(b) + } + } + + info := &errdetails.ErrorInfo{ + // Reason is sanitized like every other wire string: an invalid-UTF-8 byte + // in any proto string field fails the whole detail's marshal, and ToStatus' + // fallback would then silently drop all structured data. Registry reasons + // are ASCII today, but a reason arriving from a non-Go plugin is not trusted. + Reason: valid(e.Code.Reason()), + Domain: errorDomain, + Metadata: md, + } + + withDetails, err := st.WithDetails(info) + if err != nil { + // WithDetails only fails if the detail can't be marshaled, which can't + // happen for a well-formed ErrorInfo; fall back to the detail-less status. + return st + } + return withDetails +} + +// FromStatus reconstructs a ConduitError from a gRPC *status.Status. It reads the +// ErrorInfo detail if present; otherwise it synthesizes a code from the gRPC +// category so the result is always a well-formed ConduitError. It never panics on +// malformed input. +func FromStatus(st *status.Status) *ConduitError { + if st == nil { + return New(CodeUnknown, "") + } + + for _, d := range st.Details() { + info, ok := d.(*errdetails.ErrorInfo) + if !ok || info.GetDomain() != errorDomain { + continue + } + + // For a registered reason the LOCAL registry is authoritative for the gRPC + // category (reasons are stable; a reason's category does not change across + // versions). For an unknown reason we fall back to the wire status' code so + // the error still round-trips. If a client and server ever disagree on a + // registered reason's category, the local mapping wins by design. + code, ok := LookupCode(info.GetReason()) + if !ok { + code = Code{reason: info.GetReason(), grpcCode: st.Code()} + } + + md := info.GetMetadata() + ce := &ConduitError{ + Code: code, + Message: st.Message(), + ConfigPath: md[mdConfigPath], + Suggestion: md[mdSuggestion], + DocsURL: md[mdDocsURL], + err: cerrors.New(st.Message()), + } + if raw, ok := md[mdFix]; ok && raw != "" { + var fix Fix + if err := json.Unmarshal([]byte(raw), &fix); err == nil { + ce.Fix = &fix + } + } + return ce + } + + // No Conduit ErrorInfo detail: synthesize from the gRPC category. + return &ConduitError{ + Code: Code{reason: CodeUnknown.reason, grpcCode: st.Code()}, + Message: st.Message(), + err: cerrors.New(st.Message()), + } +} diff --git a/pkg/foundation/cerrors/conduiterr/status_test.go b/pkg/foundation/cerrors/conduiterr/status_test.go new file mode 100644 index 000000000..4e8241f98 --- /dev/null +++ b/pkg/foundation/cerrors/conduiterr/status_test.go @@ -0,0 +1,164 @@ +// Copyright © 2026 Meroxa, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package conduiterr_test + +import ( + "strings" + "testing" + + "github.com/conduitio/conduit/pkg/foundation/cerrors/conduiterr" + "github.com/matryer/is" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// TestRoundTrip_AllFields is the guard that keeps the Go and wire encodings from +// drifting: a fully-populated ConduitError survives ToStatus -> FromStatus intact. +func TestRoundTrip_AllFields(t *testing.T) { + is := is.New(t) + + orig := conduiterr.New(conduiterr.CodeConnectorPluginNotFound, "connector plugin not found") + orig.ConfigPath = "/connectors/1/plugin" + orig.Suggestion = "run `conduit connectors install `" + orig.DocsURL = "https://conduit.io/docs/errors/connector.plugin_not_found" + orig.Fix = &conduiterr.Fix{ConfigPath: "/connectors/1/plugin", Op: "set", Value: "builtin:postgres"} + + got := conduiterr.FromStatus(conduiterr.ToStatus(orig)) + + is.Equal(got.Code.Reason(), orig.Code.Reason()) + is.Equal(got.Code.GRPCCode(), orig.Code.GRPCCode()) + is.Equal(got.Message, orig.Message) + is.Equal(got.ConfigPath, orig.ConfigPath) + is.Equal(got.Suggestion, orig.Suggestion) + is.Equal(got.DocsURL, orig.DocsURL) + is.Equal(got.Fix, orig.Fix) +} + +// TestToStatus_TopLevelShapeUnchanged confirms the additive-compat claim: the +// top-level gRPC code and message are exactly the code's category and the message, +// with the structured data living only in an additive detail. +func TestToStatus_TopLevelShapeUnchanged(t *testing.T) { + is := is.New(t) + orig := conduiterr.New(conduiterr.CodeConnectorPluginNotFound, "not found") + + st := conduiterr.ToStatus(orig) + + is.Equal(st.Code(), codes.NotFound) + is.Equal(st.Message(), "not found") + is.Equal(len(st.Details()), 1) // exactly the additive ErrorInfo detail +} + +// TestFromStatus_UnregisteredReason exercises the synthetic-code fallback: a +// reason not in the registry still reconstructs, carrying the wire reason and the +// status' gRPC category. +func TestFromStatus_UnregisteredReason(t *testing.T) { + is := is.New(t) + st := status.New(codes.FailedPrecondition, "custom") + st, err := st.WithDetails(&errdetails.ErrorInfo{ + Reason: "some.unregistered_reason", + Domain: "conduit", + Metadata: map[string]string{"configPath": "/x"}, + }) + is.NoErr(err) + + got := conduiterr.FromStatus(st) + + is.Equal(got.Code.Reason(), "some.unregistered_reason") + is.Equal(got.Code.GRPCCode(), codes.FailedPrecondition) + is.Equal(got.ConfigPath, "/x") +} + +// TestFromStatus_NoDetail synthesizes a ConduitError from a plain status with no +// Conduit detail, without panicking. +func TestFromStatus_NoDetail(t *testing.T) { + is := is.New(t) + got := conduiterr.FromStatus(status.New(codes.NotFound, "plain not found")) + + is.Equal(got.Message, "plain not found") + is.Equal(got.Code.GRPCCode(), codes.NotFound) + is.True(got.Error() == "plain not found") +} + +func TestFromStatus_Nil(t *testing.T) { + is := is.New(t) + got := conduiterr.FromStatus(nil) + is.True(got != nil) // never returns nil +} + +// Note on Code.Reason sanitization: ToStatus coerces Reason to valid UTF-8 like +// every other wire field. It is defensive (a Go caller could Register a code with a +// non-ASCII reason); it can't be exercised via a crafted *status.Status here because +// proto validates UTF-8 on marshal — WithDetails rejects an invalid-UTF-8 reason +// outright — which is also why the bad reason can't reach FromStatus over the wire. + +// FuzzRoundTrip ensures ToStatus/FromStatus never panic on arbitrary codes and +// string field values, round-trip them faithfully, and are idempotent on a relay +// (a second round-trip must not lose data — the check that would catch a field +// that silently fails to marshal). +func FuzzRoundTrip(f *testing.F) { + f.Add(uint8(0), "connector plugin not found", "/connectors/0/plugin", "install it", "https://x", "builtin:pg") + f.Add(uint8(3), "", "", "", "", "") + f.Add(uint8(1), "msg with \"quotes\" and \n newlines", "/a/b", "sug\tgest", "u", "v w") + f.Add(uint8(2), "bad \x82 utf8", "/p\x82", "s\xff", "d\x80", "\x81val") // invalid-UTF-8 seed + + want := func(s string) string { return strings.ToValidUTF8(s, "�") } + + f.Fuzz(func(t *testing.T, codeSel uint8, msg, configPath, suggestion, docsURL, fixValue string) { + all := conduiterr.Codes() + code := all[int(codeSel)%len(all)] + + e := conduiterr.New(code, msg) + e.ConfigPath = configPath + e.Suggestion = suggestion + e.DocsURL = docsURL + if fixValue != "" { + e.Fix = &conduiterr.Fix{ConfigPath: configPath, Op: "set", Value: fixValue} + } + + got := conduiterr.FromStatus(conduiterr.ToStatus(e)) + + if got.Code.Reason() != code.Reason() { + t.Fatalf("code reason: got %q want %q", got.Code.Reason(), code.Reason()) + } + if got.Code.GRPCCode() != code.GRPCCode() { + t.Fatalf("code grpc: got %v want %v", got.Code.GRPCCode(), code.GRPCCode()) + } + if got.Message != want(msg) { + t.Fatalf("message: got %q want %q", got.Message, want(msg)) + } + if got.ConfigPath != want(configPath) { + t.Fatalf("configPath: got %q want %q", got.ConfigPath, want(configPath)) + } + if got.Suggestion != want(suggestion) { + t.Fatalf("suggestion: got %q want %q", got.Suggestion, want(suggestion)) + } + if got.DocsURL != want(docsURL) { + t.Fatalf("docsURL: got %q want %q", got.DocsURL, want(docsURL)) + } + if fixValue != "" && (got.Fix == nil || got.Fix.Value != want(fixValue)) { + t.Fatalf("fix: got %+v want value %q", got.Fix, want(fixValue)) + } + + // Relay: a second round-trip must be idempotent. If any field silently + // failed to marshal (dropping the detail), the relay diverges. + relay := conduiterr.FromStatus(conduiterr.ToStatus(got)) + if relay.Code.Reason() != got.Code.Reason() || relay.Message != got.Message || + relay.ConfigPath != got.ConfigPath || relay.Suggestion != got.Suggestion || + relay.DocsURL != got.DocsURL { + t.Fatalf("relay not idempotent: %+v vs %+v", relay, got) + } + }) +}