Plandex / app /server /model /model_error.go
google-labs-jules[bot]
Final deployment for HF with landing page
93d826e
Raw
History Blame
7.7 kB
package model
import (
"fmt"
"log"
"net/http"
shared "plandex-shared"
"regexp"
"strconv"
"strings"
"time"
)
type HTTPError struct {
StatusCode int
Body string
Header http.Header
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("status code: %d, body: %s", e.StatusCode, e.Body)
}
// JSON-style `"retry_after_ms":1234`
var reJSON = regexp.MustCompile(`"retry_after_ms"\s*:\s*(\d+)`)
// Header- or text-style "Retry-After: 12" / "retry_after: 12s"
var reRetryAfter = regexp.MustCompile(
`retry[_\-\s]?after[_\-\s]?(?:[:\s]+)?(\d+)(ms|seconds?|secs?|s)?`,
)
// Free-form Azure style "Try again in 59 seconds."
// Also matches "Retry in 10 seconds."
var reTryAgain = regexp.MustCompile(
`(?:re)?try[_\-\s]+(?:again[_\-\s]+)?in[_\-\s]+(\d+)(ms|seconds?|secs?|s)?`,
)
func ClassifyErrMsg(msg string) *shared.ModelError {
log.Printf("Classifying error message: %s", msg)
msg = strings.ToLower(msg)
if strings.Contains(msg, "maximum context length") ||
strings.Contains(msg, "context length exceeded") ||
strings.Contains(msg, "exceed context limit") ||
strings.Contains(msg, "decrease input length") ||
strings.Contains(msg, "too many tokens") ||
strings.Contains(msg, "payload too large") ||
strings.Contains(msg, "payload is too large") ||
strings.Contains(msg, "input is too large") ||
strings.Contains(msg, "input too large") ||
strings.Contains(msg, "input is too long") ||
strings.Contains(msg, "input too long") {
log.Printf("Context too long error: %s", msg)
return &shared.ModelError{
Kind: shared.ErrContextTooLong,
Retriable: false,
RetryAfterSeconds: 0,
}
}
if strings.Contains(msg, "model_overloaded") ||
strings.Contains(msg, "model overloaded") ||
strings.Contains(msg, "server is overloaded") ||
strings.Contains(msg, "model is currently overloaded") ||
strings.Contains(msg, "overloaded_error") ||
strings.Contains(msg, "resource has been exhausted") {
log.Printf("Overloaded error: %s", msg)
return &shared.ModelError{
Kind: shared.ErrOverloaded,
Retriable: true,
RetryAfterSeconds: 0,
}
}
if strings.Contains(msg, "cache control") {
log.Printf("Cache control error: %s", msg)
return &shared.ModelError{
Kind: shared.ErrCacheSupport,
Retriable: true,
RetryAfterSeconds: 0,
}
}
log.Println("No error classification based on message")
return nil
}
func ClassifyModelError(code int, message string, headers http.Header, isClaudeMax bool) shared.ModelError {
msg := strings.ToLower(message)
// first of all, if it's claude max and a 429, it means the subscription limit was reached, so handle it accordingly
if isClaudeMax && code == 429 {
retryAfter := extractRetryAfter(headers, msg)
if retryAfter > 0 {
return shared.ModelError{
Kind: shared.ErrSubscriptionQuotaExhausted,
Retriable: true,
RetryAfterSeconds: retryAfter,
}
}
return shared.ModelError{
Kind: shared.ErrSubscriptionQuotaExhausted,
Retriable: false,
RetryAfterSeconds: 0,
}
}
// next try to classify the error based on the message only
msgRes := ClassifyErrMsg(msg)
if msgRes != nil {
log.Printf("Classified error message: %+v", msgRes)
return *msgRes
}
var res shared.ModelError
switch code {
case 429, 529:
res = shared.ModelError{
Kind: shared.ErrRateLimited,
Retriable: true,
RetryAfterSeconds: 0,
}
case 413:
res = shared.ModelError{
Kind: shared.ErrContextTooLong,
Retriable: false,
RetryAfterSeconds: 0,
}
// rare codes but they never succeed on retry if they do show up
case 501, 505:
res = shared.ModelError{
Kind: shared.ErrOther,
Retriable: false,
RetryAfterSeconds: 0,
}
default:
res = shared.ModelError{
Kind: shared.ErrOther,
Retriable: code >= 500 || strings.Contains(msg, "provider returned error"), // 'provider returned error' is from OpenRouter, and unless it's a non-retriable status code, it should still be retried since OpenRouter may switch to a different provider
RetryAfterSeconds: 0,
}
}
log.Printf("Model error: %+v", res)
// best‑effort parse of "Retry‑After" style hints in the message
if res.Retriable {
retryAfter := extractRetryAfter(headers, msg)
// if the retry after is greater than the max delay, then the error is not retriable
if retryAfter > MAX_RETRY_DELAY_SECONDS {
log.Printf("Retry after %d seconds is greater than the max delay of %d seconds - not retriable", retryAfter, MAX_RETRY_DELAY_SECONDS)
res.Retriable = false
} else {
res.RetryAfterSeconds = retryAfter
}
}
return res
}
func extractRetryAfter(h http.Header, body string) (sec int) {
now := time.Now()
// Retry-After header: seconds or HTTP-date
if v := h.Get("Retry-After"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return n
}
if t, err := time.Parse(http.TimeFormat, v); err == nil {
d := int(t.Sub(now).Seconds())
if d > 0 {
return d
}
}
}
// X-RateLimit-Reset epoch
if v := h.Get("X-RateLimit-Reset"); v != "" {
if reset, _ := strconv.ParseInt(v, 10, 64); reset > now.Unix() {
return int(reset - now.Unix())
}
}
lower := strings.ToLower(strings.TrimSpace(body))
// "retry_after_ms": 1234
if m := reJSON.FindStringSubmatch(lower); len(m) == 2 {
n, _ := strconv.Atoi(m[1])
return n / 1000
}
// "retry after 12"
if m := reRetryAfter.FindStringSubmatch(lower); len(m) >= 2 {
unit := ""
if len(m) == 3 {
unit = m[2]
}
return normalizeUnit(m[1], unit)
}
// "try again in 8"
if m := reTryAgain.FindStringSubmatch(lower); len(m) >= 2 {
unit := ""
if len(m) == 3 {
unit = m[2]
}
return normalizeUnit(m[1], unit)
}
return 0
}
func normalizeUnit(numStr, unit string) int {
n, _ := strconv.Atoi(numStr) // safe because the regex matched \d+
switch unit {
case "ms": // milliseconds
return n / 1000
case "sec", "secs", "second", "seconds", "s":
return n // already in seconds
default: // unit omitted ⇒ assume seconds
return n
}
}
func classifyBasicError(err error, isClaudeMax bool) shared.ModelError {
// if it's an http error, classify it based on the status code and body
if httpErr, ok := err.(*HTTPError); ok {
me := ClassifyModelError(
httpErr.StatusCode,
httpErr.Body,
httpErr.Header,
isClaudeMax,
)
return me
}
// try to classify the error based on the message only
msgRes := ClassifyErrMsg(err.Error())
if msgRes != nil {
return *msgRes
}
// Fall back to old heuristic – still keeps the signature identical
if isNonRetriableBasicErr(err) {
return shared.ModelError{Kind: shared.ErrOther, Retriable: false}
}
return shared.ModelError{Kind: shared.ErrOther, Retriable: true}
}
func isNonRetriableBasicErr(err error) bool {
errStr := err.Error()
// we don't want to retry on the errors below
if strings.Contains(errStr, "context deadline exceeded") || strings.Contains(errStr, "context canceled") {
log.Println("Context deadline exceeded or canceled - no retry")
return true
}
if strings.Contains(errStr, "status code: 400") &&
strings.Contains(errStr, "reduce the length of the messages") {
log.Println("Token limit exceeded - no retry")
return true
}
if strings.Contains(errStr, "status code: 401") {
log.Println("Invalid auth or api key - no retry")
return true
}
if strings.Contains(errStr, "status code: 429") && strings.Contains(errStr, "exceeded your current quota") {
log.Println("Current quota exceeded - no retry")
return true
}
return false
}