MUL-7816 fix(taskfailure): classify a 403 usage limit as provider quota, not auth (#8967)

* fix(taskfailure): classify a 403 usage limit as provider quota, not auth

Kimi Code reports an exhausted usage window as HTTP 403. The bare 403
auth rule ran before the quota rule, so the failure was filed as
provider_auth_or_access. Match the usage-limit witness before the auth
rule. The concurrent-request-limit case still comes first, and a plain
403 still classifies as auth.

Fixes #8965

* fix(taskfailure): upgrade old-daemon 403 usage limits and beat the token rule

Move the usage-limit check into a shared isUsageLimit403 helper that
requires HTTP 403. Classify checks it right after the concurrent-request
case, so an access-token-prefixed 403 usage limit no longer lands in
context_overflow. NormalizeDaemonReason upgrades the same message from
older daemons (context_overflow, provider_auth_or_access, unknown,
agent_error) to provider_quota_limit.
This commit is contained in:
Younes Beriane
2026-09-30 21:37:17 -07:00
committed by GitHub
parent 19c5a23c2b
commit 262dcb5fa7
2 changed files with 60 additions and 0 deletions
+28
View File
@@ -43,6 +43,14 @@ var (
// member-facing recovery guidance describe the actual failure.
const concurrentRequestLimitWitness = "concurrent request limit"
var httpForbiddenCodeRe = regexp.MustCompile(`(^|[^0-9])403([^0-9]|$)`)
// isUsageLimit403 is shared by Classify and NormalizeDaemonReason so new and
// old daemons land on the same reason.
func isUsageLimit403(lower string) bool {
return httpForbiddenCodeRe.MatchString(lower) && strings.Contains(lower, "usage limit")
}
// Classify maps a free-form error string from the agent runtime / CLI
// to one of the 14 agent_error.* sub-reasons. Always returns a valid
// Reason; falls back to ReasonAgentUnknown when no rule matches and for
@@ -85,6 +93,13 @@ func Classify(rawError string) Reason {
case strings.Contains(lower, concurrentRequestLimitWitness):
return ReasonAgentProviderCapacityOrRateLimit
// Some providers (e.g. Kimi Code) report an exhausted usage window as HTTP
// 403, and Claude Code may prefix it with an access-token failure. It must
// beat both the token-window rule (the text has "token" and "limit") and
// the bare 403 auth rule, or valid credentials get blamed.
case isUsageLimit403(lower):
return ReasonAgentProviderQuotaLimit
// 1. Context / token window overflow. Checked early so "token
// limit" doesn't get swallowed by the broader "limit" / "quota"
// rule below.
@@ -495,6 +510,16 @@ var legacyConcurrentRequestLimitReasons = map[string]bool{
"agent_error": true,
}
// legacyUsageLimit403Reasons are the stale buckets an older daemon lands a
// 403 usage-limit rejection in: the bare 403 auth rule, the token-window rule
// when an access-token prefix is present, or the catchalls.
var legacyUsageLimit403Reasons = map[string]bool{
string(ReasonAgentContextOverflow): true,
string(ReasonAgentProviderAuthOrAccess): true,
string(ReasonAgentUnknown): true,
"agent_error": true,
}
// NormalizeDaemonReason upgrades a failure_reason reported by an older daemon
// onto the taxonomy this server understands, using the raw error text as the
// witness. It returns the reason unchanged when nothing applies.
@@ -515,6 +540,9 @@ func NormalizeDaemonReason(reason, rawError string) Reason {
strings.Contains(strings.ToLower(rawError), concurrentRequestLimitWitness) {
return ReasonAgentProviderCapacityOrRateLimit
}
if legacyUsageLimit403Reasons[reason] && isUsageLimit403(strings.ToLower(rawError)) {
return ReasonAgentProviderQuotaLimit
}
if legacySkillBundleReasons[reason] &&
strings.HasPrefix(strings.TrimSpace(rawError), legacySkillBundlePrefix) {
return ReasonSkillBundleUnavailable
+32
View File
@@ -228,6 +228,14 @@ func TestClassifyOrderingPriorities(t *testing.T) {
{"403 concurrent request limit beats auth", "Failed to authenticate. API Error: 403 You've reached your concurrent request limit. Please wait for your ongoing requests to finish and try again.", ReasonAgentProviderCapacityOrRateLimit},
{"access token concurrent request limit beats context", "Failed to refresh access token. API Error: 403 You've reached your concurrent request limit.", ReasonAgentProviderCapacityOrRateLimit},
// Kimi Code reports an exhausted subscription window as HTTP 403. The
// usage-limit witness must beat the bare 403 auth rule, while an
// unrelated 403 stays an auth failure.
{"403 usage limit beats auth", "API Error: 403 You've reached your 5-hour usage limit", ReasonAgentProviderQuotaLimit},
{"403 usage limit with prefix beats auth", "Failed to authenticate. API Error: 403 {\"error\":{\"message\":\"You've reached your 5-hour usage limit\"}}", ReasonAgentProviderQuotaLimit},
{"access token 403 usage limit beats context", "Failed to refresh access token. API Error: 403 You've reached your 5-hour usage limit", ReasonAgentProviderQuotaLimit},
{"plain 403 stays auth", "API Error: 403 Forbidden", ReasonAgentProviderAuthOrAccess},
// Both "429" and "rate limit" present — should still land in
// the capacity bucket, not the quota bucket.
{"429 rate limit", "API Error: 429 rate limit reached", ReasonAgentProviderCapacityOrRateLimit},
@@ -271,6 +279,30 @@ func TestNormalizeDaemonReasonUpgradesConcurrentRequestLimit(t *testing.T) {
}
}
func TestNormalizeDaemonReasonUpgradesUsageLimit403(t *testing.T) {
t.Parallel()
for _, raw := range []string{
"API Error: 403 You've reached your 5-hour usage limit",
"Failed to refresh access token. API Error: 403 You've reached your 5-hour usage limit",
} {
for _, reason := range []string{
string(ReasonAgentContextOverflow),
string(ReasonAgentProviderAuthOrAccess),
string(ReasonAgentUnknown),
"agent_error",
} {
if got := NormalizeDaemonReason(reason, raw); got != ReasonAgentProviderQuotaLimit {
t.Errorf("NormalizeDaemonReason(%q, %q) = %q, want %q", reason, raw, got, ReasonAgentProviderQuotaLimit)
}
}
}
if got := NormalizeDaemonReason(string(ReasonAgentProviderAuthOrAccess), "API Error: 403 Forbidden"); got != ReasonAgentProviderAuthOrAccess {
t.Errorf("plain 403 auth rejection changed to %q", got)
}
}
// TestClassify5xxRegex pins the boundary behavior of the 5xx HTTP
// status detector. The SQL classifier uses an anchored regex
// `(^|[^0-9])5[0-9][0-9]([^0-9]|$)`; this Go classifier mirrors it via