Skip to content

Commit b8b3208

Browse files
fix(objectql): a plugin driver's request-shaped read refusal answers 400 INVALID_QUERY with its message, not 503 retry-later (#27648)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi2FieswzDA2Ddcee2frV6cP)_ Fixes #27471 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么:** 插件 driver 的外部数据源上,`find` 抛出带请求型状态(`status`/`statusCode` 为 400、404、422)的错误时,调用方现在拿到 `400 INVALID_QUERY`,消息就是 driver 自己的原话;其余失败(5xx、连不上、没声明状态、401/403/407/429)照旧是 `503 ERR_DATASOURCE_UNAVAILABLE`、原文不外泄。指南里的 `refuse()` 现在带 `status: 400`,失败段落逐条写明调用方看到什么。 - **为什么改:** 之前读路径的任何抛错都变成 503「远端故障,稍后重试」,作者照指南写的「点名拒绝」被说成可以重试,而重试永远不会好,真正原因只在服务器日志里。 - **风险与回滚:** 低。只动外部插件 driver 兜底层的读路径一个 catch 分支,内置 driver 和受管数据源不受影响;没有新错误码(复用标准 `INVALID_QUERY`)。回滚即还原本 PR。 - **先看:** `packages/objectql/src/external-driver-floor.ts`(`readCapped` 的 catch 分支)、`packages/objectql/src/external-driver-errors.ts`(`ExternalReadRefusedError` 与状态集合)、`content/docs/data-modeling/virtual-objects.mdx`(`refuse()` 与失败段落)。 - **两分钟验证:** `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/external-driver-floor.test.ts`,预期 45/45 通过,其中 `[#27471]` 一组 14 条。 - **需要你做的:** 无,席位落地。 ## What changes - `packages/objectql/src/external-driver-errors.ts`: `EXTERNAL_READ_REFUSED_STATUSES` (400, 404, 422) and `ExternalReadRefusedError`, which carries the standard `INVALID_QUERY` code, `status`/`httpStatus` 400, the driver's message, and `details` (`datasource`, `object`, `operation: 'find'`, `remoteStatus`). It has the same shape as `ExternalWriteRefusedError`. No new ledger code: `INVALID_QUERY` is a `StandardErrorCode` member, and REST already treats it as an expected query rejection (no unhandled-error log line). - `packages/objectql/src/external-driver-floor.ts`: the floor's `find` catch reads the declared status the same way the write path does (`declaredStatus`: `status`, then `statusCode`). A status in the read set throws the 400 refusal, with the original error as `cause`. Everything else still goes through `remoteFailure`, so it stays 503, reason `remote`, with the text logged at `warn` and withheld from the caller. `findOne` and `count` read through the same `readCapped`, so they get the same answer. The refusal leaves the engine through the existing boundary (`redactPropagatedDriverFault` in the middleware seam), which cuts bound statement text the same way it does for any driver error. - `packages/objectql/src/index.ts`: exports `ExternalReadRefusedError` and `EXTERNAL_READ_REFUSED_STATUSES` next to the write-side exports. - `content/docs/data-modeling/virtual-objects.mdx`: the guide's `refuse()` now throws with `status: 400`, which is the status the engine reads. The "Fail when the remote fails" bullet now lists, per status, what the caller sees: a refused read answers 400 `INVALID_QUERY`, a refused write answers 422 `EXTERNAL_WRITE_REFUSED`, and everything else answers 503. The guide's own driver test also pins `status: 400`. - `.changeset/27471-external-read-refusal-400.md`: `@objectstack/objectql` patch. ## Tests `packages/objectql/src/external-driver-floor.test.ts` has a new `[#27471]` block with 14 cases, run through the real engine: - The guide-shaped `refuse()` gets `ExternalReadRefusedError` / `INVALID_QUERY` / 400, with the driver's sentence and `details`, on `find`, `findOne` and `count`. - 400, 404 and 422, spelled as `status` and as `statusCode`, each answer 400 with the driver's message and `remoteStatus`. - 401, 403, 407, 429, 500, 502, 503, 409 and 412 each stay `ERR_DATASOURCE_UNAVAILABLE`, `kind: 'remote'`, with the text withheld. 409 and 412 are write-only refusal statuses and stay 503 on a read. - A throw with no status stays 503 with the text withheld. Results at head 9773776: - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 src/external-driver-floor.test.ts`: 45/45 passed. - `pnpm --filter @objectstack/objectql test` (under the verify lock): Test Files 487/487, Tests 9163/9163, VERDICT command-exit 0. - `pnpm --filter @objectstack/objectql typecheck`: exit 0, test layer held in `test-typecheck-debt.json`. <details><summary>Ablation (one-shot, not kept)</summary> `node scripts/ablation-replace.mjs --file packages/objectql/src/external-driver-floor.ts --anchor "if (status !== undefined && EXTERNAL_READ_REFUSED_STATUSES.has(status)) {" --replacement "if (false && status !== undefined && EXTERNAL_READ_REFUSED_STATUSES.has(status)) {"` - The mutation landed: anchor 1 to 0, blob 1957bd3 to 993bc75ea06b. - Result: 4 failed, 41 passed. The 4 failures were the refuse() case and the 400, 404 and 422 cases. - The 503-band cases stayed green, because they pin behaviour that did not change. - Restored: blob equals HEAD (1957bd3), and `git diff HEAD` is empty. - The test imports `./engine` from source, so no dist rebuild was involved. </details> ## Acceptance notes - **The code is reused, not new.** The card left the choice of an existing 400 code to the dev. I picked `INVALID_QUERY` over `INVALID_FILTER` because the guide's `refuse()` also refuses an unmapped object and unsupported operations, not only filter terms. - **The triage wording differs from the ruling.** Triage 6491225200 says "a typed refusal keeps its code and message". This PR follows the card's ruled Task instead: the floor reads the declared status, and a driver's own `code` on a 400/404/422 throw is replaced by `INVALID_QUERY`. The driver's object is kept as `cause`. - **The guide's write stubs change answer too.** They call `notSupported`, which now carries `status: 400` through `refuse()`. So a refused write on that skeleton answers 422 `EXTERNAL_WRITE_REFUSED` with the driver's sentence, where it used to answer 503. The paragraph states this. ## Verification Gates were derived with `node scripts/pm/dispatch-gates.mjs --commands` (no paths) and reconciled with `--ran`; the verdict at head 9773776: "91 derived famil(ies) accounted for — 91 run, 0 NOT-MEASURED (a DERIVED zero)". All 91 exited 0. Four first exited 3 or timed out on prerequisites (a shallow clone missing the teardown fixture commit, `dist/` not yet built, and the 400 s per-gate timeout on `check:type-check-debt`); each was re-run after its prerequisite and exited 0. CI check-runs on this PR carry every family CI runs; they are cited in the os-dev report, not re-run locally. --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2fT4drj3TCsV9G9CPh)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fT4drj3TCsV9G9CPh --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e2f386 commit b8b3208

6 files changed

Lines changed: 285 additions & 21 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): a plugin driver's request-shaped read refusal answers 400 with its own code and message, not 503 "retry later"
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a datasource served by a plugin driver (a non-managed `schemaMode` on a driver that is not built in), a `find` the driver throws with a request-shaped status (`status` or `statusCode` 400, 404 or 422) now answers `400`: an operator, a combinator, a column or a resource the remote cannot run, which a changed query fixes and a retry never does. The code is the one the driver declared when it is a registered code (`INVALID_FILTER`, for example), otherwise `INVALID_QUERY`; `details.remoteCode` names what the driver declared. The driver's message reaches the caller when the read's filter is the caller's own. When the read carries a predicate the caller did not write (a sharing or row-level security scope), the message is withheld, the answer names the datasource and object and points at the server log, and the driver's text is logged at `warn`. Before, every `find` throw answered `503 ERR_DATASOURCE_UNAVAILABLE` ("It may clear; retry later.") with the driver's reason only in the server log. `find`, `findOne` and `count` all read this way.
11+
12+
Everything else keeps today's answer: a 5xx, an unreachable remote, a throw that declares no status, and 401, 403, 407 and 429 (a credential or rate-limit condition whose text may name the credential) stay `503 ERR_DATASOURCE_UNAVAILABLE`, the text withheld. The refusal is exported as `ExternalReadRefusedError`, with its statuses as `EXTERNAL_READ_REFUSED_STATUSES`. The virtual-objects guide's `refuse()` helper now sets `status: 400`, and its failure paragraph says what the caller sees for each status.

‎content/docs/data-modeling/virtual-objects.mdx‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ const RESOURCES: Record<string, { resource: string; columns: ReadonlySet<string>
9090

9191
const OPERATORS: Record<string, RemoteCondition['op']> = { $eq: 'eq', $ne: 'ne', $in: 'in' };
9292

93+
/** A refusal of this request, by name. `status: 400` is what the engine reads to tell it from an outage. */
9394
function refuse(what: string): never {
94-
throw new Error(`port_schedule driver: the remote cannot run ${what}`);
95+
throw Object.assign(new Error(`port_schedule driver: the remote cannot run ${what}`), { status: 400 });
9596
}
9697

9798
function resourceOf(object: string) {
@@ -191,15 +192,25 @@ trust a plugin driver to have applied the query: it re-applies it
191192
of an `$or` narrows the result. The skeleton above takes the stricter road and refuses
192193
instead, which is also safe.
193194
- **Fail when the remote fails.** Throw; ⛔ never return an empty array, which reads
194-
exactly like "no rows match". A read that throws (a refusal by name included) answers
195-
the caller `503 ERR_DATASOURCE_UNAVAILABLE` with `reason: 'remote'`. Its message says
196-
the remote could not answer and to retry; your error's own text goes to the server log
197-
at `warn` and never to the caller. A write the remote refuses for the request's own
198-
sake (400, 404, 409, 412 or 422) is answered `422 EXTERNAL_WRITE_REFUSED` carrying your
199-
error's message, so set `status` (or `statusCode`) on the error you throw. A write
200-
that fails any other way is `503`, as a read is. That includes 401, 403, 407 and 429:
201-
an expired credential or a rate limit is the operator's to fix, and its text may name
202-
the credential.
195+
exactly like "no rows match". What the caller sees depends on the `status` (or
196+
`statusCode`) on the error you throw:
197+
- A read refused for the request's own sake (400, 404 or 422), which is what
198+
`refuse()` above throws, answers `400`. The code is `INVALID_QUERY`, or the code you
199+
set on the error when it is a registered one (`INVALID_FILTER`, say). A retry of the
200+
same read never clears it. The caller reads your error's message, so they know which
201+
operator or column to drop. Write it for them: ⛔ no remote URL, host or credential
202+
in it. The message is withheld, and goes to the server log, when the read carries a
203+
predicate the caller did not write, such as a sharing or row-level security scope,
204+
because it may name that predicate's column or value. A remote `404` on a list
205+
endpoint often means a wrong base path or resource mapping in your driver, not a bad
206+
query.
207+
- A write refused for the request's own sake (400, 404, 409, 412 or 422) answers
208+
`422 EXTERNAL_WRITE_REFUSED` carrying your error's message.
209+
- Anything else answers `503 ERR_DATASOURCE_UNAVAILABLE` with `reason: 'remote'`: a
210+
5xx, an unreachable remote, a throw with no `status`, and also 401, 403, 407 and 429,
211+
because an expired credential or a rate limit is the operator's to fix and its text
212+
may name the credential. The message says the remote could not answer and to retry;
213+
your error's own text goes to the server log at `warn` and never to the caller.
203214
- **Send the remote only the columns it owns.** `update` receives the patch plus any
204215
field a hook stamped, plus `id`, `updated_at` and `updated_by`, which the engine adds on
205216
its own and no remote column declares.
@@ -561,6 +572,8 @@ describe('port_schedule driver — the shapes the engine sends, and what the rem
561572
const remote = recordingTransport();
562573
const driver = createPortScheduleDriver(remote);
563574
await expect(driver.find('berth_slot', { where: { $or: [{ archived: false }, { planner: 'u1' }] } } as never)).rejects.toThrow(/\$or/);
575+
// The status the engine reads: a refusal of this request answers 400, not "retry later".
576+
await expect(driver.find('berth_slot', { where: { $or: [{ archived: false }] } } as never)).rejects.toMatchObject({ status: 400 });
564577
await expect(driver.find('berth_slot', { where: { start_date: { $gte: '2026-11-01' } } } as never)).rejects.toThrow(/\$gte/);
565578
// The default sharing model asks for ownership columns no remote row carries.
566579
await expect(driver.findOne('berth_slot', { where: { $and: [{ id: 's_1' }, { created_by: 'u1' }] } } as never)).rejects.toThrow(/created_by/);

‎packages/objectql/src/external-driver-errors.ts‎

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* [#27277] The two refusals the engine's floor over a plugin driver raises
4+
* [#27277] The refusals the engine's floor over a plugin driver raises
55
* (`external-driver-floor.ts`, ADR-0015 Tier A, epic #27261 P1).
66
*
7-
* Both are 4xx: the caller can change the request and try again, and the
7+
* All are 4xx: the caller can change the request and try again, and the
88
* message is theirs to read. That is what keeps them apart from the third
99
* outcome of the same floor, a remote that failed or did not answer, which is
1010
* `ERR_DATASOURCE_UNAVAILABLE` / 503 (`DatasourceUnavailableError`, reason
@@ -13,12 +13,16 @@
1313
* Each declares its `status` on the thrown error (the convergent HTTP-exit
1414
* convention `driver-connect-errors.ts` and `concurrent-update-error.ts`
1515
* follow), so every door answers the same status and keeps the `code`.
16-
* ⛔ Neither is an ADR-0015 gate refusal: `EXTERNAL_WRITE_FORBIDDEN` (403) is
16+
* ⛔ None is an ADR-0015 gate refusal: `EXTERNAL_WRITE_FORBIDDEN` (403) is
1717
* the platform's own write switch being off, fixed by turning it on; these are
1818
* fixed by changing the payload or the filter. They are registered under this
19-
* package in `ERROR_CODE_LEDGER`, beside the gate codes' own owner.
19+
* package in `ERROR_CODE_LEDGER`, beside the gate codes' own owner; the read
20+
* refusal carries the driver's registered code or the standard `INVALID_QUERY`
21+
* and needs no row.
2022
*/
2123

24+
import { ErrorCode, StandardErrorCode } from '@objectstack/spec/api';
25+
2226
/** The `code` {@link ExternalWriteRefusedError} carries. */
2327
export const EXTERNAL_WRITE_REFUSED_CODE = 'EXTERNAL_WRITE_REFUSED' as const;
2428

@@ -50,6 +54,73 @@ export type ExternalWriteOperation =
5054
*/
5155
export const EXTERNAL_WRITE_REFUSED_STATUSES: ReadonlySet<number> = new Set([400, 404, 409, 412, 422]);
5256

57+
/**
58+
* [#27471] The statuses a plugin driver's `find` throw is read as a refusal of
59+
* THIS read with: the remote (or the driver, before it asked — the guide's
60+
* `refuse()`) judged the filter, the column or the resource, and a changed
61+
* request may pass. Answered {@link ExternalReadRefusedError}, with the driver's
62+
* message when the read's `where` is the caller's own. Every other status, and a throw that declares none, is a remote
63+
* failure (`ERR_DATASOURCE_UNAVAILABLE`, 503, text withheld), for the reasons
64+
* {@link EXTERNAL_WRITE_REFUSED_STATUSES} gives.
65+
*/
66+
export const EXTERNAL_READ_REFUSED_STATUSES: ReadonlySet<number> = new Set([400, 404, 422]);
67+
68+
/**
69+
* [#27471] A plugin driver refused a read with a request-shaped 4xx
70+
* ({@link EXTERNAL_READ_REFUSED_STATUSES}): an operator, a combinator or a
71+
* column the remote cannot run, a resource it does not map. 400: the caller's
72+
* query is what has to change, and a retry of the same read never clears it.
73+
* `find`, `findOne` and `count` all read through `find`, so `operation` is
74+
* always `find`.
75+
*
76+
* - **Code.** A driver that declared a registered ADR-0112 code (`ErrorCode`,
77+
* the membership test the HTTP door applies to a thrown `code`) keeps it, as
78+
* it would on a datasource the engine does not floor; any other spelling, or
79+
* none, answers the standard `INVALID_QUERY`. `details.remoteCode` carries
80+
* what the driver declared either way.
81+
* - **Message.** The driver's own sentence, when the floor passes it
82+
* (`remoteMessage` a string). `null` withholds it: the read's `where` was not
83+
* attested as the caller's own (`filter-subtree-provenance.ts`), so the
84+
* sentence may name a policy subtree's column or comparand. The answer is
85+
* then the same refusal with a fixed sentence pointing at the server log.
86+
*/
87+
export class ExternalReadRefusedError extends Error {
88+
readonly code: ErrorCode;
89+
readonly status = 400;
90+
readonly httpStatus = 400;
91+
readonly details: {
92+
datasource: string;
93+
object: string;
94+
operation: 'find';
95+
/** The status the driver declared on its throw. */
96+
remoteStatus: number;
97+
/** The `code` the driver declared on its throw, kept or not; absent when it declared none. */
98+
remoteCode?: string;
99+
};
100+
101+
constructor(
102+
datasource: string,
103+
object: string,
104+
remoteStatus: number,
105+
remoteMessage: string | null,
106+
options?: { cause?: unknown; remoteCode?: string },
107+
) {
108+
const head = `The plugin driver of datasource '${datasource}' refused this read on '${object}'`;
109+
const said = remoteMessage?.trim() || `it answered ${remoteStatus} with no message`;
110+
super(remoteMessage === null ? `${head}; its reason is in the server log.` : `${head}: ${said}`);
111+
this.name = 'ExternalReadRefusedError';
112+
if (options && 'cause' in options) (this as Error & { cause?: unknown }).cause = options.cause;
113+
const remoteCode = options?.remoteCode;
114+
this.code = remoteCode !== undefined && ErrorCode.safeParse(remoteCode).success
115+
? remoteCode as ErrorCode
116+
: StandardErrorCode.enum.INVALID_QUERY;
117+
this.details = {
118+
datasource, object, operation: 'find', remoteStatus,
119+
...(remoteCode !== undefined ? { remoteCode } : {}),
120+
};
121+
}
122+
}
123+
53124
/**
54125
* The remote system behind a plugin driver refused a write with a request-shaped
55126
* 4xx ({@link EXTERNAL_WRITE_REFUSED_STATUSES}) — its validation failed, the

‎packages/objectql/src/external-driver-floor.test.ts‎

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212

1313
import { describe, it, expect } from 'vitest';
1414
import type { IDataDriver } from '@objectstack/spec/contracts';
15+
import { markFilterSubtreeProvenance } from '@objectstack/spec/data';
1516
import { ObjectQL } from './engine';
16-
import { EXTERNAL_ROW_CAP, ExternalRowCapExceededError, ExternalWriteRefusedError } from './external-driver-errors.js';
17+
import {
18+
EXTERNAL_ROW_CAP, ExternalReadRefusedError, ExternalRowCapExceededError, ExternalWriteRefusedError,
19+
} from './external-driver-errors.js';
1720
import { DatasourceUnavailableError } from './driver-connect-errors.js';
1821
import { isExternalDriverFloor, needsExternalDriverFloor, unwrapExternalDriverFloor } from './external-driver-floor.js';
1922

@@ -233,6 +236,108 @@ describe('[#27277] reads: the engine applies the query', () => {
233236
});
234237
});
235238

239+
describe('[#27471] a read the driver refuses with a request-shaped 4xx is 400', () => {
240+
const refusingRead = (error: unknown) => makeEngine({ extra: { async find() { throw error; } } });
241+
/** The virtual-objects guide's `refuse()`: a by-name refusal carrying the status the floor reads. */
242+
const refuse = (what: string) =>
243+
Object.assign(new Error(`tree driver: the remote cannot run ${what}`), { status: 400 });
244+
/** A `where` a merge boundary vouched for as the caller's own. */
245+
const authored = <T>(where: T): T => markFilterSubtreeProvenance(where, 'author');
246+
247+
it('the guide\'s refuse() answers INVALID_QUERY / 400 naming what the remote cannot run, on every read', async () => {
248+
const { engine } = refusingRead(refuse('the operator $gte on \'weight\''));
249+
for (const read of [
250+
() => engine.find('tree_node', { where: authored({ weight: { $gte: 2 } }) }),
251+
() => engine.findOne('tree_node', { where: authored({ weight: { $gte: 2 } }) }),
252+
() => engine.count('tree_node', { where: authored({ weight: { $gte: 2 } }) }),
253+
]) {
254+
const err = await read().then(() => undefined, (e) => e);
255+
expect(err).toBeInstanceOf(ExternalReadRefusedError);
256+
expect(err.code).toBe('INVALID_QUERY');
257+
expect(err.status).toBe(400);
258+
expect(err.message).toContain("the remote cannot run the operator $gte on 'weight'");
259+
expect(err.details).toEqual({ datasource: 'tree', object: 'tree_node', operation: 'find', remoteStatus: 400 });
260+
}
261+
});
262+
263+
for (const status of [400, 404, 422]) {
264+
it(`${status} (as \`status\` or \`statusCode\`) is 400 carrying the driver's message`, async () => {
265+
for (const key of ['status', 'statusCode']) {
266+
const { engine } = refusingRead(Object.assign(new Error(`remote said ${status}`), { [key]: status }));
267+
const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e);
268+
expect(err).toBeInstanceOf(ExternalReadRefusedError);
269+
expect(err.code).toBe('INVALID_QUERY');
270+
expect(err.status).toBe(400);
271+
expect(err.message).toContain(`remote said ${status}`);
272+
expect(err.details.remoteStatus).toBe(status);
273+
}
274+
});
275+
}
276+
277+
it('a registered code the driver declared is kept (ADR-0112), and remoteCode names it', async () => {
278+
const { engine } = refusingRead(
279+
Object.assign(new Error('tree driver: $regex is not a filter'), { status: 400, code: 'INVALID_FILTER' }),
280+
);
281+
const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e);
282+
expect(err).toBeInstanceOf(ExternalReadRefusedError);
283+
expect(err.code).toBe('INVALID_FILTER');
284+
expect(err.status).toBe(400);
285+
expect(err.details.remoteCode).toBe('INVALID_FILTER');
286+
});
287+
288+
it('a code that is not registered answers INVALID_QUERY, and remoteCode still names it', async () => {
289+
const { engine } = refusingRead(
290+
Object.assign(new Error('tree driver: no such filter'), { status: 400, code: 'TREE_BAD_FILTER' }),
291+
);
292+
const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e);
293+
expect(err.code).toBe('INVALID_QUERY');
294+
expect(err.details.remoteCode).toBe('TREE_BAD_FILTER');
295+
});
296+
297+
it('a where no boundary vouched for is refused 400 with its reason withheld and logged', async () => {
298+
const warned: Array<Record<string, unknown> | undefined> = [];
299+
const { engine } = refusingRead(refuse("a filter on 'region' with comparand EMEA"));
300+
(engine as unknown as { logger: { warn: (m: string, meta?: Record<string, unknown>) => void } }).logger.warn =
301+
(_m, meta) => { warned.push(meta); };
302+
const err = await engine.find('tree_node', { where: { kind: 'leaf' } }).then(() => undefined, (e) => e);
303+
expect(err).toBeInstanceOf(ExternalReadRefusedError);
304+
expect(err.status).toBe(400);
305+
expect(err.message).not.toContain('EMEA');
306+
expect(err.message).toContain('server log');
307+
expect(warned.some((meta) => String(meta?.error).includes('EMEA'))).toBe(true);
308+
});
309+
310+
it('a policy arm under an author root withholds the reason (#7929 fail-closed)', async () => {
311+
const { engine } = refusingRead(refuse("a filter on 'region' with comparand EMEA"));
312+
const where = authored({
313+
$and: [authored({ kind: 'leaf' }), markFilterSubtreeProvenance({ region: 'EMEA' }, 'policy')],
314+
});
315+
const err = await engine.find('tree_node', { where }).then(() => undefined, (e) => e);
316+
expect(err).toBeInstanceOf(ExternalReadRefusedError);
317+
expect(err.status).toBe(400);
318+
expect(err.message).not.toContain('EMEA');
319+
expect(err.details.remoteStatus).toBe(400);
320+
});
321+
322+
for (const status of [401, 403, 407, 429, 500, 502, 503, 409, 412]) {
323+
it(`${status} stays ERR_DATASOURCE_UNAVAILABLE / 503, its text withheld`, async () => {
324+
const { engine } = refusingRead(Object.assign(new Error('token sk_live_123 rejected'), { status }));
325+
const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e);
326+
expect(err).toBeInstanceOf(DatasourceUnavailableError);
327+
expect(err.code).toBe('ERR_DATASOURCE_UNAVAILABLE');
328+
expect(err.kind).toBe('remote');
329+
expect(err.message).not.toContain('sk_live_123');
330+
});
331+
}
332+
333+
it('a throw that declares no status stays ERR_DATASOURCE_UNAVAILABLE / 503, its text withheld', async () => {
334+
const { engine } = refusingRead(new Error('tree driver: the remote cannot run the combinator $or'));
335+
const err = await engine.find('tree_node', {}).then(() => undefined, (e) => e);
336+
expect(err).toBeInstanceOf(DatasourceUnavailableError);
337+
expect(err.message).not.toContain('$or');
338+
});
339+
});
340+
236341
describe('[#27277] writes pass through; a remote refusal is 422', () => {
237342
it('a write the remote accepts is the remote\'s answer', async () => {
238343
const { engine } = makeEngine({

0 commit comments

Comments
 (0)