Skip to content
skyl

What Is Never Retried

Five sentinels skyl refuses to retry, and one case that surprises people.

Retrying the wrong failure is worse than not retrying at all: it burns quota, adds latency, and delays the error an operator needs to see. skyl retries three classes of failure and refuses five.

You will learn

  • Which failures are never retried, and why each one is hopeless
  • The certificate case, which surprises people
  • How Error.Retryable() decides
  • When to retry at your own level instead

The five#

SentinelWhy retrying is pointless
ErrAuthThe same key will fail again. Rotating it is a human action, not a retry.
ErrBadRequestThe request is malformed. Sending it again produces the same rejection.
ErrNotFoundThe model does not exist for this account. It will not appear between attempts.
ErrRefusalThe same prompt gets the same answer, and retrying looks like policy evasion.
ErrUnsupportedThe provider structurally cannot express the request. This is produced locally.

ErrUnsupported is often returned before any network call, so there is nothing to retry in the first place.

The three that are#

ErrRateLimit, ErrServer, and unclassified transport failures — a dial timeout, a reset connection, a DNS blip. These are the ones where the same request, sent again in a moment, plausibly succeeds.

The certificate case#

This is the general shape of the rule: retryability is about whether the world might differ in a moment, not about whether the error was transport-level.

How the decision is made#

func (e *Error) Retryable() bool {
	switch {
	case errors.Is(e.Kind, ErrRateLimit), errors.Is(e.Kind, ErrServer):
		return true
	case e.Kind == nil:
		// Unclassified with no reply at all — a dial timeout, a reset
		// connection. Worth another attempt.
		return e.StatusCode == 0
	default:
		return false
	}
}
func (e *Error) Retryable() bool {
	switch {
	case errors.Is(e.Kind, ErrRateLimit), errors.Is(e.Kind, ErrServer):
		return true
	case e.Kind == nil:
		// Unclassified with no reply at all — a dial timeout, a reset
		// connection. Worth another attempt.
		return e.StatusCode == 0
	default:
		return false
	}
}

An unclassified error with a status is not retried: the provider answered, skyl just could not classify what it said, and repeating the request is unlikely to produce a more classifiable answer.

You can call it yourself:

goCompiles
var e *skyl.Error
if errors.As(err, &e) && !e.Retryable() {
	// A permanent failure. Alert rather than backing off.
	return fmt.Errorf("permanent failure on %s: %w", e.Provider, err)
}
var e *skyl.Error
if errors.As(err, &e) && !e.Retryable() {
	// A permanent failure. Alert rather than backing off.
	return fmt.Errorf("permanent failure on %s: %w", e.Provider, err)
}

Cancellation stops everything#

Retrying also stops immediately when your context is cancelled — skyl checks ctx.Err() after each attempt, and the backoff wait is itself cancellable:

Output
skyl: waiting to retry: context deadline exceeded

So a deadline you set is always respected, regardless of how many retries remain configured.

Models is special#

Client.Models additionally refuses to retry ErrUnsupported, because a provider that cannot list models will never be able to. That is a capability statement rather than a failure.

When to retry yourself#

Deep diveThe cases skyl deliberately leaves to you

Cross-provider fallback. Failing over to a different vendor is a product decision — the second model answers differently, costs differently, and may have different data-residency implications. See Choosing a Provider.

Mid-stream failures. skyl retries only the handshake, because replaying a partially consumed response would duplicate output the caller has seen. In a batch job that buffers, retrying is trivially safe and you should. In a UI, it is not.

Refusals after rephrasing. Retrying the same prompt is pointless. Retrying a rephrased one is a different request, and entirely reasonable — but only you can decide what the rephrasing should be.

In each case skyl declines not because it could not, but because the right answer depends on knowledge skyl does not have.

Recap

  • Never retried: ErrAuth, ErrBadRequest, ErrNotFound, ErrRefusal, ErrUnsupported.
  • Retried: ErrRateLimit, ErrServer, and transport failures with no status.
  • Certificate failures are not retried — a misconfiguration is not a blip.
  • An unclassified error with a status is not retried; the provider did answer.
  • Context cancellation stops retrying immediately, including mid-backoff.
  • Cross-provider fallback, mid-stream retry and rephrasing are deliberately yours.

Try out some challenges

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

Alert on permanent failures only

Page a human for failures that will not fix themselves, and stay quiet about the ones backoff is already absorbing.

Show hint

Retryable() is the distinction — but remember a retryable error that exhausted its retries is also worth knowing about.

Show solution
goCompiles
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	if ev.Err == nil {
		return
	}
	var e *skyl.Error
	if !errors.As(ev.Err, &e) {
		return
	}

	switch {
	case !e.Retryable():
		// Will never succeed on its own. Page.
		alerts.Page("skyl permanent failure", "provider", e.Provider, "kind", e.Kind)
	case ev.Attempt > 0:
		// Retryable, but we are already burning attempts. Trend it.
		metrics.Inc("skyl.retry", "provider", e.Provider)
	}
})
skyl.WithHook(func(_ context.Context, ev skyl.HookEvent) {
	if ev.Err == nil {
		return
	}
	var e *skyl.Error
	if !errors.As(ev.Err, &e) {
		return
	}

	switch {
	case !e.Retryable():
		// Will never succeed on its own. Page.
		alerts.Page("skyl permanent failure", "provider", e.Provider, "kind", e.Kind)
	case ev.Attempt > 0:
		// Retryable, but we are already burning attempts. Trend it.
		metrics.Inc("skyl.retry", "provider", e.Provider)
	}
})

Note the hook fires per attempt, so a permanent failure pages once — it is never retried — while a retryable one increments a counter you can alert on by rate rather than by occurrence.

Edit this page on GitHub