Skip to content

Commit 91ec1ea

Browse files
baozhoutaoclaude
andauthored
fix(rest): 未分类的路由错误回消毒 5xx,不再把服务端故障说成 400 客户端错误 (#5489) (#5585)
`mapDataError` 的终局兜底 —— 所有 code 匹配、显式状态直通、文本启发式全部 放弃之后的那一支 —— 原先答 `{ status: 400, body: { error: <原始 message> } }`。 两半都错在同一个方向: - 400 的语义是「你请求错了」,SDK / 代理 / 重试策略据此判定不要重试。真正落到 这一支的恰恰相反:元数据存储读不到时 `matchEndpoint` 按契约抛错(ADR-0110 D3,抛就是为了让 outage 不伪装成 miss),或者处理器自身的 `TypeError`。 实测 `GET /api/v1/meta/api` 对着抛 `Error('metadata store unreachable')` 的存储:HTTP 400。 - 原文逐字下发,而这是全文件里最没有证据可以下发的一条路径:走到这里的前提 就是 `looksLikeInternalErrorLeak` 什么都没匹配上。 改为 `UNCLASSIFIED_FAULT()`:`500 {error:'Internal server error', code:'INTERNAL_ERROR'}`。`INTERNAL_ERROR` 而非 `DATA_STORE_FAULT` 的 `DATABASE_ERROR` —— 后者用在证据指名了存储故障的地方,而这一支的定义性事实 是没有任何证据;`INTERNAL_ERROR` 是 `standardErrorCodeForHttpStatus(500)` 的取值,不是第三套措辞。 真客户端错误一个未动:改动前先给这一支加桩跑完 rest 全套(48 文件 / 719 用例),落到这里的只有 6 个错误 —— 本单的 outage、两个 502 的 ECONNREFUSED、 三个 TypeError,没有一个是客户端错误;历史上唯一骑这条兜底的客户端错误家族 (driver-sql 的 filter 拒收)已由 #4436 在生产者侧迁走。 - 新增 `rest-unclassified-fault-status.test.ts`:兜底落点、消毒、日志留痕、 以及「真 4xx 全部从各自分支拿到原状态」的边界钉。 - `rest-endpoint-surfaces-served-only.test.ts` 的 outage 用例从 `>=400` 升格为 5xx(#5487 的注释写明了在等本单)。 - `rest.test.ts` / `rest-4xx-message-truncation.test.ts` 里两条只写 `not.toBe(502)` 的否定断言升级为钉住实际落点 —— 它们对旧的 400+原文泄漏 同样成立,分不出两者。 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 43ca399 commit 91ec1ea

10 files changed

Lines changed: 436 additions & 19 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): an unclassified route error answers a sanitised 500, not a 400 (#5489)
6+
7+
**升级须知 — 状态码行为变化。** `@objectstack/rest` 的错误映射 `mapDataError`
8+
在所有分类分支都不匹配时,原先的终局兜底是
9+
`{ status: 400, body: { error: <原始 message> } }`。这一支现在改为一个消毒过的
10+
服务端故障信封:
11+
12+
```
13+
500 {"error":"Internal server error","code":"INTERNAL_ERROR"}
14+
```
15+
16+
**为什么。** 400 的语义是「你请求错了」——SDK、fetch 封装、代理和重试策略都据此
17+
判定「不要重试,调用方得改点什么」。而真正落到这一支的错误恰恰相反:元数据存储
18+
读不到时 `matchEndpoint` 按契约抛错(它抛就是为了让 outage 不伪装成「没有声明
19+
任何 endpoint」,ADR-0110 D3),或者干脆是处理器自身的 `TypeError`。两者调用方都
20+
修不了,且都**应该**重试。实测:`GET /api/v1/meta/api` 对着一个抛
21+
`Error('metadata store unreachable')` 的存储,返回 HTTP 400。
22+
23+
同时,原始 message 是逐字下发的——而这偏偏是全文件里最没有证据表明可以下发的一
24+
条路径:走到这里的前提就是 `looksLikeInternalErrorLeak` 什么都没匹配上,而
25+
#5462 已经记过「关键词启发式沉默不等于安全」。实测到的一例:一个声明了
26+
`status: 502`、message 为 `connect ECONNREFUSED 10.0.0.5:5432 (internal pool)`
27+
的错误,经由数据路由直接调用 `mapDataError` 时,以 400 携带主机与端口下发。
28+
沿用 #5464 的纪律:原文进服务端日志,不进客户端(500 不在
29+
`isExpectedDataStatus` 内,`handleRouteError` 会打印完整错误对象)。
30+
31+
**真正的客户端错误一个都没有改变。** 改动前先做了测绘:给这一支加桩,跑完
32+
`@objectstack/rest` 全套(48 文件 / 719 用例),落到这一支的只有 6 个错误——本单
33+
的存储 outage、两个 502 的 ECONNREFUSED、三个 `TypeError`,没有一个是客户端
34+
错误。历史上唯一骑在这条兜底上的客户端错误家族(driver-sql 无法编译的 filter
35+
拒绝)已由 #4436**生产者侧**声明 `status: 400` + `INVALID_FILTER` 迁走。
36+
validation / permission / unknown object / unknown field / not-null 漂移 /
37+
unique 冲突 / 沙箱业务拒绝等全部仍由各自分支给出原本的 4xx。
38+
39+
**`INTERNAL_ERROR` 而非 `DATABASE_ERROR`** #5462`DATA_STORE_FAULT`
40+
(`500 DATABASE_ERROR`)用在证据**指名**了存储故障的地方(驱动的 missing-relation
41+
措辞、`looksLikeInternalErrorLeak` 命中);而这一支的定义性事实是「没有任何证据」,
42+
把处理器的 `TypeError` 报成 `DATABASE_ERROR` 会把运维指向一个其实健康的数据库。
43+
`INTERNAL_ERROR``standardErrorCodeForHttpStatus(500)` 的取值
44+
(`@objectstack/spec``HttpStatusErrorCodeMap`)——目录自己为「500 且无更具体
45+
code」定义的下限,不是第三套措辞;message 复用的也是
46+
`resolveErrorResponse` 声明式 5xx 分支已在用的 `INTERNAL_ERROR_MESSAGE`
47+
48+
**如果你的客户端把这条兜底当 400 处理过**:它现在是 5xx,可以重试;若你有生产者
49+
依赖「不声明 status 即可把 message 原文送达调用方」,请改为在抛出点声明
50+
`status``code`(契约优先),那是唯一仍会把措辞交给调用方的路径。

packages/plugins/driver-sql/src/sql-driver.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,12 @@ const SQLITE_TIME_EXPR_REFS = 8;
467467
* tail (attribution, issue numbers) may be cut. Keep the actionable part —
468468
* operator, field, path, what arrived, what the spec declares — at the FRONT.
469469
*
470+
* [#5489] The "without a status it reached the client verbatim" half is now
471+
* history: that terminal branch answers a sanitised 500 (`INTERNAL_ERROR`).
472+
* Declaring `status` + `code` at the throw site is therefore the ONLY way a
473+
* refusal's words reach the caller at all — which is the contract-first
474+
* arrangement #4436 wanted, no longer relying on a fallback that leaked.
475+
*
470476
* The `[sql-driver]` prefix these messages used to carry is GONE from the text:
471477
* it is driver-internal wording, and shipping it to clients is exactly what the
472478
* #3867 sanitiser exists to stop. The operator/field/vocabulary detail — the

packages/rest/src/rest-4xx-message-truncation.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,17 @@ describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)',
147147
Object.assign(new Error('connect ECONNREFUSED 10.0.0.5:5432 '.repeat(20)), { status: 502 }),
148148
);
149149
expect(r.status).not.toBe(502);
150+
// [#5489] `not.toBe(502)` was true of the OLD landing too, and that
151+
// landing was `400` with every byte of the ECONNREFUSED text — host and
152+
// port included — on the wire. The negative assertion could not tell
153+
// the two apart, so what it actually lands on is pinned here: this
154+
// declared 5xx now leaves `mapDataError` through the terminal
155+
// `UNCLASSIFIED_FAULT`, sanitised and in the server band. (The declared
156+
// 502 is still not preserved on this direct-call path — that is
157+
// `resolveErrorResponse`'s branch, and out of #5489's scope.)
158+
expect(r.status).toBe(500);
159+
expect(r.body.code).toBe('INTERNAL_ERROR');
160+
expect(String(r.body.error)).not.toContain('10.0.0.5');
150161
});
151162
});
152163

packages/rest/src/rest-5xx-message-sanitization.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
// -> 404 Object 'showcase_account' is not registered
4545
// 500 `Failed to delete customization overlay: connect ECONNREFUSED ...`
4646
// -> 400 with the driver text STILL verbatim (terminal fallback)
47+
// [#5489] that terminal fallback is now a sanitised 500, so this
48+
// third row's LEAK is closed at the source. The other two rows are
49+
// untouched — they are mis-classifications by the text heuristics,
50+
// not by the fallback — and the reason this fix stays in the branch
51+
// itself (keep the producer's declared status) is unchanged.
4752
//
4853
// So it re-labels a server fault as a client mistake, re-labels a capability
4954
// refusal as a missing object, and — for any 5xx whose wording matches no

packages/rest/src/rest-endpoint-surfaces-served-only.test.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,16 @@ describe('#5224 — GET /meta/api announces only what the matcher serves', () =>
265265
const { rest } = mountRest(ALL_ENUMERATED, outage);
266266

267267
const res = await getMetaApi(rest);
268-
// The pin is that the request FAILS rather than answering a set. The exact
269-
// status is not this change's to decide: an unrecognised error reaching
270-
// `handleRouteError` lands on `mapDataError`'s terminal fallback, which
271-
// this route measured at 400 — a pre-existing classification shared by
272-
// every error on the metadata routes, not a consequence of the narrowing.
273-
// Asserting 5xx here would pin someone else's bug as if it were fixed.
274-
expect(res.statusCode).toBeGreaterThanOrEqual(400);
268+
// [#5489] Promoted from `>= 400` to the 5xx band. #5487 deliberately left
269+
// it at `>= 400` because the terminal fallback in `mapDataError` measured
270+
// 400 here, and asserting 5xx would have pinned someone else's bug as if it
271+
// were fixed. #5489 fixed it: an outage the mapper cannot attribute to the
272+
// request is a server fault, which is what an SDK must read to decide that
273+
// retrying is the right move. The route's own pin — that it FAILS rather
274+
// than confidently answering "this deployment declares no endpoints" — is
275+
// unchanged and is the second assertion.
276+
expect(res.statusCode).toBeGreaterThanOrEqual(500);
277+
expect(res.body?.code).toBe('INTERNAL_ERROR');
275278
expect(res.body?.items ?? res.body).not.toEqual([SERVED]);
276279
}, 60_000);
277280
});

packages/rest/src/rest-expected-error-logging.test.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@
2525
// OPPOSITE overreach, a predicate widened to "any 4xx is expected", which
2626
// would silence the un-coded 400 that `mapDataError` degrades an
2727
// unrecognised error (a handler `TypeError`) to.
28+
//
29+
// [#5489] That last sentence describes the world before the unrecognised-error
30+
// fallback became a sanitised 500. The handler-bug case below now asserts 500;
31+
// its adversary is no longer a widened 4xx predicate but any future attempt to
32+
// add 500 to `isExpectedDataStatus`. The invariant it guards — a real handler
33+
// bug is never silent — is the same one, and is now carried by the status band
34+
// rather than by the absence of a `code`.
2835

2936
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3037
import { RestServer } from './rest-server';
@@ -168,20 +175,28 @@ describe('metadata routes — genuine faults keep the loud log (#4886)', () => {
168175
expect(res.statusCode).toBe(500);
169176
});
170177

171-
it('an UNRECOGNISED error (handler bug) stays loud even though it maps to 400', async () => {
172-
// This is the case a blanket "any 4xx is expected" predicate would
173-
// wrongly silence: `mapDataError` degrades anything it recognises
174-
// nothing about to an UN-CODED 400, and that is where a real handler
175-
// bug lands. Silencing it would be the mirror-image of #4886.
178+
it('an UNRECOGNISED error (handler bug) stays loud — and is a 500, not a 400 (#5489)', async () => {
179+
// The loudness is what #4886 pinned, and it is unchanged. What moved is
180+
// WHY it is structural: this case used to land on `mapDataError`'s
181+
// un-coded 400 fallback, so the guard read "loud even though it maps to
182+
// 400" and its adversary was a predicate widened to "any 4xx is
183+
// expected". #5489 made that fallback a sanitised 500
184+
// (`UNCLASSIFIED_FAULT`) because a handler bug is not the caller's
185+
// fault and an SDK must not read "do not retry" off it. 500 is outside
186+
// `isExpectedDataStatus` entirely, so the log line no longer depends on
187+
// the predicate staying narrow in the 4xx band.
176188
const bug = new TypeError('Cannot read properties of undefined (reading \'name\')');
177189
const { rest } = setup({ getMetaItem: vi.fn().mockRejectedValue(bug) });
178190

179191
const res = await callMetaItem(rest, { type: 'object', name: 'showcase_account' });
180192

181193
expect(unhandledLogs()).toHaveLength(1);
182194
expect(unhandledLogs()[0][1]).toBe(bug);
183-
expect(res.statusCode).toBe(400);
184-
expect(res.body?.code).toBeUndefined();
195+
expect(res.statusCode).toBe(500);
196+
expect(res.body?.code).toBe('INTERNAL_ERROR');
197+
// The bug's own words are the operator's, not the client's — and the
198+
// log line above is where they went.
199+
expect(JSON.stringify(res.body)).not.toContain('Cannot read properties');
185200
});
186201
});
187202

packages/rest/src/rest-server.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,59 @@ const DATA_STORE_FAULT = (): { status: number; body: Record<string, unknown> } =
436436
body: { error: 'Internal data error', code: 'DATABASE_ERROR' },
437437
});
438438

439+
/**
440+
* [#5489] The envelope for "nothing in this mapper recognised the error": a
441+
* sanitised 500 carrying the catalog's `INTERNAL_ERROR`.
442+
*
443+
* This is `mapDataError`'s TERMINAL branch, and until now it answered
444+
* `{ status: 400, error: <the raw message> }`. Both halves of that were wrong
445+
* in the same direction:
446+
*
447+
* - **400 says the CALLER is at fault**, and an SDK reads it as "do not
448+
* retry, fix the request". The errors that actually reach here are the ones
449+
* no branch above could attribute to the request at all — a metadata store
450+
* that cannot be read (`matchEndpoint` throws rather than answering an empty
451+
* set, precisely so an outage does not masquerade as a miss; ADR-0110 D3),
452+
* or a plain handler bug (`TypeError: x is not a function`). Both are server
453+
* faults that a caller cannot fix and a caller SHOULD retry. Measured on
454+
* `GET /api/v1/meta/api` with a store that throws
455+
* `Error('metadata store unreachable')`: HTTP 400 (#5224 / PR #5487 left the
456+
* assertion at `>= 400` rather than pin this as intended).
457+
* - **The raw message shipped verbatim**, which is the exact discipline
458+
* #5437/#5464 closed one branch up: a declared 5xx drops its prose because
459+
* length was never a proxy for leakage. An error that matched no heuristic
460+
* is the LEAST attributable text in the file — this branch is reached only
461+
* because `looksLikeInternalErrorLeak` said nothing, and #5462 already
462+
* recorded that a negative from a keyword heuristic is not evidence of
463+
* safety. The words still reach the operator: 500 is outside
464+
* `isExpectedDataStatus`, so `handleRouteError` prints `[REST] Unhandled
465+
* error` with the whole error, and `sendError`'s `logWithheldServerFault`
466+
* covers the routes that bypass it.
467+
*
468+
* `INTERNAL_ERROR` rather than {@link DATA_STORE_FAULT}'s `DATABASE_ERROR`, and
469+
* the distinction is deliberate: `DATA_STORE_FAULT` is emitted where the
470+
* evidence NAMES a store failure (a driver's missing-relation phrasing, a
471+
* `looksLikeInternalErrorLeak` hit), so it can honestly say "database". Here
472+
* the defining fact is that there is no evidence of anything — sending a
473+
* handler `TypeError` back as `DATABASE_ERROR` would point an operator at a
474+
* database that is fine. `INTERNAL_ERROR` is not a third vocabulary either: it
475+
* is what `standardErrorCodeForHttpStatus(500)` yields (`HttpStatusErrorCodeMap`
476+
* in `@objectstack/spec`) — the catalog's own floor for "500 with no more
477+
* specific code" — and the message is the same `INTERNAL_ERROR_MESSAGE` the
478+
* declared-5xx branch of {@link resolveErrorResponse} already emits.
479+
*
480+
* What did NOT move: every branch above this one. A client error is a 4xx here
481+
* because a producer DECLARED `status` in the 4xx band or because a branch
482+
* matched it by `code`/name/phrasing — validation, permission, unknown object,
483+
* unknown field, not-null drift, unique violation, the sandbox unwraps. This
484+
* branch is the one that had nothing to go on, and "no idea" is a server-side
485+
* answer, not a client-side one.
486+
*/
487+
const UNCLASSIFIED_FAULT = (): { status: number; body: Record<string, unknown> } => ({
488+
status: 500,
489+
body: { error: INTERNAL_ERROR_MESSAGE, code: 'INTERNAL_ERROR' },
490+
});
491+
439492
/**
440493
* [#5462] Does a driver's missing-relation message name the very object this
441494
* request asked for?
@@ -899,7 +952,7 @@ export function mapDataError(error: any, object?: string): { status: number; bod
899952
}
900953
return DATA_STORE_FAULT();
901954
}
902-
return { status: 400, body: { error: raw || 'Bad request' } };
955+
return UNCLASSIFIED_FAULT();
903956
}
904957

905958
/**
@@ -1086,10 +1139,17 @@ function isExpectedQueryRejection(body: Record<string, unknown> | undefined): bo
10861139
* - `isExpectedQueryRejection` — the client-caused 400 vocabulary
10871140
* - `VALIDATION_FAILED` — the per-field 400 envelope
10881141
*
1089-
* It is deliberately NOT "any 4xx". `mapDataError`'s final fallback degrades an
1090-
* error it recognised nothing about to an un-coded 400, and that bucket is
1091-
* where a genuine handler bug (a `TypeError`, say) lands — silencing it would
1092-
* be the mirror-image of the defect this fixes.
1142+
* It is deliberately NOT "any 4xx". [#5489] That used to be argued from
1143+
* `mapDataError`'s final fallback, which degraded an error it recognised
1144+
* nothing about to an UN-CODED 400 — the bucket a genuine handler bug (a
1145+
* `TypeError`, say) landed in, so a predicate widened to "any 4xx is expected"
1146+
* would have silenced it. That fallback is now {@link UNCLASSIFIED_FAULT}'s
1147+
* 500, which this predicate cannot treat as expected at all
1148+
* (`isExpectedDataStatus` names 502/503 and nothing else in the 5xx band), so
1149+
* the handler bug is loud STRUCTURALLY rather than by this sentence. The
1150+
* narrowness still matters for what remains in the un-coded 4xx band — the
1151+
* sandbox unwraps' business-rule 400s — and for the next author tempted to
1152+
* simplify the predicate down to a status range.
10931153
*
10941154
* [#4886] Every route catch now decides through this one function. Before, the
10951155
* metadata family logged unconditionally — the designer's `?state=draft` probe

0 commit comments

Comments
 (0)