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
Unwrapreturns a slice, and what that buys you - How HTTP statuses map onto sentinels
The sentinels#
| Sentinel | Message | Retried? | Meaning |
|---|---|---|---|
ErrAuth | skyl: authentication failed | Never | The credential was missing, malformed, or rejected. The same key will fail again. |
ErrRateLimit | skyl: rate limited | Yes | The provider is throttling. Retried with backoff, honouring Retry-After. |
ErrNotFound | skyl: not found | Never | The model or endpoint does not exist for this account. Because model IDs pass through unvalidated, a typo arrives here rather than failing locally. |
ErrBadRequest | skyl: invalid request | Never | The request was malformed. Often produced locally by Request.Validate. |
ErrServer | skyl: provider server error | Yes | The provider failed on its side. Retried with backoff. |
ErrUnsupported | skyl: unsupported by this provider | Never | This 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. |
ErrRefusal | skyl: model declined the request | Never | The model or its safety classifiers declined. The same prompt gets the same answer. |
ErrStreamClosed | skyl: stream is closed | Never | The 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
}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() []errorfunc (e *Error) Unwrap() []errorIt returns both the sentinel and the underlying cause. That is what makes this work:
// 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:
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:
| Status | Sentinel |
|---|---|
| 401, 403, 407 | ErrAuth |
| 404 | ErrNotFound |
| 429 | ErrRateLimit |
| 408, 409 | ErrServer — transient, worth another attempt |
| 5xx | ErrServer |
| other 4xx | ErrBadRequest |
| 2xx / 3xx | nil — 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:
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#
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.Asrecovers*skyl.Errorwith provider, status, message and retry hint.Unwrapreturns sentinel and cause, soerrors.Is(err, context.DeadlineExceeded)works.- An
Errornever contains credentials;Bodyis truncated at 2 KB. 408and409classify asErrServer, because both are worth retrying.- Local validation failures are
ErrBadRequesttoo, 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
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.