Files
ThearchyHelios 0e997eb968 copy-to-postgres 在真实开发数据上核对不过的几处(复核 R-a1、R-a2、R-a3、R-a8、R-a13)
- R-a1 老库 NOT NULL 列为空:第一版补 schedules.trigger 时没带缺省值,存量定时任务这一列是 NULL,导入 PostgreSQL 撞 NOT NULL,连带触发记录、通知撞外键。迁移表里这一列改成带 DEFAULT '{}';新增 _NOT_NULL_BACKFILLS,启动时按模型的缺省值把已经补过、留着 NULL 的填上(SQLite 这边也填,两种库存的一样;只更新这一列,不碰 updated_at);导入时 _Fixer 遇到 NOT NULL、有缺省值的列的 NULL 也填缺省值,计入「转换」。迁移表里的 NOT NULL 列都过了一遍,只有这一列没带缺省值
- R-a2 孤儿行:导入前在快照上按模型外键扫(settle_orphans):外键是 SET NULL、列可空的置空,计入转换;CASCADE / 不可空的从快照里拿掉、不导入,反复扫到没有为止,报告里单列「孤儿行(SQLite 当时没强制外键)」的表和条数,不算核对失败
- R-a3 停服务后 agentlab.db-wal 还在:lifespan 收尾 dispose 应用库引擎(只加了这几行),SQLite 最后一个连接关掉时并回、删掉 -wal,PostgreSQL 的池子也正常关
- R-a8 自增主键的序列取「目标表 MAX、快照 sqlite_sequence 的计数器、序列现值」三者最大,删过的号不复用(召回按 source_changes.seq 当游标)
- R-a13 插不进的行的原因不再带「<class 'asyncpg.exceptions.…'>:」前缀(errors.first_line 两种写法都去);设了 AGENTLAB_DATABASE_URL 却没装 [postgres] 时,应用库引擎和断点库都直接说要装什么
- 测试:DROP COLUMN 再按第一版迁移补回造老表、关着外键写孤儿行,迁移填值、快照上置空和拿掉、导入 PostgreSQL 核对通过且重跑一样;NULL 落回模型缺省值;端到端里删掉最后一条变化,导过去新变化从 3 编起;起停一次服务后数据目录里没有 -wal / -shm(去掉 dispose 这条测试就挂)
- PostgreSQL 引擎上另挂了去掉文本参数里 NUL 的钩子(db/postgres.strip_nul,见下一个提交)
2026-10-19 12:06:31 +08:00

238 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""面向用户的报错:说清发生了什么、为什么、怎么办,不把 Python 异常类名端上去。
以前各处都是 f"{type(e).__name__}: {e}",界面首行红字是
「_make_query_tool.<locals>._run() got an unexpected keyword argument 'query'」
——对业务用户是天书,也没有下一步。原始异常不丢:调用方把 raw(e) 放进响应或
事件的 detail 字段、写进日志,排查时照样找得到。
这里曾经是两份:接口层一份(测连接这类回「原因 + 怎么办」),执行层一份(节点
报错首行只要原因)。两份各认一套异常类名、各写一套说法,同一个 401 在设置页和
运行记录里是两句话。现在认异常的规则只有下面这一张表,两个入口只在一件事上分叉:
TypeError、KeyError 这类「程序自己的毛病」——
- 接口里冒出来的,是后端的 bug,原文里全是内部符号,说「服务端内部错误」;
- 节点里冒出来的,多半是工具被传错了参数、用户代码取错了字段,说清是哪个参数、
哪个字段,模型和人才知道该改什么。
判断按类名走 MRO,不 import 各家 SDK:没装的驱动和 SDK 不该让报错这一层也跟着
ImportError。
"""
from __future__ import annotations
import json
import re
from typing import Any
_TIMEOUT = {"TimeoutError", "TimeoutException", "APITimeoutError", "ReadTimeout",
"ConnectTimeout", "PoolTimeout", "WriteTimeout"}
_NETWORK = {"ConnectError", "ConnectionError", "APIConnectionError", "gaierror",
"ClientConnectorError", "RemoteProtocolError", "ReadError", "NetworkError"}
_AUTH = {"AuthenticationError", "PermissionDeniedError", "InvalidPasswordError",
"InvalidAuthorizationSpecificationError"}
#: 这几类是程序自己的毛病,str(e) 里全是内部符号,原样端上去只会更糊涂
_BUG = {"TypeError", "AttributeError", "KeyError", "NameError", "IndexError",
"UnboundLocalError", "AssertionError", "RecursionError"}
_UNREACHABLE = ("connection refused", "nodename nor servname", "name or service not known",
"all connection attempts failed", "network is unreachable")
#: SQLAlchemy 包过的驱动异常开头是「(sqlite3.OperationalError) 」;asyncpg(PostgreSQL)的后面还跟着
#: 「<class 'asyncpg.exceptions.UniqueViolationError'>: 」,一起去掉(直接拿 e.orig 的也只剩后一段,同样去掉)
_WRAPPED = re.compile(r"^(?:\([\w.]+\)\s*)?(?:<class '[\w.]+'>:\s*)?")
# 内部符号:`a.b.<locals>._run()`、`<function f at 0x…>`、模块路径。对使用者没有意义
_LOCALS = re.compile(r"[\w.]*<locals>\.?[\w.]*(\(\))?")
_REPR = re.compile(r"<(?:function|bound method|coroutine|object) [^>]*>")
# 包在消息里的异常类名("连不上:ConnectError: All connection attempts failed")
_CLASS = re.compile(r"\b[A-Z]\w*(?:Error|Exception|Warning)\b:\s*")
def raw(e: BaseException) -> str:
"""原始报错,给「技术细节」折叠区、事件的 detail 字段和日志用。"""
text = str(e).strip()
return (f"{type(e).__name__}: {text}" if text else type(e).__name__)[:2000]
def raw_detail(e: BaseException | None) -> str | None:
"""同 raw,没有异常时是 None——事件里 detail 字段就不带。"""
return None if e is None else raw(e)
def first_line(e: BaseException) -> str:
"""异常自己的说明,去掉驱动包装的类名前缀和 SQLAlchemy 的文档链接。"""
text = str(e).strip()
line = text.splitlines()[0] if text else ""
return _WRAPPED.sub("", line)[:300]
def _names(e: BaseException) -> set[str]:
return {c.__name__ for c in type(e).__mro__}
def _status(e: BaseException) -> int | None:
for obj in (e, getattr(e, "response", None)):
code = getattr(obj, "status_code", None)
if isinstance(code, int):
return code
return None
def _scrub(text: str) -> str:
text = _LOCALS.sub("内部函数", text)
text = _REPR.sub("内部对象", text)
text = _CLASS.sub("", text)
return " ".join(text.split())[:240]
#: classify() 的返回值:错误属于哪一类。调用方按类别给场景专用的说法(比如测数据库连接时,
#: 网络不通要说主机和端口,不说 API),不要拿 explain() 返回的文字做比较——文字会改,类别不会
AUTH, NOT_FOUND, RATE_LIMIT, SERVER, TIMEOUT, NETWORK = "auth", "not_found", "rate_limit", "server", "timeout", "network"
BAD_JSON, VALIDATION, PERMISSION, FILE_MISSING, OVERFLOW, BUG = (
"bad_json", "validation", "permission", "file_missing", "overflow", "bug")
def _known(e: BaseException, *, sniff: bool) -> tuple[str, str, str] | None:
"""两种场景说法一样的那些:鉴权、找不到、限流、服务方故障、超时、网络、格式、文件。
返回 (类别, 原因, 怎么办)。
sniff:类名认不出时要不要按原文字面猜。SDK 抛出来的裸异常要猜(原文就是
"Connection refused");执行层的异常常常是已经拼好的一句话、里头套着具体原因,
按字面猜会把那个原因换成笼统的一句。
"""
names = _names(e)
low = str(e).lower() if sniff else ""
status = _status(e)
if status in (401, 403) or names & _AUTH or "invalid api key" in low or "unauthorized" in low:
code = f"({status})" if status else ""
return (AUTH, f"鉴权失败{code}:服务方拒绝了当前 API Key",
"请检查 API Key 是否填写正确、是否过期,以及该账号是否有权使用此模型")
if status == 404 or "NotFoundError" in names:
return (NOT_FOUND, "请求的地址或模型不存在(404)",
"请检查地址路径(常见问题是多了或少了 /v1)和模型名")
if status == 429 or "RateLimitError" in names:
return RATE_LIMIT, "请求过于频繁或额度已用完(429)", "请稍后重试;频繁出现时请检查账号额度"
if isinstance(status, int) and status >= 500:
return SERVER, f"服务方内部错误(HTTP {status})", "通常是暂时性故障,请稍后重试"
if isinstance(e, TimeoutError) or names & _TIMEOUT or "timed out" in low:
return (TIMEOUT, "等待超时:服务方未在限定时间内响应",
"服务方可能繁忙或网络不稳定,请稍后重试;持续超时请检查地址和网络")
if isinstance(e, ConnectionError) or names & _NETWORK or any(s in low for s in _UNREACHABLE):
return (NETWORK, "无法连接服务:网络不通或服务未启动",
"请检查地址和端口,确认服务已启动且本机可以访问")
if isinstance(e, json.JSONDecodeError):
return BAD_JSON, "返回内容不是合法的 JSON", "地址可能指向了网页而非 API,请检查 Base URL"
if "ValidationError" in names and callable(getattr(e, "errors", None)):
return VALIDATION, f"参数有误:{_first_validation(e)}", ""
if isinstance(e, PermissionError):
return PERMISSION, "没有权限访问所需的文件或资源", ""
if isinstance(e, FileNotFoundError):
return FILE_MISSING, f"找不到文件{'「' + str(e.filename) + '」' if e.filename else ''}", ""
if isinstance(e, (OverflowError, MemoryError)):
text = str(e).strip()
return OVERFLOW, f"数据超出了可处理的范围{':' + _scrub(text) if text else ''}", ""
return None
def classify(e: BaseException) -> str | None:
"""错误属于哪一类(上面那组常量),认不出返回 None。和 explain() 的判断是同一套规则。"""
known = _known(e, sniff=True)
if known:
return known[0]
return BUG if _names(e) & _BUG else None
def explain(e: BaseException) -> tuple[str, str]:
"""接口层:(原因, 怎么办)。原因是一句人话;说不出怎么办时第二项是空串。
要按错误类别换说法的,用 classify() 拿类别,不要比较这里返回的文字。"""
known = _known(e, sniff=True)
if known:
return known[1], known[2]
if _names(e) & _BUG:
return "服务端内部错误", "与你的操作无关,请将技术细节提供给维护者"
return first_line(e) or "发生了未说明原因的错误", ""
def describe_exception(e: BaseException) -> str:
"""执行层:原因,一句话。「发生了什么」和「怎么办」由节点按场景拼。"""
text = str(e).strip()
if isinstance(e, TypeError):
if m := re.search(r"unexpected keyword argument '(\w+)'", text):
return f"参数不匹配:不接受名为「{m.group(1)}」的参数"
if m := re.search(r"missing \d+ required (?:positional |keyword-only )?arguments?: (.+)$", text):
wanted = "、".join(re.findall(r"'(\w+)'", m.group(1))) or m.group(1)
return f"缺少必填参数「{wanted}」"
if isinstance(e, KeyError):
return f"缺少字段「{e.args[0] if e.args else '?'}」"
known = _known(e, sniff=False)
if known:
return known[1]
return _scrub(text) or "内部错误(未提供原因)"
def message(e: BaseException, lead: str = "") -> str:
"""一整句:lead + 原因 + 怎么办。给只能放一个字符串的地方(HTTPException 的 detail)。"""
reason, hint = explain(e)
text = f"{lead}{reason}" if lead else reason
return f"{text}。{hint}" if hint else text
def payload(e: BaseException, lead: str = "") -> dict[str, Any]:
"""{ok: false, error, hint, detail}:测试连接这类「失败也是正常结果」的接口用。"""
reason, hint = explain(e)
return {"ok": False, "error": f"{lead}{reason}", "hint": hint, "detail": raw(e)}
def not_configured(e: BaseException, *, with_hint: bool = True) -> str:
"""模型接入没配好(ProviderNotConfigured)给用户看的那句话。运行失败原因、助手、设置页都经过这里。
with_hint=False 只要「缺了什么」、不要「去哪里补」:设置页的测试弹窗本来就在接入的编辑框里,
助手那边自己另说去哪里。别处抛的同类异常(没有 reason 字段)原话里可能还是内部叫法
(provider 'x'、base_url),一并换成设置页上的说法。"""
text = str(e) if with_hint else str(getattr(e, "reason", "") or e)
text = re.sub(r"provider '([^']*)'", r"模型接入「\1」", text)
text = re.sub(r"模型 '([^']*)'", r"模型「\1」", text)
return text.replace("base_url", "Base URL").rstrip("。")
def _has_cjk(text: str) -> bool:
return any("一" <= ch <= "鿿" for ch in text)
def _first_validation(e: Any) -> str:
err = (e.errors() or [{}])[0]
loc = ".".join(str(p) for p in err.get("loc") or ())
msg = str(err.get("msg") or "").removeprefix("Value error, ")
if _has_cjk(msg):
return msg
if not loc:
return "格式有误"
return f"缺少「{loc}」" if err.get("type") == "missing" else f"「{loc}」格式有误"
def graph_error(e: BaseException) -> str:
"""工作流结构读不懂时,指到是哪个节点、哪一项。
pydantic 的原文是一大段英文,还列了全部十几种节点类型和一个文档链接,
用户从里面找不到自己错在哪。
"""
errors = e.errors() if callable(getattr(e, "errors", None)) else None
if not errors:
return explain(e)[0]
err = errors[0]
loc = list(err.get("loc") or ())
msg = str(err.get("msg") or "").removeprefix("Value error, ")
more = f"(另有 {len(errors) - 1} 处)" if len(errors) > 1 else ""
if len(loc) >= 2 and loc[0] in ("nodes", "edges") and isinstance(loc[1], int):
where = f"第 {loc[1] + 1} 个{'节点' if loc[0] == 'nodes' else '连线'}"
field = str(loc[2]) if len(loc) > 2 else ""
if err.get("type") == "enum" and field == "type":
return f"{where}的类型「{err.get('input')}」无法识别{more}"
if err.get("type") == "missing":
return f"{where}缺少「{field}」{more}"
if _has_cjk(msg):
return f"{where}:{msg}{more}"
return f"{where}的「{field or '内容'}」格式有误{more}"
if _has_cjk(msg):
return msg + more
return f"「{'.'.join(str(p) for p in loc) or '整体'}」格式有误{more}"