From 4ae97e5de05685e78ef234506b02bf273f2156f4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 19 Jun 2026 00:03:57 -0700 Subject: [PATCH] [eric] throttle: emit rate_limited on exhausted backoff + muted auto-clearing pill (no error card) (cherry picked from commit 03a9233b860683ae0f9d15b9e9efda32763c7d41) --- backend/apps/agents/agent_manager.py | 20 +++++++ backend/apps/agents/core/error_classify.py | 18 ++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 3 + .../pages/AgentChat/shell/RateLimitPill.tsx | 58 +++++++++++++++++++ frontend/src/shared/state/agentsSlice.ts | 21 +++++++ frontend/src/shared/ws/WebSocketManager.ts | 12 ++++ 6 files changed, 132 insertions(+) create mode 100644 frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 958afbc4..38461130 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -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. diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index d7b1fb7b..ad1cb15f 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -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"; diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 8d7a7962..a621b423 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -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 = ({ sessionId: sessionIdProp, onClose )) )} + + {isGlowing ? ( { e.stopPropagation(); onDismissGlow?.(); }} diff --git a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx new file mode 100644 index 00000000..cafd06b2 --- /dev/null +++ b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx @@ -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 ( + + + + {lastLabel.current} + + + ); +}; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 77984d20..07a1c4ce 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -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, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 970664ac..7d3d6f8e 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -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