[eric] throttle: emit rate_limited on exhausted backoff + muted auto-clearing pill (no error card)

(cherry picked from commit 03a9233b860683ae0f9d15b9e9efda32763c7d41)
This commit is contained in:
ciregenz
2026-06-19 00:25:55 -07:00
parent 6193863408
commit 4ae97e5de0
6 changed files with 132 additions and 0 deletions
+20
View File
@@ -35,6 +35,7 @@ from backend.apps.agents.core.error_classify import (
_is_long_context_error,
_is_transient_capacity_error,
_is_unknown_model_error,
parse_retry_after,
redact_for_telemetry,
)
from backend.apps.agents.manager.session.session_store import (
@@ -3238,6 +3239,25 @@ class AgentManager:
})
except Exception:
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
elif _is_transient_capacity_error(e, extra_text=_stderr_tail):
# A genuine throttle (429/overload/capacity) that already burned
# the whole silent-backoff budget (the only way one reaches here).
# It's a limit, not a failure, so don't append a system-message
# card; emit a transient signal for the muted pill and mark the
# turn completed so it doesn't read as an error.
session.status = "completed"
if stream_text_msg_id:
try:
await ws_manager.send_to_session(session_id, "agent:stream_end", {
"session_id": session_id,
"message_id": stream_text_msg_id,
})
except Exception:
pass
await ws_manager.send_to_session(session_id, "agent:rate_limited", {
"session_id": session_id,
"retry_after_s": parse_retry_after(e, _stderr_tail),
})
elif _is_free_trial_exhausted(e, extra_text=_stderr_tail):
# Free runs spent. Flip back to own_key and show a friendly
# "connect a model" upsell instead of a raw 402.
@@ -173,6 +173,24 @@ def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
))
def parse_retry_after(exc: BaseException, extra_text: str = "") -> int | None:
"""Best-effort seconds-until-retry pulled from a throttle error; None if the
upstream didn't say. Only used to label the rate-limit pill, so a miss just
means the pill shows no countdown, never anything load-bearing."""
combined = f"{exc!s}\n{extra_text}"
# "1m 59s" / "2m" / "45s" (reset-window phrasing Codex/Anthropic use).
m = re.search(r"\b(?:(\d{1,2})\s*m(?:in)?)?\s*(\d{1,3})\s*s(?:ec)?\b", combined, re.IGNORECASE)
if m and (m.group(1) or m.group(2)):
return int(m.group(1) or 0) * 60 + int(m.group(2) or 0)
# "retry-after: 30" / "try again in 2 minutes".
m = re.search(r"(?:retry[-\s]?after|try\s+again\s+in)\D{0,8}(\d{1,4})\s*(m|min|minute|s|sec|second)?", combined, re.IGNORECASE)
if m:
n = int(m.group(1))
unit = (m.group(2) or "s").lower()
return n * 60 if unit.startswith("m") else n
return None
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
# The Claude CLI's underlying ProcessError stringifies to a generic
# "Command failed with exit code 1 / Check stderr output for details";
@@ -55,6 +55,7 @@ import MessageActionBar from './shell/MessageActionBar';
import ToolCallBubble, { ToolPair } from './tool-bubbles/ToolCallBubble';
import ToolGroupBubble, { RenderItem, ToolGroup, isToolGroup, isToolPair } from './tool-bubbles/ToolGroupBubble';
import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar';
import { RateLimitPill } from './shell/RateLimitPill';
import ChatInput, { ChatInputHandle } from './ChatInput';
import ContextDrawer from './shell/ContextDrawer';
import { ErrorSlime } from '@/app/components/feedback/ErrorSlime';
@@ -1860,6 +1861,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
))
)}
<RateLimitPill sessionId={session.id} />
{isGlowing ? (
<Box
onClick={(e) => { e.stopPropagation(); onDismissGlow?.(); }}
@@ -0,0 +1,58 @@
import React, { useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import Fade from '@mui/material/Fade';
import Typography from '@mui/material/Typography';
import ScheduleIcon from '@mui/icons-material/Schedule';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearRateLimited } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Muted, transient pill shown only after a real provider throttle outlasted the
// silent backoff. No card, no red, no CTA; it fades and auto-clears once the
// window should have passed. The "why" lives in the hover, not on the surface.
export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const rl = useAppSelector((s) => s.agents.sessions[sessionId]?.rate_limited);
useEffect(() => {
if (!rl) return;
const ms = Math.min(Math.max(rl.retry_after_s ?? 45, 5), 300) * 1000;
const t = setTimeout(() => dispatch(clearRateLimited({ sessionId })), ms);
return () => clearTimeout(t);
}, [rl, sessionId, dispatch]);
const label = rl?.retry_after_s
? `Back ~${(() => {
const d = new Date(Date.now() + rl.retry_after_s * 1000);
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
})()}`
: 'Rate limited';
// Hold the last text so the exit fade renders content, not a blank pill.
const lastLabel = useRef(label);
if (rl) lastLabel.current = label;
return (
<Fade in={!!rl} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
<Box
title="Your plan hit its rate limit, it'll resume on its own"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: 0.6,
alignSelf: 'flex-start',
mx: 2,
mb: 1,
px: 1.25,
py: 0.5,
borderRadius: 999,
bgcolor: c.bg.secondary,
color: c.text.tertiary,
}}
>
<ScheduleIcon sx={{ fontSize: 14 }} />
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500 }}>{lastLabel.current}</Typography>
</Box>
</Fade>
);
};
+21
View File
@@ -102,6 +102,7 @@ export interface AgentSession {
context_window?: number;
framework_overhead_tokens?: number;
context_overflow?: { reason: string; message: string; at: string } | null;
rate_limited?: { retry_after_s: number | null; at: string } | null;
mcp_suggestions?: Array<{ id: string; title: string; description: string; reason?: string }>;
mcp_suggestions_is_vague?: boolean;
compacted_through_msg_id?: string | null;
@@ -919,6 +920,24 @@ const agentsSlice = createSlice({
}
},
setRateLimited(
state,
action: PayloadAction<{ sessionId: string; retryAfterS: number | null }>
) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.rate_limited = {
retry_after_s: action.payload.retryAfterS,
at: new Date().toISOString(),
};
}
},
clearRateLimited(state, action: PayloadAction<{ sessionId: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) session.rate_limited = null;
},
clearContextOverflow(
state,
action: PayloadAction<{ sessionId: string }>
@@ -1425,6 +1444,8 @@ export const {
updateSessionCost,
updateSessionContext,
setContextOverflow,
setRateLimited,
clearRateLimited,
clearContextOverflow,
setMcpSuggestions,
clearMcpSuggestions,
@@ -11,6 +11,7 @@ import {
updateSessionCost,
updateSessionContext,
setContextOverflow,
setRateLimited,
setMcpSuggestions,
addBranch,
setActiveBranch,
@@ -655,6 +656,17 @@ class WebSocketManager {
}
break;
case 'agent:rate_limited':
// Provider throttle that outlasted the silent backoff. Transient muted
// pill, not a card; auto-clears frontend-side.
if (session_id) {
store.dispatch(setRateLimited({
sessionId: session_id,
retryAfterS: typeof data.retry_after_s === 'number' ? data.retry_after_s : null,
}));
}
break;
case 'agent:context_status':
// Auto-compaction collapsed older turns into a summary. Mirror
// compacted_through_msg_id locally so the renderer can drop a