Skip to content
skyl

Error Classification

Eight sentinels, one Error type, and errors.Is all the way down.

Classification matters more than message text, because callers branch on it. skyl maps every provider failure onto one of eight sentinels and wraps it in a *skyl.Error carrying enough context to act on.

You will learn

  • The eight sentinels and what each means
  • How to recover the detail with errors.As
  • Why Unwrap returns a slice, and what that buys you
  • How HTTP statuses map onto sentinels

The sentinels#

SentinelMessageRetried?Meaning
ErrAuthskyl: authentication failedNeverThe credential was missing, malformed, or rejected. The same key will fail again.
ErrRateLimitskyl: rate limitedYesThe provider is throttling. Retried with backoff, honouring Retry-After.
ErrNotFoundskyl: not foundNeverThe model or endpoint does not exist for this account. Because model IDs pass through unvalidated, a typo arrives here rather than failing locally.
ErrBadRequestskyl: invalid requestNeverThe request was malformed. Often produced locally by Request.Validate.
ErrServerskyl: provider server errorYesThe provider failed on its side. Retried with backoff.
ErrUnsupportedskyl: unsupported by this providerNeverThis provider cannot express part of the request. Returned instead of silently dropping data, because a quietly discarded image looks like a model that ignored the question.
ErrRefusalskyl: model declined the requestNeverThe model or its safety classifiers declined. The same prompt gets the same answer.
ErrStreamClosedskyl: stream is closedNeverThe stream was used after being closed.

Branch on these with errors.Is. They are stable; provider messages are not.

The Error type#

type Error struct {
	Provider   string         // "anthropic"
	StatusCode int            // the HTTP status, or 0 for transport failures
	Message    string         // the provider's explanation, when it gave one
	Kind       error          // the sentinel this classifies as
	RetryAfter time.Duration  // how long the provider asked us to wait
	Body       string         // the raw payload, truncated at 2048 bytes
}
type Error struct {
	Provider   string         // "anthropic"
	StatusCode int            // the HTTP status, or 0 for transport failures
	Message    string         // the provider's explanation, when it gave one
	Kind       error          // the sentinel this classifies as
	RetryAfter time.Duration  // how long the provider asked us to wait
	Body       string         // the raw payload, truncated at 2048 bytes
}
goCompiles
var e *skyl.Error
if errors.As(err, &e) {
	log.Printf("%s returned %d: %s", e.Provider, e.StatusCode, e.Message)
	log.Printf("retryable: %v", e.Retryable())
}
var e *skyl.Error
if errors.As(err, &e) {
	log.Printf("%s returned %d: %s", e.Provider, e.StatusCode, e.Message)
	log.Printf("retryable: %v", e.Retryable())
}

Unwrap returns a slice#

func (e *Error) Unwrap() []error
func (e *Error) Unwrap() []error

It returns both the sentinel and the underlying cause. That is what makes this work:

goCompiles
// Both of these match on the same value.
errors.Is(err, skyl.ErrServer)
errors.Is(err, context.DeadlineExceeded)
// Both of these match on the same value.
errors.Is(err, skyl.ErrServer)
errors.Is(err, context.DeadlineExceeded)
Deep diveWhy this matters

Flattening the cause into a message string loses exactly the information that tells a timeout apart from a DNS failure or a rejected certificate. All three arrive as StatusCode: 0 with no sentinel — indistinguishable, unless the cause survives.

With the multi-error Unwrap, you can ask precise questions:

goCompiles
switch {
case errors.Is(err, context.DeadlineExceeded):
	// We ran out of time.
case errors.Is(err, context.Canceled):
	// The caller went away.
default:
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		// A transport-level timeout, distinct from our deadline.
	}
}
switch {
case errors.Is(err, context.DeadlineExceeded):
	// We ran out of time.
case errors.Is(err, context.Canceled):
	// The caller went away.
default:
	var netErr net.Error
	if errors.As(err, &netErr) && netErr.Timeout() {
		// A transport-level timeout, distinct from our deadline.
	}
}

errors.Is and errors.As handle the slice form natively, so nothing in your code changes to take advantage of it.

Status mapping#

ClassifyStatus is the fallback adapters use when a provider's own error type is not more precise:

StatusSentinel
401, 403, 407ErrAuth
404ErrNotFound
429ErrRateLimit
408, 409ErrServer — transient, worth another attempt
5xxErrServer
other 4xxErrBadRequest
2xx / 3xxnil — not an error

Adapters prefer a provider's own error type where it is more precise, and fall back to this. 408 and 409 mapping to ErrServer rather than ErrBadRequest is deliberate — a request timeout and a transient conflict are both worth retrying, and classifying them as client errors would mean skyl gave up on a failure that would have succeeded.

Local errors#

Not every error comes from a provider. Request.Validate produces ErrBadRequest without any network call, so the same branch catches both a malformed request you built and one the provider rejected:

goCompiles
if errors.Is(err, skyl.ErrBadRequest) {
	// Could be local validation or a provider 400 — both are your bug to fix,
	// and neither is worth retrying.
}
if errors.Is(err, skyl.ErrBadRequest) {
	// Could be local validation or a provider 400 — both are your bug to fix,
	// and neither is worth retrying.
}

Retryable#

goCompiles
var e *skyl.Error
if errors.As(err, &e) && e.Retryable() {
	// Rate limits, server errors, and transport failures.
}
var e *skyl.Error
if errors.As(err, &e) && e.Retryable() {
	// Rate limits, server errors, and transport failures.
}

An unclassified error with StatusCode == 0 is treated as retryable — a dial timeout or a reset connection, which is worth another attempt. An unclassified error with a status is not.

Recap

  • Eight sentinels; branch with errors.Is, never on message text.
  • errors.As recovers *skyl.Error with provider, status, message and retry hint.
  • Unwrap returns sentinel and cause, so errors.Is(err, context.DeadlineExceeded) works.
  • An Error never contains credentials; Body is truncated at 2 KB.
  • 408 and 409 classify as ErrServer, because both are worth retrying.
  • Local validation failures are ErrBadRequest too, so one branch catches both.

Try out some challenges

Each of these is solvable with what this page covered. Run them against the sandbox — no API key needed.

Write an error handler that never string-matches

Handle every sentinel with an appropriate action, and log enough detail to diagnose the rest.

Show hint

Use errors.Is for the branch and errors.As for the detail. They compose.

Show solution
goCompiles
func handle(err error) error {
	if err == nil {
		return nil
	}

	var e *skyl.Error
	errors.As(err, &e) // may leave e nil; that is fine below

	switch {
	case errors.Is(err, skyl.ErrAuth):
		return fmt.Errorf("credential rejected by %s: check the key", providerOf(e))
	case errors.Is(err, skyl.ErrNotFound):
		return fmt.Errorf("no such model on %s: check the spelling", providerOf(e))
	case errors.Is(err, skyl.ErrRateLimit):
		return fmt.Errorf("rate limited after retries; back off at the caller: %w", err)
	case errors.Is(err, skyl.ErrRefusal):
		return fmt.Errorf("the model declined; do not retry: %w", err)
	case errors.Is(err, skyl.ErrUnsupported):
		return fmt.Errorf("this provider cannot express the request: %w", err)
	case errors.Is(err, context.DeadlineExceeded):
		return fmt.Errorf("timed out: %w", err)
	default:
		if e != nil {
			log.Printf("unclassified: provider=%s status=%d body=%s", e.Provider, e.StatusCode, e.Body)
		}
		return err
	}
}

func providerOf(e *skyl.Error) string {
	if e == nil {
		return "the provider"
	}
	return e.Provider
}
func handle(err error) error {
	if err == nil {
		return nil
	}

	var e *skyl.Error
	errors.As(err, &e) // may leave e nil; that is fine below

	switch {
	case errors.Is(err, skyl.ErrAuth):
		return fmt.Errorf("credential rejected by %s: check the key", providerOf(e))
	case errors.Is(err, skyl.ErrNotFound):
		return fmt.Errorf("no such model on %s: check the spelling", providerOf(e))
	case errors.Is(err, skyl.ErrRateLimit):
		return fmt.Errorf("rate limited after retries; back off at the caller: %w", err)
	case errors.Is(err, skyl.ErrRefusal):
		return fmt.Errorf("the model declined; do not retry: %w", err)
	case errors.Is(err, skyl.ErrUnsupported):
		return fmt.Errorf("this provider cannot express the request: %w", err)
	case errors.Is(err, context.DeadlineExceeded):
		return fmt.Errorf("timed out: %w", err)
	default:
		if e != nil {
			log.Printf("unclassified: provider=%s status=%d body=%s", e.Provider, e.StatusCode, e.Body)
		}
		return err
	}
}

func providerOf(e *skyl.Error) string {
	if e == nil {
		return "the provider"
	}
	return e.Provider
}

The default branch logging Body is what makes an unknown failure diagnosable — it is the provider's own words, truncated, and it is the thing you will paste into a support ticket.

Edit this page on GitHub