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) {
|
async function boundary(options?: RequestOptions) {
|
||||||
options?.signal?.throwIfAborted(); await Promise.resolve(); options?.signal?.throwIfAborted();
|
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> {
|
async function idempotent<T>(operation: string, target: string, request: () => unknown, options: IdempotentOptions, work: () => T): Promise<T> {
|
||||||
await boundary(options);
|
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." }]);
|
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.currentValidation?.validatedVersion, 2);
|
||||||
assert.equal(edgeToken.nextAction, "FIX_VALIDATION");
|
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