package provider import ( "errors" "fmt" ) // The provider error taxonomy. Every error a Provider method returns should // be classifiable as exactly one of these four, via Wrap or Class. The // reconciler branches on this classification to decide how to react — // never on a provider-specific error type. var ( // ErrNotFound means the instance doesn't exist. Get returning this is // normal, not exceptional. ErrNotFound = errors.New("provider: instance not found") // ErrQuotaExceeded means the request failed because of a cloud quota or // rate limit. The reconciler backs off slowly (minutes, not seconds) // rather than hammering an exhausted quota. ErrQuotaExceeded = errors.New("provider: quota exceeded") // ErrTransient means the request failed for a reason likely to clear on // retry (network blip, 5xx, timeout). The reconciler retries with the // workqueue's normal exponential backoff. ErrTransient = errors.New("provider: transient error") // ErrPermanent means the request failed for a reason that will not // clear on retry (bad config, permission denied, invalid argument). The // reconciler stops retrying and surfaces the failure in status. ErrPermanent = errors.New("provider: permanent error") ) // Error wraps a provider-specific error with enough context for logs, while // remaining classifiable via errors.Is against one of the four sentinels // above and unwrappable via errors.As to the underlying SDK error. type Error struct { Class error // one of ErrNotFound/ErrQuotaExceeded/ErrTransient/ErrPermanent Op string // "create", "get", "delete", "list" Provider string // the configured provider name, e.g. "gcp-eu" ID string // providerID, if known Err error // underlying error; may be nil } func (e *Error) Error() string { msg := fmt.Sprintf("provider %s: %s", e.Provider, e.Op) if e.ID != "" { msg += fmt.Sprintf(" %s", e.ID) } msg += ": " + e.Class.Error() if e.Err != nil { msg += fmt.Sprintf(" (%v)", e.Err) } return msg } // Unwrap exposes both the taxonomy sentinel and the underlying error, via // Go's multi-error unwrap (errors.Unwrap() []error). This is what lets // errors.Is(err, ErrQuotaExceeded) and errors.As(err, &googleErr) both // succeed against the same *Error value: errors.Is/As walk every branch. func (e *Error) Unwrap() []error { if e.Err != nil { return []error{e.Class, e.Err} } return []error{e.Class} } // Wrap builds an *Error classified under class. err is the underlying // SDK/network error and may be nil when there's nothing further to wrap // (e.g. classifying a bare HTTP status code). func Wrap(class error, op, providerName, id string, err error) error { return &Error{Class: class, Op: op, Provider: providerName, ID: id, Err: err} } // Class returns the taxonomy sentinel matching err — ErrNotFound, // ErrQuotaExceeded, ErrPermanent, or ErrTransient, checked via errors.Is so // it works against any error built with Wrap regardless of how deeply it's // wrapped elsewhere. An unclassified error (nil, or not built with Wrap) // defaults to ErrTransient: retrying is always safer than latching Failed // on an error nobody has taught this package to recognize. func Class(err error) error { if err == nil { return nil } switch { case errors.Is(err, ErrNotFound): return ErrNotFound case errors.Is(err, ErrQuotaExceeded): return ErrQuotaExceeded case errors.Is(err, ErrPermanent): return ErrPermanent default: return ErrTransient } }