Skip to content

Commit 3536613

Browse files
committed
fix(qoder): 保活帧不打断流、排队不误判死、解密失败不连累整批
服务端日志里的三类真错误,各有根因,逐个修掉并补回归测试。 **1. Qoder 保活帧打断整条流**(日志主因,16:29-16:53 连续 13 次 「流式响应失败: Qoder inner chunk is not an object」) 上游在流中发 `{"headers":…,"body":"null","statusCodeValue":200}` 保活。 我们把「内层不是对象」判成协议违规 → 抛 `UpstreamProtocolViolation` → 整条流判失败——而模型其实已在正常输出,客户端只看到回复被截断。 这正是「静默出错」的典型形态:不是报错,而是**不说话了**。 判据改用**结构**(`json.loads` 后是 `None` / 空 dict / 空 list), 新增 `events.is_keepalive_body`,保活帧直接跳过。 **2. 排队报文被漏判 → 好凭证被硬禁用** 上游把「排队中」包成 403,而排队标记藏在**再序列化过的字符串**里 (`{"code":"403","message":"{\"code\":\"10605\",…isQueued…}"}`)。 `parse_queue_hint` 只认 dict/list → 漏判 → 403 落 `classify_error_code` 的 DEAD → **可自愈的凭证被打进冷宫**。 改三处:下钻字符串(串里套串不额外消耗深度预算,秒数常在最内层); 标记节点无秒数时继续往深处找;排队判据同时放进 `classify_error_code` (而非只在调用点),保证任何入口都不漏判。 **3. 一条坏凭证连累整批**(14:48「凭证解密失败:APP_SECRET 是否被更换过?」→ 500) 解密失败早先直达顶层 → 整个聊天请求 500、整轮后台任务挂掉、整页模型 列表 500,其余好凭证白白连坐。 按路径分两种口径:**批处理路径**(选号、模型列表、后台任务、即时探测) 捕获后跳过该凭证(记 skipped + 一条 ERROR 日志),其余照常; **单条探测端点**照旧如实报可行动的 500——管理员该去处理这个号。 **4. 签到失败不再 500** 上游拒绝(账号被禁用 / 登录过期)是这条凭证的真实答复,属**正常结果**: 收成 `ok=false` + 可读 message,不再是 ASGI 500 + 一整屏 traceback。 另:管理台签到按钮此前只弹「签到失败」,看不出真实原因。 改动:src/provider/{base,qoder/*}.py、src/engine/executor.py、 src/api/{models,admin_credentials}.py、src/tasks/*、src/main.py、 src/db/repo.py;文档同步 TECHNICAL / README(中英)。 测试:新增 3 个文件 + 9 处用例,共 4012 passed、覆盖率 100%。
1 parent 04218a3 commit 3536613

26 files changed

Lines changed: 794 additions & 27 deletions

‎README.en.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,8 @@ Free models at [Kilo](https://kilo.ai) (`api.kilo.ai/api/gateway`), no login. St
191191

192192
**Upstream node failures**: Qoder sometimes wraps own node failures in a 400 (`[FAIL]node:…`) → model-scoped transient fault, only that model cools; exhausted candidates → 503 (`no_healthy_credential`).
193193

194+
**Keepalive frames and queueing**: the upstream sends keepalive frames with `body:"null"` (outer status 200) mid-stream → skipped, never interrupting output; "queued" arrives as a 403 whose queue marker hides inside a **serialized string** → classified as QUEUE, waits the upstream's named seconds and reopens on the same credential instead of hard-disabling it.
195+
194196
### CodeArts (`codearts`)
195197

196198
[Huawei Cloud CodeArts](https://codearts.huaweicloud.com), real account: *Log in to CodeArts*, authorize, then paste the **unreachable local URL** back; server exchanges it for STS credentials (AK/SK + security_token + refresh_token + DPoP key) and stores them encrypted.
@@ -362,6 +364,7 @@ The credentials page offers multi-select plus batch bar (probe / check-in / paus
362364

363365
- **Endpoint**: `POST /api/credentials/batch`, body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`. Up to 200 ids, duplicates deduped.
364366
- **Per-item isolation**: one failure never blocks the rest. Response `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`.
367+
- **A failed check-in is a result, not a failure**: an upstream refusal (account disabled / session expired / rate limited) returns `ok=false` with a readable `message`, never 500 — otherwise the console only says "check-in failed" and hides the real reason.
365368
- **Pause is explicit state, not toggle-flip**: `enabled` comes from body → bar has separate *Pause* / *Unpause* buttons.
366369
- **Revive ≠ unpause**: revive clears system hard-disable (needs re-login first); unpause sets `toggle enabled=true`.
367370
- Only actions with existing single-item endpoints: `refresh` and `delete` are excluded.
@@ -602,7 +605,7 @@ Full list in `src/config.py` (authoritative); every item wired through `docker-c
602605

603606
| Variable | Default | Notes |
604607
|---|---|---|
605-
| `APP_SECRET` | **required** | Credential encryption key, ≥16 chars; losing it voids every credential. Low-entropy values rejected |
608+
| `APP_SECRET` | **required** | Credential encryption key, ≥16 chars; losing it voids every credential. Low-entropy values rejected. **After rotating it, old credentials no longer decrypt**: a single probe returns an actionable error code, while chat and background tasks just skip that one bad credential and carry on |
606609
| `ADMIN_USERNAMES` | empty | Bootstrap only (no active admin in the DB yet): promoted to `admin` on first start |
607610
| `USERS_FILE` | `secrets/users.txt` | Bootstrap only: legacy user file imported once |
608611
| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | Browser-reachable address; TRAE login callback; HSTS when https. Does not decide the session cookie `Secure` flag |

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ curl http://127.0.0.1:8000/v1/chat/completions \
193193

194194
**上游节点故障**:Qoder 有时把自身节点故障包成 400(`[FAIL]node:…`)→ 判模型级瞬时故障,只冷却该模型。候选耗尽 → 503(`no_healthy_credential`)。
195195

196+
**保活帧与排队**:上游在流中发 `body:"null"` 的保活帧(外层 200)→ 跳过、不打断输出;「排队中」包成 403 且排队标记藏在**序列化字符串**里 → 归 QUEUE 等上游点名的秒数后在原号重开,不硬禁用凭证。
197+
196198
### CodeArts(`codearts`)
197199

198200
[华为云 CodeArts](https://codearts.huaweicloud.com),需真实账号:点「登录 CodeArts」→ 授权后浏览器跳到打不开的本地地址 → **整条链接**粘回页面换 STS 凭证(AK/SK + security_token + refresh_token + DPoP 私钥),加密入库。
@@ -364,6 +366,7 @@ claude
364366

365367
- **端点**:`POST /api/credentials/batch`,body `{action: "probe"|"toggle"|"checkin"|"revive", ids: [...], enabled?}`。一次 ≤200 条,重复 id 去重。
366368
- **逐条隔离**:单条失败不影响其余。响应 `{action, total, succeeded, failed, results: [{id, ok, error?, detail?}]}`。
369+
- **签到失败是结果不是故障**:上游拒绝(账号被禁用 / 登录过期 / 限流)回 `ok=false` + 可读 `message`,不 500——否则管理台只弹「签到失败」,看不出真实原因。
367370
- **暂停是显式状态而非取反**:`toggle` 的 `enabled` 由请求体给出 → 操作条拆「暂停」/「取消暂停」两按钮。
368371
- **「恢复」≠「取消暂停」**:前者 `revive`(解系统硬禁用,需先重新登录);后者 `toggle enabled=true`。
369372
- 只收已有单条端点的动作:`refresh` / `delete` 不走此通道。
@@ -604,7 +607,7 @@ python3 scripts/restore_db.py data/backups/coding2api-20261014-030000.sqlite3 --
604607

605608
| 变量 | 默认 | 说明 |
606609
|---|---|---|
607-
| `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全废。拒低熵值 |
610+
| `APP_SECRET` | **必填** | 凭证加密密钥,≥16 字符;丢失 = 凭证全废。拒低熵值。**换密钥后旧凭证解不开**:单条探测会回可行动的错误码,而对话 / 后台任务只跳过那条坏凭证,其余照常 |
608611
| `ADMIN_USERNAMES` | 空 | 仅引导期(库里还没有活跃 admin):逗号分隔,首次启动提权 `admin` |
609612
| `USERS_FILE` | `secrets/users.txt` | 仅引导期:老式用户文件,启动导入一次 |
610613
| `PUBLIC_BASE_URL` | `http://127.0.0.1:8000` | 浏览器可达地址;TRAE 登录回调用;https 下发 HSTS。不决定会话 Cookie 的 `Secure`。配回环 / `0.0.0.0` → 回调基址取当前请求 origin(见「部署注意」) |

‎TECHNICAL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ MODEL_SCOPED_KINDS = frozenset({MODEL, BLOCKED, CONCURRENCY})
115115

116116
识别 `provider.base.parse_queue_hint`(信封可嵌套,最多下钻 3 层):`isQueued: true` / `subCode ∈ {RESOURCE_LIMIT, RESOURCE_LIMIT_EXCEEDED}` 任一成立即认定。**宁可漏判不可误判**:识别不出走原分类,绝不把普通失败当排队无限等。Qoder `_decode_frame` 须**先于** `classify_error_code` 拦下——上游把排队包成 403,按状态码分类判 DEAD 硬禁用凭证。`Event.retry_after_seconds` 承载上游点名秒数。
117117

118+
**排队标记可能藏在**序列化字符串**里**:上游常把内层报文再 `json.dumps` 一次(Qoder 排队报文串两层:`{"code":"403","message":"{\"code\":\"10605\",\"message\":\"{…isQueued…}\"}"}`)。只认 dict/list 会漏判 → 403 落 DEAD → **可自愈的凭证被硬禁用**。故 `parse_queue_hint` 下钻字符串;串里套串**不额外消耗深度预算**(深度只按 dict/list 的协议层级计,秒数常在最内层)。判据同时放进 `classify_error_code`(而非只在调用点),保证任何入口都不漏判。
119+
118120
| 上游信号 | CB | TRAE | Kilo | ErrKind |
119121
|---|---|---|---|---|
120122
| 权益耗尽 | `code=1005` | `code:1005` | — | PLAN |
@@ -349,6 +351,7 @@ class Provider(Protocol):
349351
- 签名 `md5(payload_b64 + "\n" + cosy_key + "\n" + date + "\n" + body + "\n" + path)`(`path` 去 `/algo`,query 不参与)。`payload` = 键排序紧凑 JSON `{cosyVersion,ideVersion,info,requestId,version}`;`cosy_key` = 临时 AES 密钥经服务端 RSA 公钥加密;`info` = AES-128-CBC 加密的身份 JSON。复用 `cryptography`。
350352
- `cosy-machineid`/`cosy-machinetoken` 按 `uid` 稳定派生(随机机器码触发风控)。
351353
- **信封式 SSE**:`data:{"headers":…,"body":"<内层 OpenAI chunk>","statusCodeValue":200}`。逐帧解 `body`;`body=="[DONE]"` 结束;`statusCodeValue != 200` 判上游错误(HTTP 仍可能 200)。`clean_delta` 剔除空噪声 delta。
354+
- **保活帧 `body:"null"` 必须跳过**:上游在流中发 `{"headers":…,"body":"null","statusCodeValue":200,"statusCode":"OK"}` 保活(实测 2026-10-26)。它不含内容,早先当「内层不是对象」→ 抛 `UpstreamProtocolViolation` → 整条流判失败(16:29-16:53 连续 13 次「流式响应失败: Qoder inner chunk is not an object」),而模型其实已在正常输出——客户端只看到回复被截断。判据用**结构**(`json.loads` 后是 `None` / 空 dict / 空 list)而非字符串前缀,见 `events.is_keepalive_body`。
352355
- **模型发现** `GET {gateway}/algo/api/v2/model/list?Encode=1`,**须带整套 COSY 头**(签名 body = `qoder_encode("")`);裸 GET 403,方法固定 GET(POST/PUT 400)。`id` 用 `key`(如 `qmodel_38max`)、`name` 用 `display_name`。`price_factor` = credit 消耗倍率(相对值,免费给 `0.0`)。
353356
- **签到(活动制)**:`GET /sash/api/v1/me/campaigns`(**须带 `Cosy-ClientType: 10`**,否则静默空列表)→ 筛 `actionType=="CLAIM_BENEFIT"` 且 `claimStatus=="CLAIMABLE"` → `POST .../{campaignId}/claim`(同样带头)。`replayed:true` 与 `BLOCKED`+`SAME_PERSON_ALREADY_CLAIMED` 归一为已签;其它 `BLOCKED` 才是失败。新协议无连续天数(`streak_days` 恒 None)。仅活动制接口 404/405/410 时回退旧 `daily-check-in`;两者都不可用记 skipped(`ok=False` 但不算失败)。**国际版该端点 404**。
354357
- **签到窗口 ≠ 自然日**:上游每日 **10:00(UTC+8)** 轮换 `campaignId`,窗口 `10:00 → 次日 09:59`。按自然日封账会在凌晨封掉整天 → `CheckinResult.seal_until` 回填 `checkin_window_end(payload)`(签到类活动里**最晚**的 `endAt`,不采信 `VIEW_DETAILS`)。`CheckinTask._done_scopes` = `dict[done_key → 截止时刻]`,判据 `_sealed()`(`until > now`)。
@@ -543,6 +546,7 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k
543546

544547
- 连接:`threading.local()` 每线程一个 `sqlite3.Connection(row_factory=sqlite3.Row)`。写入统一走 `Database.transaction()`,无应用层写锁,并发由 SQLite 串行化。
545548
- 加密:`Fernet(base64.urlsafe_b64encode(sha256(APP_SECRET).digest()))`;APP_SECRET 丢失 = 凭证全部不可解。启动按熵拒弱密钥(长度 ≥16、不同字符数 ≥8,拒重复字符 / 短周期重复串)。
549+
- **解密失败按「这条凭证用不了」处理,不得连累整批**:早先 `credential_data` 抛 `CredentialDecryptError` 直达顶层 → 一条坏凭证就让整个聊天请求 500、整轮后台任务(额度探测 / 预刷新 / 签到 / 成长中心 / 活跃上报 / 模型探活)挂掉、整页模型列表 500(实测 2026-10-26 14:48)。现按路径分两种口径:**批处理路径**(选号、模型列表、后台任务、即时探测)捕获后**跳过该凭证**(记 skipped + 一条 ERROR 日志),其余照常;**单条探测端点**照旧如实报可行动的 500(`credential_decrypt_failed`,管理员该去处理这个号)。
546550
- `runtime_settings(key, value, updated_at)`:纯 key/value,只存管理台改过的 key,未出现回落 env;值由 `HOT_SETTINGS` 白名单校验,非法行读取时忽略并记日志。
547551
- 模型列表与目录不占表:进程内 TTL 缓存 + `DATA_DIR/model_catalog.json` / `openrouter_catalog.json`(§9.4);签到去重由 `CheckinTask` 当日作用域集合实现。
548552

‎src/api/admin_credentials.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,25 @@ async def probe_credential_once(credentials, provider, data, credential_id: str)
179179

180180
async def checkin_credential_once(credentials, provider, data, credential_id: str,
181181
schedule_probe) -> dict:
182-
"""签到一条凭证,返回对外响应体(单条与批量共用)。"""
183-
result = await provider.checkin(data)
182+
"""签到一条凭证,返回对外响应体(单条与批量共用)。
183+
184+
**上游失败是正常结果,不是异常**:账号被禁用 / 登录过期 / 限流都是上游
185+
对这条凭证的真实答复,直接抛出去会变成 ASGI 500 + 一整屏 traceback
186+
(实测 2026-10-26 14:47 管理台点「签到」回 500,前端只弹「签到失败」,
187+
看不出是账号被禁用)。这里把异常收成 `ok=False` + 可读 message:
188+
前端本来就按 `ok` / `message` 渲染(见 `CredentialsPage` 的 checkin 分支),
189+
批量端点也不必再单独兜一次。
190+
"""
191+
try:
192+
result = await provider.checkin(data)
193+
except Exception as error: # noqa: BLE001 - 上游失败 → 失败结果,非 500
194+
reason = describe_probe_failure(error)
195+
logger.info("签到失败 %s: %s", credential_id, reason)
196+
# detail 只进服务端日志:上游原文可能回显凭证片段
197+
logger.info("签到失败详情 %s: %s", credential_id, str(error)[:200])
198+
return {"ok": False, "credit": None, "code": None,
199+
"message": f"签到失败:{reason}", "already_checked_in": False,
200+
"status": None}
184201
if result.ok and not result.already_checked_in:
185202
schedule_probe(credential_id) # 只有真签到了才会发积分
186203
# already_checked_in 必须透传:前端靠它区分「刚签到」与「今天已签过」

‎src/api/models.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040

4141
from ..benchmarks import BenchmarkTable
4242
from ..compat.openai.errors import error_payload
43+
from ..db.crypto import CredentialDecryptError
4344
from ..engine.reasoning import parse_effort_map
4445
from ..model_alias import parse_model_aliases
4546
from ..pricing import PriceTable
@@ -229,10 +230,22 @@ async def _refresh_provider(services: Services, provider_id: str, provider: Any)
229230
# 用该上游的一个可用凭证拉取(凭证有归属,模型列表是账号级的)
230231
# selectable_only:硬禁用/用户关闭的凭证取不到数据,只会白失败
231232
candidates = services.credentials.candidates([provider_id], selectable_only=True)
232-
credential_data = (
233-
services.credentials.credential_data(candidates[0].credential_id)
234-
if candidates else {}
235-
)
233+
try:
234+
credential_data = (
235+
services.credentials.credential_data(candidates[0].credential_id)
236+
if candidates else {}
237+
)
238+
except CredentialDecryptError:
239+
# 解不开(APP_SECRET 换过 / 密文损坏)→ 本上游暂时拉不了清单,但
240+
# **不该让整页模型列表 500**(实测 2026-10-26 14:48:一条坏凭证
241+
# 连累所有渠道)。按「本次拉取失败」处理:保留旧缓存、记负缓存
242+
# (避免每次请求都重试同一个坏凭证),其余上游照常。
243+
logger.error("模型列表获取失败 %s: 凭证解密失败(APP_SECRET 是否被更换过?)",
244+
provider_id)
245+
services.model_list_failures[provider_id] = (
246+
services.model_list_failures.get(provider_id, 0) + 1)
247+
services.model_list_fetched_at[provider_id] = time.monotonic()
248+
return
236249
try:
237250
models = await provider.list_models(credential_data)
238251
except Exception as error: # noqa: BLE001 - 单上游失败不影响其他

‎src/db/repo.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,14 @@ def attention_counts(self) -> dict[str, int]:
623623
return {"disabled": int(row["disabled"]), "exhausted": int(row["exhausted"])}
624624

625625
def credential_data(self, credential_id: str) -> dict[str, Any] | None:
626+
"""取凭证明文;不存在返回 None。
627+
628+
解密失败**照旧抛** `CredentialDecryptError`:它表示 APP_SECRET 换过
629+
或密文损坏,是需要人处理的状态(见 `webapp.handlers` 的可行动 500
630+
映射)。「一条坏凭证不该连累整个请求」由**调用方**处理:引擎选号
631+
与模型列表会捕获它并跳过该凭证(见 `executor._safe_credential_data`
632+
/ `api.models`),而单条探测端点把它如实报给管理员。
633+
"""
626634
row = self._db.connect().execute(
627635
"SELECT data_enc FROM credentials WHERE id = ?", (credential_id,)).fetchone()
628636
if row is None:

‎src/engine/executor.py‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
aggregate,
2121
)
2222
from ..config import live
23+
from ..db.crypto import CredentialDecryptError
2324
from ..db.repo import CredentialRepository
2425
from ..provider.base import (
2526
QUEUE_DEFAULT_WAIT_SECONDS,
@@ -612,7 +613,7 @@ def terminal(message: str, code: str) -> bytes:
612613
# 会让三次排队就把整个请求打成 503。
613614
credential_id, credential_data = pinned
614615
pinned = None
615-
if self._deps.credentials.credential_data(credential_id) is None:
616+
if self._safe_credential_data(credential_id) is None:
616617
continue
617618
provider_id = self._deps.credentials.provider_of(credential_id)
618619
state.provider, state.credential_id = provider_id or "-", credential_id
@@ -1005,7 +1006,7 @@ async def _complete_model(self, request: ChatRequest, target: ModelTarget,
10051006
if pinned is not None:
10061007
credential_id, credential_data = pinned
10071008
pinned = None
1008-
if self._deps.credentials.credential_data(credential_id) is None:
1009+
if self._safe_credential_data(credential_id) is None:
10091010
continue
10101011
provider_id = self._deps.credentials.provider_of(credential_id)
10111012
last_provider, last_credential = provider_id or "-", credential_id
@@ -1256,10 +1257,33 @@ async def _pick(self, target: ModelTarget, tried: set[str],
12561257
return None
12571258
waited = True
12581259
continue
1259-
credential_data = self._deps.credentials.credential_data(credential_id)
1260+
credential_data = self._safe_credential_data(credential_id)
12601261
if credential_data is not None:
12611262
return credential_id, credential_data
1262-
tried.add(credential_id) # 并发删除:标记后重选
1263+
tried.add(credential_id) # 并发删除 / 解不开:标记后重选
1264+
1265+
def _safe_credential_data(self, credential_id: str) -> dict[str, Any] | None:
1266+
"""取凭证明文;**解不开**时跳过该凭证而不是让整个请求失败。
1267+
1268+
解密失败(APP_SECRET 换过 / 密文损坏)早先直接冒泡 → 一条坏凭证
1269+
就让**整个聊天请求**回 500(实测 2026-10-26 14:48:
1270+
`/v1/chat/completions` 500,栈顶「凭证解密失败:APP_SECRET 是否被
1271+
更换过?」),其余好凭证白白连坐。
1272+
1273+
这里吞掉它并返回 None(= 「这条凭证用不了」,与并发删除同义)→
1274+
选号继续找下一个号。单条探测端点仍把该错误如实报给管理员
1275+
(见 `webapp.handlers` 的可行动 500),两处口径不冲突:一个是
1276+
「这个号不能用,换一个」,一个是「请处理这个号」。
1277+
1278+
走 `credential_data`(而不是仓储的 `credential_data_or_skip`):
1279+
测试替身只实现前者,后者是仓储层的具体能力。
1280+
"""
1281+
try:
1282+
return self._deps.credentials.credential_data(credential_id)
1283+
except CredentialDecryptError:
1284+
logger.error("凭证解密失败(凭证 %s):APP_SECRET 是否被更换过?"
1285+
"该凭证已跳过,其余凭证不受影响", credential_id)
1286+
return None
12631287

12641288
async def _wait_short_cooldown(self, target: ModelTarget,
12651289
tried: set[str]) -> bool:

‎src/main.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767
validate_zen_endpoint_allowed,
6868
)
6969
from .db.conn import Database
70-
from .db.crypto import CredentialCipher
70+
from .db.crypto import CredentialCipher, CredentialDecryptError
7171
from .db.key_usage import KeyUsageRepository
7272
from .db.limits import LimitsRepository, day_usage
7373
from .db.migrate import apply_schema
@@ -744,7 +744,12 @@ def schedule_probe(credential_id: str) -> None:
744744
"""
745745
provider_id = credentials.provider_of(credential_id)
746746
provider = registry.get(provider_id or "")
747-
data = credentials.credential_data(credential_id)
747+
# 解不开(APP_SECRET 换过 / 密文损坏)→ 跳过这条凭证,不让后台任务
748+
# 因一条坏凭证整轮失败
749+
try:
750+
data = credentials.credential_data(credential_id)
751+
except CredentialDecryptError:
752+
return
748753
if provider is None or data is None:
749754
return
750755

0 commit comments

Comments
 (0)