%#v prints struct field names, so a key that spells one (for example "body" on ProviderError) leaked even though Error() was redacted. - llm.ProviderError and llm.RateLimitedError gain GoString returning their already-redacted Error text. - Both adapters' transportFailure now holds its fully redacted text and gains GoString. - The redact backstop now wraps every non-nil error when a key is set, so the returned error and every unwrapped layer print through redactedError.GoString, never a raw struct such as fmt.wrapError. - redactedError.As also refuses a match whose %#v form holds the key. - Contract test in both adapters: field-name keys (body, status, text, RetryAfter, msg, err, key, kind) on HTTP 500 "down" and 429, checking %#v of the returned error, every unwrapped layer and errors.As results. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
4.9 KiB
Go
140 lines
4.9 KiB
Go
package llm
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
var (
|
|
// ErrAuth means the vendor rejected the credentials.
|
|
ErrAuth = errors.New("llm: authentication failed")
|
|
// ErrRateLimited means the vendor throttled the request. The concrete
|
|
// error is a *RateLimitedError carrying the requested wait.
|
|
ErrRateLimited = errors.New("llm: rate limited")
|
|
// ErrTimeout means the request did not finish within its timeout.
|
|
ErrTimeout = errors.New("llm: request timed out")
|
|
// ErrBadOutput means the model replied but the reply is unusable, e.g.
|
|
// structured output that does not parse.
|
|
ErrBadOutput = errors.New("llm: unusable model output")
|
|
// ErrProvider means the vendor returned an unexpected HTTP status. The
|
|
// concrete error is a *ProviderError carrying the status and a redacted
|
|
// body excerpt.
|
|
ErrProvider = errors.New("llm: provider error")
|
|
)
|
|
|
|
// RateLimitedError is a throttled request. It matches ErrRateLimited.
|
|
// Adapters build it with NewRateLimitedError so its text never holds their
|
|
// credential.
|
|
type RateLimitedError struct {
|
|
// RetryAfter is how long the vendor asked callers to wait; 0 means it
|
|
// did not say.
|
|
RetryAfter time.Duration
|
|
// text is the redacted Error text set by NewRateLimitedError; "" means
|
|
// the error was built without a credential.
|
|
text string
|
|
}
|
|
|
|
// NewRateLimitedError returns the error for a throttled request. secret is
|
|
// the credential sent with the request, or "" when the request sent none.
|
|
// Every occurrence of a non-empty secret is removed from the error text
|
|
// when it is built, even where the fixed text happens to spell it, and the
|
|
// secret itself is not kept.
|
|
func NewRateLimitedError(retryAfter time.Duration, secret string) *RateLimitedError {
|
|
e := &RateLimitedError{RetryAfter: retryAfter}
|
|
e.text = Redact(e.Error(), secret)
|
|
return e
|
|
}
|
|
|
|
func (e *RateLimitedError) Error() string {
|
|
if e.text != "" {
|
|
return e.text
|
|
}
|
|
if e.RetryAfter == 0 {
|
|
return ErrRateLimited.Error()
|
|
}
|
|
return fmt.Sprintf("%s: retry after %v", ErrRateLimited, e.RetryAfter)
|
|
}
|
|
|
|
// GoString keeps %#v to the redacted Error text instead of the fields.
|
|
func (e *RateLimitedError) GoString() string { return e.Error() }
|
|
|
|
// Is reports whether target is ErrRateLimited.
|
|
func (e *RateLimitedError) Is(target error) bool { return target == ErrRateLimited }
|
|
|
|
// MaxBodyExcerpt is the longest response-body excerpt, in bytes, a
|
|
// ProviderError keeps.
|
|
const MaxBodyExcerpt = 512
|
|
|
|
// redactionMarker replaces each secret in redacted text, unless the marker
|
|
// would itself hold the secret.
|
|
const redactionMarker = "[REDACTED]"
|
|
|
|
// Redact returns s with every occurrence of a non-empty secret removed. Each
|
|
// occurrence becomes "[REDACTED]" when the result then holds no secret;
|
|
// otherwise, because the marker contains the secret or joins with its
|
|
// neighbours to form it, occurrences are deleted until none remains. The
|
|
// result never contains secret.
|
|
func Redact(s, secret string) string {
|
|
if secret == "" {
|
|
return s
|
|
}
|
|
if out := strings.ReplaceAll(s, secret, redactionMarker); !strings.Contains(out, secret) {
|
|
return out
|
|
}
|
|
for strings.Contains(s, secret) {
|
|
s = strings.ReplaceAll(s, secret, "")
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ProviderError is an unexpected HTTP status from the vendor. It matches
|
|
// ErrProvider. Build it with NewProviderError, which redacts the body.
|
|
type ProviderError struct {
|
|
status int
|
|
body string
|
|
text string
|
|
}
|
|
|
|
// NewProviderError returns the error for an unexpected HTTP status. It
|
|
// always keeps a body excerpt of at most MaxBodyExcerpt bytes. secret is the
|
|
// credential sent with the request, or "" when the request sent none; every
|
|
// occurrence of a non-empty secret in body is removed by Redact before the
|
|
// body is cut, so no part of it survives, and again after the cut, so the
|
|
// truncation mark cannot complete it. It is removed from the complete error
|
|
// text too, even where the fixed text happens to spell it, and the secret
|
|
// itself is not kept.
|
|
func NewProviderError(status int, body []byte, secret string) *ProviderError {
|
|
s := strings.TrimSpace(Redact(string(body), secret))
|
|
if len(s) > MaxBodyExcerpt {
|
|
cut := MaxBodyExcerpt
|
|
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
|
cut--
|
|
}
|
|
s = Redact(s[:cut]+"…", secret)
|
|
}
|
|
text := fmt.Sprintf("%s: HTTP %d", ErrProvider, status)
|
|
if s != "" {
|
|
text += ": " + s
|
|
}
|
|
return &ProviderError{status: status, body: s, text: Redact(text, secret)}
|
|
}
|
|
|
|
// Status returns the HTTP status code.
|
|
func (e *ProviderError) Status() int { return e.status }
|
|
|
|
// Body returns the redacted, truncated response-body excerpt; it is empty
|
|
// when the vendor sent no body.
|
|
func (e *ProviderError) Body() string { return e.body }
|
|
|
|
func (e *ProviderError) Error() string { return e.text }
|
|
|
|
// GoString keeps %#v to the redacted Error text instead of the fields,
|
|
// whose names could spell the credential.
|
|
func (e *ProviderError) GoString() string { return e.text }
|
|
|
|
// Is reports whether target is ErrProvider.
|
|
func (e *ProviderError) Is(target error) bool { return target == ErrProvider }
|