Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog/unreleased/devin-rate-limit-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Honor Devin rate-limit reset times from the response body, and cool an exhausted usage quota for the whole account instead of until local midnight.

### 中文

- 采用 Devin 响应正文里的限流重置时间,并把用尽的用量配额冷却到整个账号,而不是冷却到本地午夜。
16 changes: 14 additions & 2 deletions internal/executor/classify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,24 @@ func TestClassifyErrorKeepsJSONRetryAfterWhenCodeIsSet(t *testing.T) {
}
}

func TestClassifyErrorHonorsProviderQuotaReset(t *testing.T) {
got := ClassifyError(&providers.Error{
Kind: KindQuota, Status: 429, Message: "usage quota exhausted", RetryAfter: 13 * time.Minute,
})
if got.Kind != KindQuota || got.Failover || got.Cooldown != 13*time.Minute || got.RetryAfter != 13*time.Minute {
t.Fatalf("quota reset hint must replace local midnight without failing over, got %+v", got)
}
if got.Model != "" {
t.Fatalf("quota cooldown must stay account-wide, got model %q", got.Model)
}
}

func TestClassifyErrorIgnoresProviderFailoverAndAuthCooldown(t *testing.T) {
got := ClassifyError(&providers.Error{
Kind: KindQuota, Status: 429, Message: "plan exhausted", RetryAfter: time.Minute,
Kind: KindQuota, Status: 429, Message: "plan exhausted",
})
if got.Kind != KindQuota || got.Failover || got.Cooldown <= 0 || got.Cooldown > 24*time.Hour {
t.Fatalf("quota must not fail over and must use local midnight, got %+v", got)
t.Fatalf("quota without a reset hint must use local midnight, got %+v", got)
}

got = ClassifyError(&providers.Error{
Expand Down
9 changes: 8 additions & 1 deletion internal/executor/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ func ClassifyError(err error) Classified {
classified.Failover = true
classified.Cooldown = 0
}
if classified.Kind == accounts.KindRateLimit {
switch classified.Kind {
case accounts.KindRateLimit:
switch {
case providerErr.Code == "4011" && providerErr.RetryAfter <= 0:
classified.Cooldown = 5 * time.Minute
Expand All @@ -130,6 +131,12 @@ func ClassifyError(err error) Classified {
classified.Cooldown = 30 * time.Second
}
}
case accounts.KindQuota:
// A parsed upstream reset replaces the local-midnight fallback.
// Quota stays account-wide and does not fail over mid-request.
if providerErr.RetryAfter > 0 {
classified.Cooldown = providerErr.RetryAfter
}
}
classified.RetryAfter = classified.Cooldown
return classified
Expand Down
4 changes: 2 additions & 2 deletions internal/providers/devin/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ func (c *Client) chatNonStreamOnce(client *http.Client, built chatRequestBuild,
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
err := classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
err := classifiedErrorWithHeaders(resp.StatusCode, resp.Header, string(body), built.toolsDiag)
return providers.ChatOutcome{}, isMCPConfigDenialError(err), err
}
aggregate, err := aggregateConnectStream(resp.Body, built.originalByAlias, built.toolsDiag)
Expand All @@ -125,7 +125,7 @@ func (c *Client) chatStreamOnce(client *http.Client, built chatRequestBuild) (*h
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
err := classifiedErrorWithToolsDiag(resp.StatusCode, string(body), built.toolsDiag)
err := classifiedErrorWithHeaders(resp.StatusCode, resp.Header, string(body), built.toolsDiag)
return nil, isMCPConfigDenialError(err), err
}
// MCP configuration denials often arrive as an end-stream trailer on HTTP
Expand Down
101 changes: 101 additions & 0 deletions internal/providers/devin/devin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,107 @@ func TestClassify401And429(t *testing.T) {
}
}

func TestDevinResetHintFromMessage(t *testing.T) {
now := time.Date(2026, 10, 8, 18, 0, 0, 0, time.UTC)
cases := []struct {
name string
msg string
want time.Duration
}{
{
name: "minutes",
msg: "Reached free model rate limit. Your limit will reset in 13 minutes (at 18:13 UTC).",
want: 13 * time.Minute,
},
{
name: "hours and minutes",
msg: "Reached free model rate limit. Your limit will reset in 5 hours 30 minutes (at 23:30 UTC).",
want: 5*time.Hour + 30*time.Minute,
},
{
name: "seconds",
msg: "Your limit will reset in 52 seconds (at 18:01 UTC).",
want: 52 * time.Second,
},
{
name: "single hour",
msg: "Your limit will reset in 1 hour (at 19:00 UTC).",
want: time.Hour,
},
{
name: "no hint",
msg: "Your weekly usage quota has been exhausted.",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := resetHintFromMessage(tc.msg, now)
if got != tc.want {
t.Fatalf("reset hint = %v, want %v", got, tc.want)
}
})
}
}

func TestDevinResetHintAbsoluteRollsToNextDay(t *testing.T) {
now := time.Date(2026, 10, 8, 17, 55, 0, 0, time.UTC)
got := resetHintFromMessage("reset at unknown (at 18:01 UTC)", now)
if got != 6*time.Minute {
t.Fatalf("absolute hint = %v, want 6m", got)
}
later := time.Date(2026, 10, 8, 18, 30, 0, 0, time.UTC)
got = resetHintFromMessage("(at 18:01 UTC)", later)
if got <= 23*time.Hour || got > 24*time.Hour {
t.Fatalf("rollover hint = %v, want just under 24h", got)
}
}

func TestClassifyDevinRateLimitCarriesResetHint(t *testing.T) {
body := "devin upstream error (resource_exhausted): Reached free model rate limit. Your limit will reset in 13 minutes (at 18:13 UTC)."
err := classifiedErrorWithHeaders(http.StatusTooManyRequests, http.Header{}, body, "")
var providerErr *providers.Error
if !errors.As(err, &providerErr) {
t.Fatalf("error type = %T", err)
}
if providerErr.Kind != accounts.KindRateLimit || providerErr.RetryAfter != 13*time.Minute {
t.Fatalf("model rate limit = %+v, want rate_limit 13m", providerErr)
}

header := http.Header{}
header.Set("Retry-After", "90")
err = classifiedErrorWithHeaders(http.StatusTooManyRequests, header, body, "")
if !errors.As(err, &providerErr) {
t.Fatalf("error type = %T", err)
}
if providerErr.RetryAfter != 90*time.Second {
t.Fatalf("Retry-After = %v, want 90s", providerErr.RetryAfter)
}
}

func TestClassifyDevinUsageQuotaIsAccountScoped(t *testing.T) {
body := "devin upstream error (failed_precondition): Your weekly usage quota has been exhausted."
err := classifiedErrorWithToolsDiag(http.StatusTooManyRequests, body, "")
var providerErr *providers.Error
if !errors.As(err, &providerErr) {
t.Fatalf("error type = %T", err)
}
if providerErr.Kind != accounts.KindQuota {
t.Fatalf("usage quota kind = %s, want quota", providerErr.Kind)
}
if providerErr.RetryAfter != 0 {
t.Fatalf("quota without a reset hint must leave retry_after empty, got %v", providerErr.RetryAfter)
}

hinted := "Your weekly usage quota has been exhausted. Your limit will reset in 9 minutes (at 18:09 UTC)."
err = classifiedErrorWithToolsDiag(http.StatusTooManyRequests, hinted, "")
if !errors.As(err, &providerErr) {
t.Fatalf("error type = %T", err)
}
if providerErr.Kind != accounts.KindQuota || providerErr.RetryAfter != 9*time.Minute {
t.Fatalf("quota reset = %+v, want quota 9m", providerErr)
}
}

type memStore struct {
accounts map[string]accounts.Account
creds map[string][]byte
Expand Down
97 changes: 92 additions & 5 deletions internal/providers/devin/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package devin

import (
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"

"github.com/caigee-cmd/cli2api/internal/accounts"
"github.com/caigee-cmd/cli2api/internal/providers"
Expand All @@ -12,6 +15,11 @@ import (
var (
sessionTokenPattern = regexp.MustCompile(`(?i)devin-session-token\$[A-Za-z0-9._\-+/=]+`)
jwtLikePattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
// resetInPattern matches relative hints such as "reset in 13 minutes" or
// "reset in 5 hours 30 minutes". Devin puts the quota reset in the body and
// does not send Retry-After.
resetInPattern = regexp.MustCompile(`(?i)reset in\s+(?:(\d+)\s*hours?)?\s*(?:(\d+)\s*minutes?)?\s*(?:(\d+)\s*seconds?)?`)
resetAtPattern = regexp.MustCompile(`(?i)\(at\s+(\d{1,2}):(\d{2})\s*UTC\)`)
)

func redactSecrets(text string) string {
Expand Down Expand Up @@ -54,10 +62,7 @@ func Classify(status int, body string) providers.ClassifiedError {
strings.Contains(text, "rate limit") ||
strings.Contains(text, "too many requests"):
kind := accounts.KindRateLimit
if strings.Contains(text, "quota") || strings.Contains(text, "credit") || strings.Contains(text, "exhausted") && !strings.Contains(text, "resource_exhausted") {
kind = accounts.KindQuota
}
if strings.Contains(text, "resource_exhausted") && (strings.Contains(text, "quota") || strings.Contains(text, "credit") || strings.Contains(text, "acu")) {
if devinQuotaScoped(text) {
kind = accounts.KindQuota
}
return providers.ClassifiedError{
Expand Down Expand Up @@ -94,6 +99,10 @@ func Classify(status int, body string) providers.ClassifiedError {
}

func classifiedErrorWithToolsDiag(status int, body, toolsDiag string) error {
return classifiedErrorWithHeaders(status, nil, body, toolsDiag)
}

func classifiedErrorWithHeaders(status int, headers http.Header, body, toolsDiag string) error {
classified := Classify(status, body)
if classified.Kind == "" {
classified = providers.ClassifiedError{
Expand All @@ -107,11 +116,89 @@ func classifiedErrorWithToolsDiag(status int, body, toolsDiag string) error {
log.Printf("devin mcp configuration denial tools_diag=%s", toolsDiag)
message = appendToolsDiag(message, toolsDiag)
}
return &providers.Error{
err := &providers.Error{
Kind: classified.Kind,
Status: classified.Status,
Message: message,
}
if classified.Kind == accounts.KindRateLimit || classified.Kind == accounts.KindQuota {
err.RetryAfter = devinRetryAfter(headers, body, time.Now())
}
return err
}

// devinQuotaScoped reports an account-level quota exhaustion. A per-model rate
// limit stays model-scoped so the rest of the account keeps serving.
func devinQuotaScoped(text string) bool {
if strings.Contains(text, "usage quota") {
return true
}
if strings.Contains(text, "quota") || strings.Contains(text, "credit") || strings.Contains(text, "acu") {
return true
}
return strings.Contains(text, "exhausted") && !strings.Contains(text, "resource_exhausted")
}

// devinRetryAfter prefers Retry-After, then the reset hint embedded in the
// body. Returns 0 when neither is present so the executor keeps its fallback.
func devinRetryAfter(headers http.Header, body string, now time.Time) time.Duration {
if delay := retryAfterHeader(headers, now); delay > 0 {
return delay
}
return resetHintFromMessage(body, now)
}

func retryAfterHeader(headers http.Header, now time.Time) time.Duration {
if headers == nil {
return 0
}
raw := strings.TrimSpace(headers.Get("Retry-After"))
if raw == "" {
return 0
}
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
if deadline, err := http.ParseTime(raw); err == nil {
if delay := deadline.Sub(now); delay > 0 {
return delay
}
}
return 0
}

func resetHintFromMessage(msg string, now time.Time) time.Duration {
if match := resetInPattern.FindStringSubmatch(msg); match != nil {
seconds := int64(0)
if value, err := strconv.ParseInt(match[1], 10, 64); err == nil {
seconds += value * 3600
}
if value, err := strconv.ParseInt(match[2], 10, 64); err == nil {
seconds += value * 60
}
if value, err := strconv.ParseInt(match[3], 10, 64); err == nil {
seconds += value
}
if seconds > 0 {
return time.Duration(seconds) * time.Second
}
}
match := resetAtPattern.FindStringSubmatch(msg)
if match == nil {
return 0
}
hour, errH := strconv.Atoi(match[1])
minute, errM := strconv.Atoi(match[2])
if errH != nil || errM != nil || hour > 23 || minute > 59 {
return 0
}
utc := now.UTC()
reset := time.Date(utc.Year(), utc.Month(), utc.Day(), hour, minute, 0, 0, time.UTC)
delay := reset.Sub(utc)
if delay <= 0 {
delay += 24 * time.Hour
}
return delay
}

func appendToolsDiag(message, toolsDiag string) string {
Expand Down
Loading