做 cloud#1116(把 Turso RemoteTransport 的过滤器拒收搬进 ADR-0112 信封,与 #5368 对齐)时扫到。不在那个 PR 范围内(跨仓),按 Prime Directive #10 单独记在这里,unassigned。
成因
packages/rest/src/rest-server.ts 有两处同样的 4xx 直通分支,都按 500 字符做二分:
mapDataError(:566-569)
if (typeof error?.status === 'number' && error.status >= 400 && error.status < 500) {
const msg = typeof error?.message === 'string' && error.message.length > 0 && error.message.length < 500
? error.message
: 'Request failed';
sendError(:785-790)
const safeMsg = typeof error.message === 'string' && error.message.length < 500
? error.message
: 'Request failed';
超过 500 字符不是截断,是整条替换。code / status 照常落地,正文全部消失。
为什么现在是 bug
driver-sql 的过滤器拒收自 #4436 起就带 status: 400,#5368 又新增了一条。逐条量它们的字面量长度(9c5abf4e9 的 sql-driver.ts,${…} 按 4 字符占位,即低估):
| # |
拒收 |
约 |
| 1 |
A filter ARRAY reached the driver…(#5158) |
503 |
| 7 |
Operator "$null" … requires a boolean comparand…(#5347/#5368) |
585 |
| 5 |
Field constraint at … carries zero operators(#5240) |
469 |
| 6 |
Unsupported filter combinator …(#5327) |
454 |
| 2 |
跨字段比较 |
349 |
| 4 |
Filter node at … not a filter condition(#5134) |
402 |
第 1、7 两条已经越线;第 5、6 条差 30-50 字符,而 safeShapePreview 单独就能贴进 80 字符、字段名和 path 还要再加——真实调用里同样越线。
所以 #5347 那条精心写出来的句子——「注意 "false" 这个字符串是 truthy,所以它落到了它本意的反面」——REST 客户端拿到的是:
{ "code": "INVALID_FILTER", "error": "Request failed" }
写这条 message 的唯一目的就是告诉作者他写错在哪,而它恰好因为写得够详细而被丢掉。
unsupportedFilterError 自己的注释还把这件事讲反了(sql-driver.ts :456):
status: 400 makes @objectstack/rest's sendError pass the message through instead of routing it to the SQL-leak heuristic
实测:不带 status 时走的是函数底部 return { status: 400, body: { error: raw } },原文完整直通(looksLikeInternalErrorLeak 不命中这些措辞);带上 status: 400 反而进了这个 500 字符闸门。也就是说在 ≥500 字符这一档,加 status 让客户端可读性变差了——这显然不是 #4436 的本意。
波及面
不止过滤器:任何带 4xx status 的领域错误都吃这一刀(plugin-sharing 的 record-scope 拒绝、metadata save validator 的 422 等)。凡是「写得越清楚越容易被吞」的错误都在这个集合里。
建议(不代裁决)
倾向 B:
- A:把上限调高(比如 2000)。一行改完,但没解决「二分」本身——只是把悬崖挪远一点,下一条写长的 message 照样掉下去,而且掉下去的方式仍然是静默整条替换。
- B(推荐):截断而不是替换 ——
error.message.slice(0, N) + '…',与驱动侧 safeShapePreview / cloud preview() 已有的做法同源。前 N 个字符恰好是每条 message 的主句(操作符、字段、收到了什么、协议怎么声明的),被砍掉的是尾部的归因与 issue 号——正好是日志该留、客户端不必读的部分。这条闸门的本意是防 SQL/内部细节泄漏,而这些 message 已经过了 looksLikeInternalErrorLeak;长度从来不是泄漏的代理指标。
- C:什么都不改,改短 message。把压力推给每一个驱动作者,且是不可执行的约定——没有任何 gate 量长度,越线是静默的。反方向。
B 的话建议同时补一条 pin:给一个 600 字符的 4xx 错误跑 mapDataError,断言正文的主句还在。今天没有任何测试观察这个分支的长 message 一侧,这也是它能安静地存在到现在的原因。
未验证的部分
按代码读 + 逐条量长度得出,没有起 REST server 打真实请求。两处分支的判据一模一样,但只核过 mapDataError 这一处的上下游;sendError 那处(:785)是同款写法,按同款处理,未单独走通。也没清点非过滤器类 4xx 里实际有多少条越线。
关联
#4436(过滤器拒收进信封的那一单)、#5368 / #5347(本次触发排查的 $null 措辞)、#5158 / #5240 / #5327 / #5134(其余越线或接近越线的拒收)、#5367(同族:analytics 路由靠 message 正则分类)、cloud#1116(下游把同一批拒收搬进同一个信封,会吃同一刀)。
Generated by Claude Code
做 cloud#1116(把 Turso
RemoteTransport的过滤器拒收搬进 ADR-0112 信封,与 #5368 对齐)时扫到。不在那个 PR 范围内(跨仓),按 Prime Directive #10 单独记在这里,unassigned。成因
packages/rest/src/rest-server.ts有两处同样的 4xx 直通分支,都按 500 字符做二分:mapDataError(:566-569)sendError(:785-790)超过 500 字符不是截断,是整条替换。
code/status照常落地,正文全部消失。为什么现在是 bug
driver-sql的过滤器拒收自 #4436 起就带status: 400,#5368 又新增了一条。逐条量它们的字面量长度(9c5abf4e9的sql-driver.ts,${…}按 4 字符占位,即低估):A filter ARRAY reached the driver…(#5158)Operator "$null" … requires a boolean comparand…(#5347/#5368)Field constraint at … carries zero operators(#5240)Unsupported filter combinator …(#5327)Filter node at … not a filter condition(#5134)第 1、7 两条已经越线;第 5、6 条差 30-50 字符,而
safeShapePreview单独就能贴进 80 字符、字段名和 path 还要再加——真实调用里同样越线。所以 #5347 那条精心写出来的句子——「注意
"false"这个字符串是 truthy,所以它落到了它本意的反面」——REST 客户端拿到的是:{ "code": "INVALID_FILTER", "error": "Request failed" }写这条 message 的唯一目的就是告诉作者他写错在哪,而它恰好因为写得够详细而被丢掉。
unsupportedFilterError自己的注释还把这件事讲反了(sql-driver.ts:456):实测:不带
status时走的是函数底部return { status: 400, body: { error: raw } },原文完整直通(looksLikeInternalErrorLeak不命中这些措辞);带上status: 400反而进了这个 500 字符闸门。也就是说在 ≥500 字符这一档,加status让客户端可读性变差了——这显然不是 #4436 的本意。波及面
不止过滤器:任何带 4xx
status的领域错误都吃这一刀(plugin-sharing 的 record-scope 拒绝、metadata save validator 的 422 等)。凡是「写得越清楚越容易被吞」的错误都在这个集合里。建议(不代裁决)
倾向 B:
error.message.slice(0, N) + '…',与驱动侧safeShapePreview/ cloudpreview()已有的做法同源。前 N 个字符恰好是每条 message 的主句(操作符、字段、收到了什么、协议怎么声明的),被砍掉的是尾部的归因与 issue 号——正好是日志该留、客户端不必读的部分。这条闸门的本意是防 SQL/内部细节泄漏,而这些 message 已经过了looksLikeInternalErrorLeak;长度从来不是泄漏的代理指标。B 的话建议同时补一条 pin:给一个 600 字符的 4xx 错误跑
mapDataError,断言正文的主句还在。今天没有任何测试观察这个分支的长 message 一侧,这也是它能安静地存在到现在的原因。未验证的部分
按代码读 + 逐条量长度得出,没有起 REST server 打真实请求。两处分支的判据一模一样,但只核过
mapDataError这一处的上下游;sendError那处(:785)是同款写法,按同款处理,未单独走通。也没清点非过滤器类 4xx 里实际有多少条越线。关联
#4436(过滤器拒收进信封的那一单)、#5368 / #5347(本次触发排查的
$null措辞)、#5158 / #5240 / #5327 / #5134(其余越线或接近越线的拒收)、#5367(同族:analytics 路由靠 message 正则分类)、cloud#1116(下游把同一批拒收搬进同一个信封,会吃同一刀)。Generated by Claude Code