Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 93 additions & 2 deletions errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"time"

"github.com/charmbracelet/x/exp/slice"
"golang.org/x/net/http2"
Expand Down Expand Up @@ -69,13 +70,36 @@ type ProviderError struct {
// payload. Treat it as a hint worth acting on when present rather than a
// field every error carries.
ErrorType string

// RetryAt is the earliest moment the provider said a retry may succeed,
// parsed from the retry-after or retry-after-ms response header, or
// supplied by a provider that knows its quota renewal schedule. The zero
// value means the provider named no deadline.
//
// The retry middleware waits until this moment when the wait fits its
// budget (RetryOptions.MaxRetryDelay) and otherwise falls back to
// exponential backoff. Error() surfaces it so a failed operation reports
// when it becomes worth retrying, not just that it failed.
RetryAt time.Time

// QuotaExhausted marks a rate limit that a billing or subscription quota
// hit, not a per-minute throttle. A quota renews on the provider's
// billing schedule — hours or days away — so when RetryAt is unknown,
// retrying within any automatic budget cannot succeed and IsRetryable
// reports false. With RetryAt set, the retry middleware decides from the
// deadline instead.
QuotaExhausted bool
}

func (m *ProviderError) Error() string {
message := m.Message
if wait := time.Until(m.RetryAt); wait > 0 {
message = fmt.Sprintf("%s; retry at %s (in %s)", message, m.RetryAt.UTC().Format(time.RFC3339), wait.Round(time.Second))
}
if m.Title == "" {
return m.Message
return message
}
return fmt.Sprintf("%s: %s", m.Title, m.Message)
return fmt.Sprintf("%s: %s", m.Title, message)
}

// Unwrap returns the underlying cause so errors.Is and errors.As can
Expand All @@ -90,7 +114,14 @@ func (m *ProviderError) Unwrap() error {
// evaluates to true, if the HTTP status code indicates a retryable
// condition (408, 409, 429, or any 5xx), or if the cause is a transient
// HTTP/2 transport error.
//
// A quota exhaustion without a known renewal deadline is the exception: the
// quota renews on the provider's billing schedule, which no automatic
// retry budget can outwait, so it reports false.
func (m *ProviderError) IsRetryable() bool {
if m.QuotaExhausted && m.RetryAt.IsZero() {
return false
}
if m.TransientError {
return true
}
Expand Down Expand Up @@ -332,6 +363,66 @@ func ErrorTitleForStatusCode(statusCode int) string {
return strings.ToLower(http.StatusText(statusCode))
}

// quotaExhaustedFragment identifies a billing or subscription quota hit in
// an error body. Synthetic (synthetic.new) answers an exhausted subscription
// with "You've exceeded your subscription rate limits"; per-minute throttles
// say "rate limit" without "subscription" and must keep their default retry
// classification.
const quotaExhaustedFragment = "exceeded your subscription"

// IsQuotaExhaustedMessage reports whether a provider error message names a
// billing or subscription quota hit, as opposed to a per-minute throttle.
// Providers that cannot classify quota exhaustion from their SDK error type
// match the message against this instead, so ProviderError.QuotaExhausted is
// set for the retry middleware to act on.
func IsQuotaExhaustedMessage(message string) bool {
return strings.Contains(strings.ToLower(message), quotaExhaustedFragment)
}

// RetryAtFromHeaders reports the earliest retry moment a provider declared
// in the standard rate-limit response headers: retry-after-ms (milliseconds,
// the more precise form) and retry-after (delay-seconds or an HTTP-date). It
// returns the zero time when the headers carry no usable deadline.
//
// Header names are matched case-insensitively, since ProviderError stores
// headers as the provider's SDK spelled them. Per-limit reset hints such as
// x-ratelimit-reset-requests describe when a token bucket refills, not when
// a retry is sanctioned, so they are deliberately not consulted.
func RetryAtFromHeaders(headers map[string]string) time.Time {
if headers == nil {
return time.Time{}
}

header := func(name string) (string, bool) {
for k, v := range headers {
if strings.EqualFold(k, name) {
return v, true
}
}
return "", false
}

if retryAfterMs, exists := header("retry-after-ms"); exists {
if ms, err := strconv.ParseFloat(retryAfterMs, 64); err == nil && ms > 0 {
return time.Now().Add(time.Duration(ms * float64(time.Millisecond)))
}
}

// About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
if retryAfter, exists := header("retry-after"); exists {
if seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil && seconds > 0 {
return time.Now().Add(time.Duration(seconds * float64(time.Second)))
}
// Try parsing as HTTP date; one already in the past names no future
// moment, so it is no deadline at all.
if t, err := time.Parse(time.RFC1123, retryAfter); err == nil && t.After(time.Now()) {
return t
}
}

return time.Time{}
}

// NoObjectGeneratedError is returned when object generation fails
// due to parsing errors, validation errors, or model failures.
type NoObjectGeneratedError struct {
Expand Down
133 changes: 133 additions & 0 deletions errors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ package fantasy
import (
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"

"golang.org/x/net/http2"
)
Expand Down Expand Up @@ -185,3 +188,133 @@ func TestConnectionLostIsRetryable(t *testing.T) {
})
}
}

func TestRetryAtFromHeaders(t *testing.T) {
t.Parallel()

t.Run("delay seconds", func(t *testing.T) {
t.Parallel()

retryAt := RetryAtFromHeaders(map[string]string{"retry-after": "30"})
if wait := time.Until(retryAt); wait <= 0 || wait > 30*time.Second {
t.Errorf("time.Until(RetryAt) = %v, want within (0s, 30s]", wait)
}
})

t.Run("milliseconds take precedence", func(t *testing.T) {
t.Parallel()

retryAt := RetryAtFromHeaders(map[string]string{"retry-after-ms": "500", "retry-after": "30"})
if wait := time.Until(retryAt); wait <= 0 || wait > 500*time.Millisecond {
t.Errorf("time.Until(RetryAt) = %v, want within (0s, 500ms]", wait)
}
})

t.Run("http date", func(t *testing.T) {
t.Parallel()

want := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
retryAt := RetryAtFromHeaders(map[string]string{"retry-after": want.Format(time.RFC1123)})
if diff := retryAt.Sub(want); diff < -time.Second || diff > time.Second {
t.Errorf("RetryAt = %v, want %v", retryAt, want)
}
})

t.Run("case-insensitive header names", func(t *testing.T) {
t.Parallel()

if RetryAtFromHeaders(map[string]string{"Retry-After": "30"}).IsZero() {
t.Error("RetryAt is zero for a canonical-cased Retry-After header")
}
})

t.Run("missing, invalid, and non-positive values", func(t *testing.T) {
t.Parallel()

for name, headers := range map[string]map[string]string{
"nil": nil,
"none": {},
"invalid": {"retry-after": "invalid"},
"zero": {"retry-after": "0"},
"negative": {"retry-after": "-5"},
"invalid-ms": {"retry-after-ms": "soon"},
"past date": {"retry-after": time.Now().Add(-time.Hour).UTC().Format(time.RFC1123)},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()

if retryAt := RetryAtFromHeaders(headers); !retryAt.IsZero() {
t.Errorf("RetryAtFromHeaders(%v) = %v, want the zero time", headers, retryAt)
}
})
}
})
}

func TestIsQuotaExhaustedMessage(t *testing.T) {
t.Parallel()

cases := []struct {
name string
message string
want bool
}{
{"synthetic subscription body", `{"error":"You've exceeded your subscription rate limits. Upgrade, or try again later."}`, true},
{"case-insensitive", "you have EXCEEDED YOUR SUBSCRIPTION quota", true},
{"plain rate limit", "Rate limit reached for requests", false},
{"unrelated", "model overloaded", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

if got := IsQuotaExhaustedMessage(tc.message); got != tc.want {
t.Errorf("IsQuotaExhaustedMessage(%q) = %v, want %v", tc.message, got, tc.want)
}
})
}
}

func TestProviderErrorRetryDeadline(t *testing.T) {
t.Parallel()

t.Run("Error names a future deadline", func(t *testing.T) {
t.Parallel()

err := &ProviderError{
Title: "too many requests",
Message: "quota gone",
RetryAt: time.Now().Add(90 * time.Minute),
}
if msg := err.Error(); !strings.Contains(msg, "retry at ") || !strings.Contains(msg, "(in 1h30m") {
t.Errorf("Error() = %q, want it to name the deadline and the wait", msg)
}
})

t.Run("Error omits a passed deadline", func(t *testing.T) {
t.Parallel()

err := &ProviderError{Title: "too many requests", Message: "quota gone", RetryAt: time.Now().Add(-time.Minute)}
if msg := err.Error(); strings.Contains(msg, "retry at") {
t.Errorf("Error() = %q, want no deadline suffix once it has passed", msg)
}
})

t.Run("quota exhaustion without a deadline is not retryable", func(t *testing.T) {
t.Parallel()

err := &ProviderError{StatusCode: http.StatusTooManyRequests, QuotaExhausted: true}
if err.IsRetryable() {
t.Error("quota exhaustion with no renewal deadline must not be retried")
}
})

t.Run("quota exhaustion with a deadline stays retryable", func(t *testing.T) {
t.Parallel()

err := &ProviderError{StatusCode: http.StatusTooManyRequests, QuotaExhausted: true, RetryAt: time.Now().Add(time.Minute)}
if !err.IsRetryable() {
t.Error("quota exhaustion with a renewal deadline is retryable at that moment")
}
})
}
7 changes: 6 additions & 1 deletion providers/anthropic/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,25 @@ const awsCredentialErrorFragment = "failed to refresh cached credentials" //noli

func toProviderErr(err error) error {
if apiErr, ok := errors.AsType[*anthropic.Error](err); ok {
headers := toHeaderMap(apiErr.Response.Header)
providerErr := &fantasy.ProviderError{
Title: apiErrorTitle(apiErr),
Message: apiErr.Error(),
Cause: apiErr,
URL: apiErr.Request.URL.String(),
StatusCode: apiErr.StatusCode,
RequestBody: apiErr.DumpRequest(true),
ResponseHeaders: toHeaderMap(apiErr.Response.Header),
ResponseHeaders: headers,
ResponseBody: apiErr.DumpResponse(true),
ErrorType: string(apiErr.Type()),
TransientError: fantasy.TransientStreamErrorTypes[string(apiErr.Type())],
RetryAt: fantasy.RetryAtFromHeaders(headers),
}

parseContextTooLargeError(apiErr.Error(), providerErr)
if fantasy.IsQuotaExhaustedMessage(apiErr.Error()) {
providerErr.QuotaExhausted = true
}

return providerErr
}
Expand Down
7 changes: 6 additions & 1 deletion providers/openai/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,24 @@ var (
func toProviderErr(err error) error {
if apiErr, ok := errors.AsType[*openai.Error](err); ok {
message := toProviderErrMessage(apiErr)
headers := toHeaderMap(apiErr.Response.Header)
providerErr := &fantasy.ProviderError{
Title: cmp.Or(fantasy.ErrorTitleForStatusCode(apiErr.StatusCode), "provider request failed"),
Message: message,
Cause: apiErr,
URL: apiErr.Request.URL.String(),
StatusCode: apiErr.StatusCode,
RequestBody: apiErr.DumpRequest(true),
ResponseHeaders: toHeaderMap(apiErr.Response.Header),
ResponseHeaders: headers,
ResponseBody: apiErr.DumpResponse(true),
ErrorType: cmp.Or(apiErr.Type, apiErr.Code),
RetryAt: fantasy.RetryAtFromHeaders(headers),
}

parseContextTooLargeError(message, providerErr)
if fantasy.IsQuotaExhaustedMessage(message) {
providerErr.QuotaExhausted = true
}

return providerErr
}
Expand Down
Loading
Loading