Files
majordomo/llm/errors.go
T
steve 323558ed72 feat(llm): ReasoningEffort request option and ErrUnsupported sentinel
Groundwork for the provider phase: reasoning levels map to native knobs
(OpenAI reasoning_effort, Ollama think); ErrUnsupported marks declared
capability mismatches that chains advance past without health penalty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:40:47 +02:00

127 lines
3.6 KiB
Go

package llm
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"syscall"
)
// ErrorClass buckets errors for retry/failover decisions.
type ErrorClass int
const (
// ClassTransient errors may succeed on retry or on another target:
// rate limits, server errors, timeouts, connection failures.
ClassTransient ErrorClass = iota
// ClassPermanent errors will not improve on retry of the same request:
// malformed requests, auth failures, model-not-found.
ClassPermanent
)
// ErrModelNotFound marks a permanent "this target does not know this model"
// condition. Chains advance past it without penalizing the target's health.
var ErrModelNotFound = errors.New("model not found")
// ErrUnsupported marks a request the target cannot serve by declaration —
// e.g. images that cannot be normalized to its capabilities, or a feature
// (tools, structured output) it does not support. Permanent for the target,
// but chains advance past it without penalizing health: another element may
// well be able to serve the request.
var ErrUnsupported = errors.New("request unsupported by target")
// APIError is a structured provider error carrying enough context to
// classify it and to debug it.
type APIError struct {
// Provider and Model identify the target that failed.
Provider string
Model string
// Status is the HTTP status code, or 0 when the failure was not an HTTP
// response (connection error, decode error, ...).
Status int
// Code is the provider-specific error code, when one was supplied.
Code string
// Message is the provider's human-readable error message.
Message string
// Err is the wrapped underlying cause, if any.
Err error
}
func (e *APIError) Error() string {
var b strings.Builder
fmt.Fprintf(&b, "%s/%s", e.Provider, e.Model)
if e.Status != 0 {
fmt.Fprintf(&b, ": HTTP %d", e.Status)
}
if e.Code != "" {
fmt.Fprintf(&b, " [%s]", e.Code)
}
if e.Message != "" {
fmt.Fprintf(&b, ": %s", e.Message)
}
if e.Err != nil {
fmt.Fprintf(&b, ": %v", e.Err)
}
return b.String()
}
func (e *APIError) Unwrap() error {
if e.Err != nil {
return e.Err
}
if e.Status == http.StatusNotFound {
return ErrModelNotFound
}
return nil
}
// Classify buckets an error as transient or permanent.
//
// The default policy (overridable via health configuration):
// - context.Canceled is permanent — the caller gave up; retrying defies
// their intent. context.DeadlineExceeded is transient.
// - Network timeouts, refused/reset connections, and DNS failures are
// transient ("high demand" conditions).
// - HTTP 400/401/403/404/405/422 (and ErrModelNotFound) are permanent;
// 408/429 and all 5xx are transient.
// - Anything unrecognized is transient: when in doubt, failing over to the
// next target in a chain can only help availability.
func Classify(err error) ErrorClass {
if err == nil {
return ClassTransient
}
if errors.Is(err, context.Canceled) {
return ClassPermanent
}
if errors.Is(err, context.DeadlineExceeded) {
return ClassTransient
}
if errors.Is(err, ErrModelNotFound) || errors.Is(err, ErrUnsupported) {
return ClassPermanent
}
if errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, syscall.ECONNRESET) {
return ClassTransient
}
if _, ok := errors.AsType[net.Error](err); ok {
return ClassTransient
}
if apiErr, ok := errors.AsType[*APIError](err); ok && apiErr.Status != 0 {
switch {
case apiErr.Status == http.StatusRequestTimeout, // 408
apiErr.Status == http.StatusTooManyRequests, // 429
apiErr.Status >= 500:
return ClassTransient
case apiErr.Status >= 400:
return ClassPermanent
}
}
return ClassTransient
}