The Provider interface (Create/Get/Delete/ListByTag), Instance, and CreateRequest that every cloud backend implements — kept independent of api/v1alpha1 so this package has no CRD-type coupling. Error taxonomy (ErrNotFound/ErrQuotaExceeded/ErrTransient/ErrPermanent) wrapped via a multi-error Unwrap() []error, so errors.Is and errors.As both work off the same value: the reconciler branches on classification, logs keep the underlying SDK error. Unclassified errors default to ErrTransient — retrying is always safer than latching Failed. Deterministic instance naming (SHA-256 -> base32 -> 16 chars, 22 total with the "proxy-" prefix) satisfying GCP's RFC1035 name rules with headroom, and idempotency-tested across 10k UIDs with zero collisions. --providers-config YAML parsing (config.go) with fail-fast validation: unknown type, duplicate name, missing gcp.project, mismatched type/config-block, and strict-mode rejection of unknown keys. internal/provider/registry/registry.go takes its type->constructor map as a parameter rather than hardcoding it, so the package has zero import on internal/provider/mock or internal/provider/gcp (neither exists yet — mock is Step 3, gcp is Step 8) and compiles today. Explicit wiring moves to the composition root in cmd/main.go (Step 10). Deferred internal/provider/metrics.go (the WithMetrics decorator) to Step 9, where the Prometheus vectors it needs actually get built — nothing in this step depends on it. internal/provider at 96.2% coverage, internal/provider/registry at 100%. make test green. Co-Authored-By: Claude <noreply@anthropic.com>
92 lines
3.4 KiB
Go
92 lines
3.4 KiB
Go
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
|
|
}
|
|
}
|