Files
a2bf528ca0 feat(domain): introduce error slugs (#12030)
# Which Problems Are Solved

As part of https://github.com/zitadel/zitadel/issues/11917 we want to
introduce error slugs so (API) clients can rely on stable,
machine-readable errors and act accordingly.

# How the Problems Are Solved

- Added a `NewSlug` helper function in the domain package.
- Added `ErrorDetails` to the `ZitadelError`
- Added an `zitadel.error.v2.ErrorDetail` proto message
- Updated the connectRPC error interceptor to map new slug based errors
to the new `ErrorDetail`
- Defined some common slugs and error functions like internal errors
- Defined (session) specific slugs used in the `DeleteSession` and
`CheckUser` functions and replaced old implementations
- Updated integration tests to check specific errors if the relation
database feature is enabled
- Updated doc and guideline to reflect the latest changes and decisions
- Updated DeleteSession endpoint API to list possible slugs

# Additional Changes

None

# Additional Context

- closes #11957

---------

Co-authored-by: Wim Van Laer <[email protected]>
2026-04-13 09:47:04 +02:00

295 lines
8.0 KiB
Go

package zerrors
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"runtime/debug"
"sync/atomic"
"github.com/zitadel/sloggcp"
)
//go:generate enumer -type=Kind -trimprefix=Kind -json
type Kind int
const (
// KindCanceled indicates that the operation was canceled, typically by the
// caller.
KindCanceled Kind = 1
// KindUnknown indicates that the operation failed for an unknown reason.
KindUnknown Kind = 2
// KindInvalidArgument indicates that client supplied an invalid argument.
KindInvalidArgument Kind = 3
// KindDeadlineExceeded indicates that deadline expired before the operation
// could complete.
KindDeadlineExceeded Kind = 4
// KindNotFound indicates that some requested entity (for example, a file or
// directory) was not found.
KindNotFound Kind = 5
// KindAlreadyExists indicates that client attempted to create an entity (for
// example, a file or directory) that already exists.
KindAlreadyExists Kind = 6
// KindPermissionDenied indicates that the caller doesn't have permission to
// execute the specified operation.
KindPermissionDenied Kind = 7
// KindResourceExhausted indicates that some resource has been exhausted. For
// example, a per-user quota may be exhausted or the entire file system may
// be full.
KindResourceExhausted Kind = 8
// KindPreconditionFailed indicates that the system is not in a state
// required for the operation's execution.
KindPreconditionFailed Kind = 9
// KindAborted indicates that operation was aborted by the system, usually
// because of a concurrency issue such as a sequencer check failure or
// transaction abort.
KindAborted Kind = 10
// KindOutOfRange indicates that the operation was attempted past the valid
// range (for example, seeking past end-of-file).
KindOutOfRange Kind = 11
// KindUnimplemented indicates that the operation isn't implemented,
// supported, or enabled in this service.
KindUnimplemented Kind = 12
// KindInternal indicates that some invariants expected by the underlying
// system have been broken. This Kind is reserved for serious errors.
KindInternal Kind = 13
// KindUnavailable indicates that the service is currently unavailable. This
// is usually temporary, so clients can back off and retry idempotent
// operations.
KindUnavailable Kind = 14
// KindDataLoss indicates that the operation has resulted in unrecoverable
// data loss or corruption.
KindDataLoss Kind = 15
// KindUnauthenticated indicates that the request does not have valid
// authentication credentials for the operation.
KindUnauthenticated Kind = 16
)
// IDRecover is the error ID used for errors created after recovery from panics.
const IDRecover = "RECOVER"
// Because errors are created through singletons, config is global.
var (
enableReportLocation atomic.Bool
enableStackTrace atomic.Bool
gcpErrorReporting atomic.Bool
)
// EnableReportLocation enables or disables report locations for created errors.
func EnableReportLocation(enable bool) {
enableReportLocation.Store(enable)
}
// EnableStackTrace enables or disables stack traces for created errors.
func EnableStackTrace(enable bool) {
enableStackTrace.Store(enable)
}
// GCPErrorReportingEnabled enables or disables special handling for GCP Error Reporting.
// It must be enabled when using a sloggcp handler to avoid duplicate report locations and stack traces.
func GCPErrorReportingEnabled(enable bool) {
gcpErrorReporting.Store(enable)
}
type ZitadelError struct {
Kind Kind
Parent error
Message string
ID string
Details ErrorDetails
// location where the error was created
reportLocation *sloggcp.ReportLocation
// stack trace at the point the error was created
stackTrace []byte
hasStackTrace bool
}
type Slug string
type ErrorDetails json.Marshaler
type ErrorDetailsMap map[string]any
func (m ErrorDetailsMap) MarshalJSON() ([]byte, error) {
return json.Marshal(map[string]any(m))
}
func ThrowError(parent error, id, message string) error {
return CreateZitadelError(KindUnknown, parent, id, message, 1)
}
// CreateZitadelError creates a ZitadelError of the given kind.
// If ReportLocation or StackTrace are enabled, they are added to the error.
// The skip parameter defines how many stack frames to skip when determining
// the report location.
// A skip value of 0 means the location where CreateZitadelError is called.
// When the parent error is also a ZitadelError, the report location and stack trace
// are inherited from the parent error.
func CreateZitadelError(kind Kind, parent error, id, message string, skip int) *ZitadelError {
err := &ZitadelError{
Kind: kind,
Parent: parent,
ID: id,
Message: message,
}
var target *ZitadelError
if errors.As(parent, &target) {
// inherit stack trace and report location from parent error
err.reportLocation = target.reportLocation
err.stackTrace = target.stackTrace
err.hasStackTrace = target.hasStackTrace
return err
}
if enableReportLocation.Load() {
err.reportLocation = sloggcp.NewReportLocation(skip + 1)
}
if enableStackTrace.Load() {
err.stackTrace = debug.Stack()
err.hasStackTrace = true
}
return err
}
func (err *ZitadelError) Error() string {
if err.Parent != nil {
return fmt.Sprintf("ID=%s Message=%s Parent=(%v)", err.ID, err.Message, err.Parent)
}
return fmt.Sprintf("ID=%s Message=%s", err.ID, err.Message)
}
func (err *ZitadelError) Unwrap() error {
return err.GetParent()
}
func (err *ZitadelError) GetParent() error {
return err.Parent
}
func (err *ZitadelError) GetMessage() string {
return err.Message
}
func (err *ZitadelError) SetMessage(msg string) {
err.Message = msg
}
func (err *ZitadelError) GetID() string {
return err.ID
}
func (err *ZitadelError) GetDetails() ErrorDetails {
return err.Details
}
func (err *ZitadelError) WithDetails(details ErrorDetails) *ZitadelError {
err.Details = details
return err
}
func (err *ZitadelError) Is(target error) bool {
t, ok := target.(*ZitadelError)
if !ok {
return false
}
if t.Kind != err.Kind {
return false
}
if t.ID != "" && t.ID != err.ID {
return false
}
if t.Message != "" && t.Message != err.Message {
return false
}
if t.Parent != nil && !errors.Is(err.Parent, t.Parent) {
return false
}
if t.Details != nil {
if err.Details == nil {
return false
}
targetData, detailsErr := t.Details.MarshalJSON()
if detailsErr != nil {
return false
}
errData, detailsErr := err.Details.MarshalJSON()
if detailsErr != nil {
return false
}
return bytes.Equal(targetData, errData)
}
return true
}
func IsZitadelError(err error) bool {
zitadelErr := new(ZitadelError)
return errors.As(err, &zitadelErr)
}
func AsZitadelError(err error) (*ZitadelError, bool) {
zitadelErr := new(ZitadelError)
ok := errors.As(err, &zitadelErr)
return zitadelErr, ok
}
var _ slog.LogValuer = (*ZitadelError)(nil)
func (err *ZitadelError) LogValue() slog.Value {
attributes := make([]slog.Attr, 0, 6)
attributes = append(attributes,
slog.String("kind", err.Kind.String()),
slog.String("message", err.Message),
slog.String("id", err.ID),
)
if err.Parent != nil {
attributes = append(attributes, slog.Any("parent", err.Parent))
}
// if gcp error reporting is enabled, log the error as a group without
// report location and stack trace, as those are handled by the handler.
if gcpErrorReporting.Load() {
return slog.GroupValue(attributes...)
}
if err.reportLocation != nil {
attributes = append(attributes, slog.Any("reportLocation", err.reportLocation))
}
if err.hasStackTrace {
attributes = append(attributes, slog.String("stackTrace", string(err.stackTrace)))
}
return slog.GroupValue(attributes...)
}
// ReportLocation implements [sloggcp.ReportLocationError].
func (err *ZitadelError) ReportLocation() *sloggcp.ReportLocation {
return err.reportLocation
}
// StackTrace implements [sloggcp.StackTraceError].
func (err *ZitadelError) StackTrace() (trace []byte, ok bool) {
return err.stackTrace, err.hasStackTrace
}
var (
_ sloggcp.ReportLocationError = (*ZitadelError)(nil)
_ sloggcp.StackTraceError = (*ZitadelError)(nil)
)