fix: normalize uncharacterized read failures in the mock Studio gateway
`idempotent()` was fixed to wrap anything `work()` throws, with a documented rationale: this port's contract is `StudioGatewayError` only, so nothing else may cross it. Its `read()` sibling was left unwrapped, so an uncharacterized internal failure on any of the seven read operations escaped as a raw `Error` and reached UI code written to catch `StudioGatewayError`. `read()` now applies the same `failureOf` classification. It has no idempotency ledger, so only the problem half is used. `boundary()` stays outside the wrap so an aborted request still surfaces as `AbortError`, exactly as `idempotent()` arranges it -- asserted by the new test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e4f9f81a3f
commit
e2a0e695dd
@@ -104,7 +104,19 @@ export function createMockStudioGateway(supplied: Partial<MockStudioDependencies
|
||||
async function boundary(options?: RequestOptions) {
|
||||
options?.signal?.throwIfAborted(); await Promise.resolve(); options?.signal?.throwIfAborted();
|
||||
}
|
||||
async function read<T>(options: RequestOptions | undefined, work: () => T) { await boundary(options); return clone(work()); }
|
||||
async function read<T>(options: RequestOptions | undefined, work: () => T) {
|
||||
// `boundary` stays outside the wrap so an abort still surfaces as
|
||||
// `AbortError`, exactly as `idempotent()` arranges it.
|
||||
await boundary(options);
|
||||
try { return clone(work()); }
|
||||
catch (error) {
|
||||
// Same reason as `idempotent()`'s catch below: this port's contract is
|
||||
// `StudioGatewayError` only. A read has no idempotency ledger, so the
|
||||
// `replayable` half of the classification has nothing to record here --
|
||||
// only the problem is used.
|
||||
throw new StudioGatewayError(clone(failureOf(error).problem));
|
||||
}
|
||||
}
|
||||
async function idempotent<T>(operation: string, target: string, request: () => unknown, options: IdempotentOptions, work: () => T): Promise<T> {
|
||||
await boundary(options);
|
||||
if (typeof options.idempotencyKey !== "string" || cp(options.idempotencyKey) < 1 || cp(options.idempotencyKey) > 200) throw requestError([{ path: "/idempotencyKey", message: "Idempotency key must be 1-200 characters." }]);
|
||||
|
||||
@@ -639,3 +639,53 @@ test("getDocument's nextAction reflects the assembled WorkingCopyDetail, not a c
|
||||
assert.equal(edgeToken.currentValidation?.validatedVersion, 2);
|
||||
assert.equal(edgeToken.nextAction, "FIX_VALIDATION");
|
||||
});
|
||||
|
||||
// Final fix wave, item 6. `idempotent()` normalizes anything `work()` throws
|
||||
// into a `StudioGatewayError` because that is the port's whole contract --
|
||||
// but its `read()` sibling did not, so an uncharacterized internal failure on
|
||||
// any of the seven read operations escaped the port as a raw `Error` and
|
||||
// reached UI code written to catch `StudioGatewayError`. An aborted request
|
||||
// must still surface as `AbortError`: `boundary()` runs outside the wrap, the
|
||||
// same way `idempotent()` already arranges it.
|
||||
test("read() normalizes an uncharacterized internal failure, and still lets an abort through", async () => {
|
||||
let failing = false;
|
||||
const gateway = createMockStudioGateway({
|
||||
clock: { now: () => new Date(NOW) },
|
||||
idGenerator: ids(),
|
||||
dependencyRevision: {
|
||||
current: () => {
|
||||
if (failing) throw new Error("의존성 리비전을 읽지 못했습니다.");
|
||||
return "catalog-2026-08-14";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await gateway.createDocument(
|
||||
emptyCase({ title: "읽기 경계", slug: "read-boundary", summary: "요약" }),
|
||||
{ idempotencyKey: "read-boundary-create" },
|
||||
);
|
||||
|
||||
failing = true;
|
||||
for (const [label, call] of [
|
||||
["getDocument", () => gateway.getDocument(created.id)],
|
||||
["getDashboard", () => gateway.getDashboard()],
|
||||
["listDocuments", () => gateway.listDocuments({})],
|
||||
] as const) {
|
||||
await assert.rejects(call(), (error: unknown) => {
|
||||
assert.ok(
|
||||
isStudioGatewayError(error),
|
||||
`${label}: expected a StudioGatewayError, got ${String(error)}`,
|
||||
);
|
||||
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
||||
assert.equal(error.status, 500);
|
||||
assert.equal(error.retryable, true);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
await assert.rejects(gateway.getDocument(created.id, { signal: controller.signal }), {
|
||||
name: "AbortError",
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user