Skip to content

Commit f5d7639

Browse files
committed
Normalize model rate limits into AG-UI run errors
Keep the change inside the SDK error-to-AG-UI boundary: detect structured 429/rate-limit signals, preserve RATE_LIMITED retry metadata on RUN_ERROR, and keep non-rate errors on their existing codes. Constraint: Aone 83566999 requires model limit errors to end the AG-UI stream with RUN_ERROR rather than client-delivered text. Rejected: funagent-core/front-end rewrite | downstream AG-UI frames are already pass-through and the SDK owns event semantics. Rejected: broad generic error framework | current need is a small rate-limit normalizer. Confidence: high Scope-risk: narrow Directive: Keep RUN_ERROR payload extensions whitelisted and preserve SSE event framing when adding fields. Tested: uv run --extra server pytest tests/unittests/server/test_invoker.py tests/unittests/server/test_agui_protocol.py tests/unittests/integration/test_langgraph_events.py -q => 101 passed, 1 warning Tested: uv run --extra server pytest tests/unittests/integration/test_langgraph_to_agent_event.py -q => 32 passed Tested: git diff --check && uv run ruff check agentrun/server/error_utils.py agentrun/server/invoker.py agentrun/server/agui_protocol.py agentrun/integration/langgraph/agent_converter.py => passed Tested: UltraQA dynamic AG-UI harness UQA-1..UQA-4 => passed Change-Id: Ice926e2c21201071713ac39faeed736078fc5823 Co-developed-by: Codex <noreply@openai.com> Not-tested: full repository test suite and remote CI pending Signed-off-by: congxiao.wxx <congxiao.wxx@alibaba-inc.com>
1 parent 72e8479 commit f5d7639

8 files changed

Lines changed: 507 additions & 44 deletions

File tree

agentrun/integration/langgraph/agent_converter.py

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
import json
3030
from typing import Any, Dict, Iterator, List, Optional, Union
3131

32+
from agentrun.server.error_utils import (
33+
build_error_event_data,
34+
format_error_message,
35+
)
3236
from agentrun.server.model import AgentResult, EventType
3337
from agentrun.utils.log import logger
3438

@@ -941,71 +945,53 @@ def _convert_astream_events_event(
941945
# 7. LLM 错误
942946
elif event_type == "on_llm_error":
943947
error = data.get("error")
944-
error_message = ""
945-
if error is not None:
946-
if isinstance(error, Exception):
947-
error_message = f"{type(error).__name__}: {str(error)}"
948-
elif isinstance(error, str):
949-
error_message = error
950-
else:
951-
error_message = str(error)
948+
error_message = format_error_message(error)
952949

953950
yield AgentResult(
954951
event=EventType.ERROR,
955-
data={
956-
"message": f"LLM error: {error_message}",
957-
"code": "LLM_ERROR",
958-
},
952+
data=build_error_event_data(
953+
error,
954+
fallback_code="LLM_ERROR",
955+
fallback_message=f"LLM error: {error_message}",
956+
),
959957
)
960958

961959
# 8. Chain 错误
962960
elif event_type == "on_chain_error":
963961
error = data.get("error")
964962
chain_name = event_dict.get("name", "")
965-
error_message = ""
966-
if error is not None:
967-
if isinstance(error, Exception):
968-
error_message = f"{type(error).__name__}: {str(error)}"
969-
elif isinstance(error, str):
970-
error_message = error
971-
else:
972-
error_message = str(error)
963+
error_message = format_error_message(error)
973964

974965
yield AgentResult(
975966
event=EventType.ERROR,
976-
data={
977-
"message": (
967+
data=build_error_event_data(
968+
error,
969+
fallback_code="CHAIN_ERROR",
970+
fallback_message=(
978971
f"Chain '{chain_name}' error: {error_message}"
979972
if chain_name
980973
else error_message
981974
),
982-
"code": "CHAIN_ERROR",
983-
},
975+
),
984976
)
985977

986978
# 9. Retriever 错误
987979
elif event_type == "on_retriever_error":
988980
error = data.get("error")
989981
retriever_name = event_dict.get("name", "")
990-
error_message = ""
991-
if error is not None:
992-
if isinstance(error, Exception):
993-
error_message = f"{type(error).__name__}: {str(error)}"
994-
elif isinstance(error, str):
995-
error_message = error
996-
else:
997-
error_message = str(error)
982+
error_message = format_error_message(error)
998983

999984
yield AgentResult(
1000985
event=EventType.ERROR,
1001-
data={
1002-
"message": (
986+
data=build_error_event_data(
987+
error,
988+
fallback_code="RETRIEVER_ERROR",
989+
fallback_message=(
1003990
f"Retriever '{retriever_name}' error: {error_message}"
1004991
if retriever_name
1005992
else error_message
1006993
),
1007-
"code": "RETRIEVER_ERROR",
1008-
},
994+
),
1009995
)
1010996

1011997
# =========================================================================

agentrun/server/agui_protocol.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
# ============================================================================
5454

5555
DEFAULT_PREFIX = "/ag-ui/agent"
56+
RUN_ERROR_EXTRA_FIELDS = ("retryable", "retryAfterMs", "traceId")
5657

5758

5859
@dataclass
@@ -743,12 +744,21 @@ def _process_event_with_boundaries(
743744

744745
# ERROR 事件
745746
if event.event == EventType.ERROR:
746-
yield self._encoder.encode(
747-
RunErrorEvent(
748-
message=event.data.get("message", ""),
749-
code=event.data.get("code"),
750-
)
747+
agui_event = RunErrorEvent(
748+
message=event.data.get("message", ""),
749+
code=event.data.get("code"),
751750
)
751+
event_dict = agui_event.model_dump(by_alias=True, exclude_none=True)
752+
for key in RUN_ERROR_EXTRA_FIELDS:
753+
value = event.data.get(key)
754+
if value is not None:
755+
event_dict[key] = value
756+
elif event.addition:
757+
value = event.addition.get(key)
758+
if value is not None:
759+
event_dict[key] = value
760+
json_str = json.dumps(event_dict, ensure_ascii=False)
761+
yield f"event: RUN_ERROR\ndata: {json_str}\n\n"
752762
return
753763

754764
# STATE 事件

agentrun/server/error_utils.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Error helpers for AgentRun server streams."""
2+
3+
import re
4+
from typing import Any, Dict, Optional
5+
6+
RATE_LIMITED_CODE = "RATE_LIMITED"
7+
RATE_LIMITED_MESSAGE = "模型当前请求过多,请稍后再试"
8+
RATE_LIMITED_RETRY_AFTER_MS = 2000
9+
10+
_RATE_LIMIT_CODES = {
11+
"ratelimitexceeded",
12+
"ratelimited",
13+
"resourcethrottled",
14+
"throttling",
15+
"throttlingquota",
16+
"throttlingratequota",
17+
"throttlingexception",
18+
"toomanyrequests",
19+
}
20+
21+
_RATE_LIMIT_TEXT_PATTERNS = [
22+
re.compile(r"\btoo[-_\s]*many[-_\s]*requests\b", re.IGNORECASE),
23+
re.compile(
24+
r"\brate[-_\s]*limit(?:ed|[-_\s]*exceeded)\b",
25+
re.IGNORECASE,
26+
),
27+
re.compile(r"\bresource[-_\s]*throttled\b", re.IGNORECASE),
28+
re.compile(r"\bthrottling(?:exception| exception| error)\b", re.IGNORECASE),
29+
re.compile(
30+
r"\b(?:http|status|status code|code)\s*[:=]?\s*429\b",
31+
re.IGNORECASE,
32+
),
33+
]
34+
35+
36+
def format_error_message(error: Any) -> str:
37+
"""Format errors consistently with existing LangGraph conversion."""
38+
if error is None:
39+
return ""
40+
if isinstance(error, Exception):
41+
return f"{type(error).__name__}: {str(error)}"
42+
return str(error)
43+
44+
45+
def build_error_event_data(
46+
error: Any,
47+
*,
48+
fallback_code: str,
49+
fallback_message: str,
50+
) -> Dict[str, Any]:
51+
"""Build AgentEvent ERROR data, normalizing model rate limits."""
52+
if not is_rate_limited_error(error):
53+
return {"message": fallback_message, "code": fallback_code}
54+
55+
data: Dict[str, Any] = {
56+
"message": RATE_LIMITED_MESSAGE,
57+
"code": RATE_LIMITED_CODE,
58+
"retryable": True,
59+
"retryAfterMs": RATE_LIMITED_RETRY_AFTER_MS,
60+
}
61+
trace_id = _extract_trace_id(error)
62+
if trace_id:
63+
data["traceId"] = str(trace_id)
64+
return data
65+
66+
67+
def is_rate_limited_error(error: Any) -> bool:
68+
"""Return whether an error carries an explicit rate-limit signal."""
69+
if _extract_status_code(error) == 429:
70+
return True
71+
72+
if _has_rate_limit_code(error):
73+
return True
74+
75+
message = str(error or "")
76+
return any(pattern.search(message) for pattern in _RATE_LIMIT_TEXT_PATTERNS)
77+
78+
79+
def _extract_status_code(error: Any) -> Optional[int]:
80+
fallback = None
81+
for obj in (error, _get_value(error, "response")):
82+
if obj is None:
83+
continue
84+
for name in ("status_code", "status", "http_status", "statusCode"):
85+
status_code = _to_int(_get_value(obj, name))
86+
if status_code == 429:
87+
return status_code
88+
if fallback is None and status_code is not None:
89+
fallback = status_code
90+
return fallback
91+
92+
93+
def _has_rate_limit_code(error: Any) -> bool:
94+
for obj in (error, _get_value(error, "response")):
95+
if obj is None:
96+
continue
97+
for name in ("code", "error_code", "errorCode"):
98+
error_code = _get_value(obj, name)
99+
if (
100+
error_code is not None
101+
and _normalize_code(error_code) in _RATE_LIMIT_CODES
102+
):
103+
return True
104+
return False
105+
106+
107+
def _extract_trace_id(error: Any) -> Optional[Any]:
108+
for name in ("trace_id", "traceId", "request_id", "requestId"):
109+
trace_id = _get_value(error, name)
110+
if trace_id:
111+
return trace_id
112+
113+
response = _get_value(error, "response")
114+
headers = _get_value(response, "headers")
115+
if not headers:
116+
return None
117+
118+
for name in ("x-acs-request-id", "x-request-id", "x-trace-id", "trace-id"):
119+
trace_id = _get_header(headers, name)
120+
if trace_id:
121+
return trace_id
122+
return None
123+
124+
125+
def _get_value(obj: Any, name: str) -> Optional[Any]:
126+
if obj is None:
127+
return None
128+
if isinstance(obj, dict):
129+
return obj.get(name)
130+
return getattr(obj, name, None)
131+
132+
133+
def _get_header(headers: Any, name: str) -> Optional[Any]:
134+
if isinstance(headers, dict):
135+
for key, value in headers.items():
136+
if str(key).lower() == name:
137+
return value
138+
return None
139+
get = getattr(headers, "get", None)
140+
if callable(get):
141+
return get(name)
142+
return None
143+
144+
145+
def _normalize_code(code: Any) -> str:
146+
normalized = re.sub(r"[^a-z0-9]", "", str(code).lower())
147+
for suffix in ("exception", "error"):
148+
if normalized.endswith(suffix):
149+
return normalized[: -len(suffix)]
150+
return normalized
151+
152+
153+
def _to_int(value: Any) -> Optional[int]:
154+
if value is None:
155+
return None
156+
try:
157+
return int(value)
158+
except (TypeError, ValueError):
159+
return None

agentrun/server/invoker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
)
2525
import uuid
2626

27+
from .error_utils import build_error_event_data
2728
from .model import AgentEvent, AgentRequest, EventType
2829
from .protocol import (
2930
AsyncInvokeAgentHandler,
@@ -142,7 +143,11 @@ async def invoke_stream(
142143
logger.error(f"Agent 调用出错: {e}", exc_info=True)
143144
yield AgentEvent(
144145
event=EventType.ERROR,
145-
data={"message": str(e), "code": type(e).__name__},
146+
data=build_error_event_data(
147+
e,
148+
fallback_code=type(e).__name__,
149+
fallback_message=str(e),
150+
),
146151
)
147152

148153
def _process_user_event(

tests/unittests/integration/test_langgraph_events.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -816,7 +816,7 @@ def test_on_llm_error(self):
816816
"event": "on_llm_error",
817817
"run_id": "run_llm",
818818
"data": {
819-
"error": RuntimeError("API rate limit exceeded"),
819+
"error": RuntimeError("Model backend failed"),
820820
},
821821
}
822822

@@ -828,6 +828,45 @@ def test_on_llm_error(self):
828828
assert "RuntimeError" in results[0].data["message"]
829829
assert results[0].data["code"] == "LLM_ERROR"
830830

831+
def test_on_llm_error_rate_limited(self):
832+
"""测试 on_llm_error 限流错误归一化"""
833+
event = {
834+
"event": "on_llm_error",
835+
"run_id": "run_llm",
836+
"data": {
837+
"error": RuntimeError("API rate limit exceeded"),
838+
},
839+
}
840+
841+
results = list(AgentRunConverter().to_agui_events(event))
842+
843+
assert len(results) == 1
844+
assert results[0].event == EventType.ERROR
845+
assert results[0].data["message"] == "模型当前请求过多,请稍后再试"
846+
assert results[0].data["code"] == "RATE_LIMITED"
847+
assert results[0].data["retryable"] is True
848+
assert results[0].data["retryAfterMs"] == 2000
849+
850+
def test_on_llm_error_rate_limit_text_false_positive(self):
851+
"""测试说明性 rate limit/429 文本不会误判"""
852+
event = {
853+
"event": "on_llm_error",
854+
"run_id": "run_llm",
855+
"data": {
856+
"error": RuntimeError(
857+
"ticket 429 mentions rate limit dashboard, auth failed"
858+
),
859+
},
860+
}
861+
862+
results = list(AgentRunConverter().to_agui_events(event))
863+
864+
assert len(results) == 1
865+
assert results[0].event == EventType.ERROR
866+
assert results[0].data["code"] == "LLM_ERROR"
867+
assert "retryable" not in results[0].data
868+
assert "retryAfterMs" not in results[0].data
869+
831870
def test_on_chain_error(self):
832871
"""测试 on_chain_error 事件
833872

0 commit comments

Comments
 (0)