Skip to content

Commit bc0da98

Browse files
committed
fix: 探测失败原因改为可操作枚举,不再暴露 Python 异常类名
本地验证时界面显示「探测失败:UpstreamProtocolViolation」——这是实现细节 泄漏:用户从类名既判断不出问题,也不知道该做什么,而且重构时还会漂移。 - 新增 describe_probe_failure():把异常翻译成稳定的机器可读枚举 credential_rejected / rate_limited / upstream_unavailable / upstream_rejected / upstream_response_invalid / upstream_timeout / unknown_error - 原始错误摘要移入 detail 字段,仅供排查 - 前端 probeFailureLabel() 翻成中文说明(如「凭证被上游拒绝,需要重新登录该账号」), 未知枚举值兜底为「未知错误」;detail 单独展示在提示右侧 - TECHNICAL.md §6.5 记录该契约与原因对照表 本地真实进程 + 浏览器验证: reason=credential_rejected, detail="upstream http 401",界面无类名泄漏 后端 562 测试 / 覆盖 100%;前端 74 测试 / tsc 零错误。
1 parent fec76a3 commit bc0da98

11 files changed

Lines changed: 209 additions & 20 deletions

File tree

‎TECHNICAL.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,32 @@ class Scheduler:
245245

246246
---
247247

248+
## 6.5 面向用户的错误语义
249+
250+
管理端点返回的失败原因必须是**稳定的机器可读枚举**,不能是 Python 异常类名。
251+
类名是实现细节:用户既判断不出问题,也不知道下一步做什么,而且重构时会漂移。
252+
253+
`POST /api/credentials/{id}/probe` 的失败响应:
254+
255+
```json
256+
{ "probed": false, "reason": "credential_rejected", "detail": "upstream http 401" }
257+
```
258+
259+
| `reason` | 含义 | 用户该做什么 |
260+
|---|---|---|
261+
| `credential_rejected` | 上游 401/403 拒绝凭证 | 重新登录该账号 |
262+
| `rate_limited` | 上游 429 | 稍后重试 |
263+
| `upstream_unavailable` | 上游 5xx | 等待上游恢复,与本账号无关 |
264+
| `upstream_rejected` | 其他 4xx | 检查账号状态 |
265+
| `upstream_response_invalid` | 响应结构不符 | 可能是官方接口变更 |
266+
| `upstream_timeout` | 请求超时 | 重试 |
267+
| `unknown_error` | 未归类 | 查 `detail` |
268+
269+
`detail` 保留原始错误摘要**仅供排查**,界面不得把它当作主提示展示。
270+
前端 `probeFailureLabel()` 负责把 `reason` 翻成中文,并对未知值兜底。
271+
272+
未识别的 `reason` 必须回退到 `unknown_error`,不得透传原始字符串。
273+
248274
## 7. 数据库(T-Q2 定稿)
249275

250276
DDL 以 PROPOSAL §5 为准(users.txt 为用户唯一源、无 users 表、凭证加密列、usage_events.credit 可空),补充实现细节:

‎src/main.py‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,9 @@ async def probe_credential(credential_id: str,
330330
quota = await provider.probe_quota(data)
331331
except Exception as error: # noqa: BLE001 - 探测失败 → unknown,不当作 0
332332
credentials.mark_probe_failed(credential_id)
333-
return {"probed": False, "reason": type(error).__name__}
333+
reason = describe_probe_failure(error)
334+
logger.info("额度探测失败 %s: %s", credential_id, reason)
335+
return {"probed": False, "reason": reason, "detail": str(error)[:200]}
334336
credentials.save_quota(credential_id, quota)
335337
return {"probed": True, "remaining": quota.remaining, "total": quota.total,
336338
"cycle_end": quota.cycle_end}
@@ -481,6 +483,36 @@ def resolve_public_callback_url(settings: Settings) -> str:
481483
return settings.public_base_url.rstrip("/") + "/authorize"
482484

483485

486+
def describe_probe_failure(error: Exception) -> str:
487+
"""把探测异常翻译成用户能据以行动的原因。
488+
489+
不能直接暴露 Python 类名(如 UpstreamProtocolViolation)——那是实现细节,
490+
用户看到它既判断不出问题,也不知道下一步该做什么。
491+
"""
492+
from .provider.codebuddy.client import UpstreamHTTPError as CodeBuddyHTTPError
493+
from .provider.codebuddy.events import (
494+
UpstreamProtocolViolation as CodeBuddyViolation,
495+
)
496+
from .provider.trae.client import UpstreamHTTPError as TraeHTTPError
497+
from .provider.trae.events import UpstreamProtocolViolation as TraeViolation
498+
499+
http_errors = (CodeBuddyHTTPError, TraeHTTPError)
500+
if isinstance(error, http_errors):
501+
status = getattr(error, "status", 0)
502+
if status in (401, 403):
503+
return "credential_rejected" # 凭证失效,需要重新登录
504+
if status == 429:
505+
return "rate_limited" # 上游限流,稍后再试
506+
if status >= 500:
507+
return "upstream_unavailable" # 上游故障,与凭证无关
508+
return "upstream_rejected" # 上游拒绝该请求
509+
if isinstance(error, (CodeBuddyViolation, TraeViolation)):
510+
return "upstream_response_invalid" # 响应结构不符,可能是上游改版
511+
if isinstance(error, TimeoutError):
512+
return "upstream_timeout"
513+
return "unknown_error"
514+
515+
484516
def _upstream_auth(registry: dict, settings: Settings) -> dict:
485517
"""返回支持 poll 轨道的 provider 的 OAuth 实现(当前仅 CodeBuddy)。"""
486518
flows: dict = {}

‎tests/test_deployment_assets.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,40 @@ def test_ci_workflow_paths_match_repository():
115115
assert (ROOT / path).exists(), f"CI 引用 {path} 但它不存在"
116116
# 覆盖率门槛必须保留
117117
assert "--cov-fail-under=100" in workflow
118+
119+
120+
# ------------------------------------------------- 探测失败原因的分类
121+
122+
def test_describe_probe_failure_maps_http_status_to_actionable_reason():
123+
"""探测失败原因必须可操作,不能是 Python 类名。"""
124+
from src.main import describe_probe_failure
125+
from src.provider.codebuddy.client import UpstreamHTTPError as CBHTTP
126+
from src.provider.trae.client import UpstreamHTTPError as TraeHTTP
127+
128+
assert describe_probe_failure(CBHTTP(401, b"")) == "credential_rejected"
129+
assert describe_probe_failure(CBHTTP(403, b"")) == "credential_rejected"
130+
assert describe_probe_failure(TraeHTTP(429, b"")) == "rate_limited"
131+
assert describe_probe_failure(TraeHTTP(503, b"")) == "upstream_unavailable"
132+
assert describe_probe_failure(TraeHTTP(400, b"")) == "upstream_rejected"
133+
134+
135+
def test_describe_probe_failure_maps_protocol_violation():
136+
from src.main import describe_probe_failure
137+
from src.provider.codebuddy.events import (
138+
UpstreamProtocolViolation as CBViolation,
139+
)
140+
from src.provider.trae.events import UpstreamProtocolViolation as TraeViolation
141+
142+
assert describe_probe_failure(CBViolation("x")) == "upstream_response_invalid"
143+
assert describe_probe_failure(TraeViolation("x")) == "upstream_response_invalid"
144+
145+
146+
def test_describe_probe_failure_handles_timeout_and_unknown():
147+
from src.main import describe_probe_failure
148+
149+
assert describe_probe_failure(TimeoutError()) == "upstream_timeout"
150+
assert describe_probe_failure(RuntimeError("boom")) == "unknown_error"
151+
# 任何情况下都不得把类名当 reason
152+
for error in (RuntimeError("x"), ValueError("y"), KeyError("z")):
153+
reason = describe_probe_failure(error)
154+
assert type(error).__name__ not in reason

‎tests/test_m15_operations.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1311,7 +1311,11 @@ async def probe_quota(self, _data):
13111311

13121312
app.state.executor._deps.providers["codebuddy"] = Boom()
13131313
body = client.post(f"/api/credentials/{credential_id}/probe").json()
1314-
assert body["probed"] is False and body["reason"] == "RuntimeError"
1314+
assert body["probed"] is False
1315+
# 不能把 Python 类名当作 reason 暴露给用户
1316+
assert body["reason"] == "unknown_error"
1317+
assert "RuntimeError" not in body["reason"]
1318+
assert body["detail"] == "boom"
13151319
assert app.state.credentials.candidates()[0].health is None
13161320

13171321

‎web/src/api/__tests__/client.test.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
formatNumber,
1212
formatTime,
1313
healthView,
14+
probeFailureLabel,
1415
quotaSemantics,
1516
} from "../display";
1617
import { makeCredential } from "../../pages/__tests__/helpers";
@@ -207,6 +208,13 @@ describe("display helpers", () => {
207208
expect(formatDuration(172800)).toBe("2.0 天");
208209
});
209210

211+
it("探测失败原因有中文文案,且未知值有兜底", () => {
212+
expect(probeFailureLabel("credential_rejected")).toContain("重新登录");
213+
expect(probeFailureLabel("upstream_unavailable")).toContain("与本账号凭证无关");
214+
expect(probeFailureLabel(undefined)).toBe("未知错误");
215+
expect(probeFailureLabel("something_new" as never)).toBe("未知错误");
216+
});
217+
210218
it("时间与数字格式化处理空值", () => {
211219
expect(formatTime(null)).toBe("—");
212220
expect(formatTime(1_700_000_000)).not.toBe("—");

‎web/src/api/client.ts‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ApiKeyCreated,
44
CredentialsResponse,
55
ModelInfo,
6+
ProbeResult,
67
ProviderStats,
78
SessionInfo,
89
StatsOverview,
@@ -64,10 +65,7 @@ export const api = {
6465
deleteCredential: (id: string) =>
6566
request<{ ok: boolean }>(`/api/credentials/${id}`, { method: "DELETE" }),
6667
probeCredential: (id: string) =>
67-
request<{ probed: boolean; remaining?: number; total?: number; reason?: string }>(
68-
`/api/credentials/${id}/probe`,
69-
{ method: "POST" },
70-
),
68+
request<ProbeResult>(`/api/credentials/${id}/probe`, { method: "POST" }),
7169
checkinCredential: (id: string) =>
7270
request<{ ok: boolean; credit: number | null; code: number | null; message: string }>(
7371
`/api/credentials/${id}/checkin`,

‎web/src/api/display.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* 展示层必须区分它们,否则探测失败会被误读成「没额度」。
66
*/
77

8-
import type { Credential, Health } from "../api/types";
8+
import type { Credential, Health, ProbeFailureReason } from "../api/types";
99

1010
export type HealthKind = "known" | "unknown" | "exhausted";
1111

@@ -100,3 +100,18 @@ export function formatNumber(value: number | null | undefined): string {
100100
if (value === null || value === undefined) return "—";
101101
return value.toLocaleString("zh-CN");
102102
}
103+
104+
105+
export const PROBE_FAILURE_LABEL: Record<ProbeFailureReason, string> = {
106+
credential_rejected: "凭证被上游拒绝,需要重新登录该账号",
107+
rate_limited: "上游限流,稍后重试",
108+
upstream_unavailable: "上游服务异常,与本账号凭证无关",
109+
upstream_rejected: "上游拒绝了这次请求",
110+
upstream_response_invalid: "上游响应格式与预期不符,可能是官方接口变更",
111+
upstream_timeout: "上游响应超时",
112+
unknown_error: "未知错误",
113+
};
114+
115+
export function probeFailureLabel(reason: ProbeFailureReason | undefined): string {
116+
return reason ? (PROBE_FAILURE_LABEL[reason] ?? "未知错误") : "未知错误";
117+
}

‎web/src/api/types.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,25 @@ export interface UpstreamAuthPoll {
9393
credential_id?: string;
9494
}
9595

96+
/** 后端保证是稳定的机器可读枚举,不是 Python 异常类名。 */
97+
export type ProbeFailureReason =
98+
| "credential_rejected"
99+
| "rate_limited"
100+
| "upstream_unavailable"
101+
| "upstream_rejected"
102+
| "upstream_response_invalid"
103+
| "upstream_timeout"
104+
| "unknown_error";
105+
106+
export interface ProbeResult {
107+
probed: boolean;
108+
remaining?: number;
109+
total?: number;
110+
reason?: ProbeFailureReason;
111+
/** 原始错误摘要,仅用于排查;界面不应直接展示给普通用户 */
112+
detail?: string;
113+
}
114+
96115
export interface ModelInfo {
97116
id: string;
98117
object: string;

‎web/src/pages/CredentialsPage.tsx‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
formatNumber,
1010
formatTime,
1111
healthView,
12+
probeFailureLabel,
1213
quotaSemantics,
1314
STATE_LABEL,
1415
STATE_TONE,
@@ -40,6 +41,7 @@ export function CredentialsPage() {
4041
const [accounts, setAccounts] = useState<{ account_id: string; nickname: string; type: string }[]>([]);
4142
// 每个上游各自可能有进行中的登录(CodeBuddy 轮询 / TRAE 回调)
4243
const [loginProviders, setLoginProviders] = useState<Provider[]>([]);
44+
const [probeDetail, setProbeDetail] = useState<string | null>(null);
4345

4446
const credentials = data?.credentials ?? [];
4547
const isAdmin = data?.is_admin ?? false;
@@ -82,14 +84,21 @@ export function CredentialsPage() {
8284
setBusy(true);
8385
setError(null);
8486
setNotice(null);
87+
setProbeDetail(null);
8588
try {
8689
const result = await api.probeCredential(credential.id);
87-
// 探测失败表示「未知」,绝不能显示成额度为 0
88-
setNotice(
89-
result.probed
90-
? `探测成功:剩余 ${formatNumber(result.remaining)} / ${formatNumber(result.total)}`
91-
: `探测失败:${result.reason ?? "上游未返回额度"}(健康度保持为「未探测」)`,
92-
);
90+
// 探测失败表示「未知」,绝不能显示成额度为 0。
91+
// 失败原因用可操作的中文说明,不直接把后端枚举或异常类名丢给用户。
92+
if (result.probed) {
93+
setNotice(
94+
`探测成功:剩余 ${formatNumber(result.remaining)} / ${formatNumber(result.total)}`,
95+
);
96+
} else {
97+
setNotice(
98+
`探测失败:${probeFailureLabel(result.reason)}(健康度保持为「未探测」)`,
99+
);
100+
if (result.detail) setProbeDetail(result.detail);
101+
}
93102
await refresh();
94103
} catch (caught) {
95104
setError(caught instanceof Error ? caught.message : "探测失败");
@@ -204,7 +213,14 @@ export function CredentialsPage() {
204213
)}
205214
{notice && (
206215
<div data-testid="credentials-notice">
207-
<Notice tone="ok">{notice}</Notice>
216+
<Notice tone="ok">
217+
{notice}
218+
{probeDetail && (
219+
<span className="ml-2 text-[var(--color-ink-muted)]" data-testid="probe-detail">
220+
原始错误:{probeDetail}
221+
</span>
222+
)}
223+
</Notice>
208224
</div>
209225
)}
210226

‎web/src/pages/__tests__/CredentialsPage.test.tsx‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,17 @@ describe("CredentialsPage", () => {
159159
);
160160
});
161161

162-
it("探测失败时提示「未探测」而非额度为 0", async () => {
162+
it("探测失败时提示「未探测」而非额度为 0,并给出可操作原因", async () => {
163163
vi.stubGlobal(
164164
"fetch",
165165
vi.fn(async (input: RequestInfo | URL) => {
166166
const url = String(input);
167167
if (url.includes("/probe")) {
168-
return jsonResponse({ probed: false, reason: "CredentialQuotaProbeError" });
168+
return jsonResponse({
169+
probed: false,
170+
reason: "upstream_response_invalid",
171+
detail: "quota response missing Accounts",
172+
});
169173
}
170174
return jsonResponse(listBody([makeCredential({ id: "cred_1" })]));
171175
}),
@@ -178,6 +182,29 @@ describe("CredentialsPage", () => {
178182
const notice = await screen.findByTestId("credentials-notice");
179183
expect(notice).toHaveTextContent("探测失败");
180184
expect(notice).toHaveTextContent("未探测");
185+
// 面向用户的是可操作的中文说明,不是枚举或异常类名
186+
expect(notice).toHaveTextContent("上游响应格式与预期不符");
187+
expect(notice).not.toHaveTextContent("upstream_response_invalid");
188+
// 原始错误另置于折叠区,便于排查
189+
expect(screen.getByTestId("probe-detail")).toHaveTextContent(
190+
"quota response missing Accounts",
191+
);
192+
});
193+
194+
it("探测失败的未知原因有兜底文案", async () => {
195+
vi.stubGlobal(
196+
"fetch",
197+
vi.fn(async (input: RequestInfo | URL) => {
198+
const url = String(input);
199+
if (url.includes("/probe")) return jsonResponse({ probed: false });
200+
return jsonResponse(listBody([makeCredential({ id: "cred_1" })]));
201+
}),
202+
);
203+
204+
renderPage(<CredentialsPage />, ADMIN);
205+
await settle();
206+
await userEvent.click(screen.getByRole("button", { name: "探测" }));
207+
expect(await screen.findByTestId("credentials-notice")).toHaveTextContent("未知错误");
181208
});
182209

183210
it("探测成功展示剩余与总量", async () => {

0 commit comments

Comments
 (0)