fix(studio): poll background generation and handle gateway failures
This commit is contained in:
1 parent
bb40dffee8
commit
3539dff45c
4 files changed
+145
-9
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/design-studio",
|
"name": "@wrnexus/design-studio",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/** Read gateway failures without exposing an HTML error page as a JSON parse error. */
|
||||||
|
export async function generationResponse(response: Response): Promise<any> {
|
||||||
|
const text = await response.text();
|
||||||
|
let value: any;
|
||||||
|
try {
|
||||||
|
value = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw Error(
|
||||||
|
[502, 503, 504].includes(response.status)
|
||||||
|
? `The deployment gateway could not complete this request (HTTP ${response.status}). Try again shortly.`
|
||||||
|
: `The generation server returned an unreadable response (HTTP ${response.status}). Reload after deploying the latest server.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!response.ok)
|
||||||
|
throw Error(
|
||||||
|
typeof value?.error === "string"
|
||||||
|
? value.error
|
||||||
|
: `Generation request failed (HTTP ${response.status}).`,
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestGeneration(
|
||||||
|
url: URL,
|
||||||
|
input: unknown,
|
||||||
|
signal: AbortSignal,
|
||||||
|
transport: typeof fetch = fetch,
|
||||||
|
) {
|
||||||
|
let jobUrl: URL | undefined;
|
||||||
|
const deadline = AbortSignal.any([signal, AbortSignal.timeout(75_000)]);
|
||||||
|
try {
|
||||||
|
let result = await generationResponse(
|
||||||
|
await transport(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", prefer: "respond-async" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
signal: deadline,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!result.job) return result; // Compatible with local engines and older synchronous servers.
|
||||||
|
if (typeof result.job.id !== "string" || !/^[a-f0-9-]{36}$/.test(result.job.id))
|
||||||
|
throw Error("Invalid generation job response.");
|
||||||
|
jobUrl = new URL(url.href.replace(/\/$/, "") + "/jobs/" + result.job.id);
|
||||||
|
while (true) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const aborted = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(deadline.reason);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
deadline.removeEventListener("abort", aborted);
|
||||||
|
resolve();
|
||||||
|
}, 1000);
|
||||||
|
if (deadline.aborted) aborted();
|
||||||
|
else deadline.addEventListener("abort", aborted, { once: true });
|
||||||
|
});
|
||||||
|
result = await generationResponse(
|
||||||
|
await transport(jobUrl, { signal: deadline, cache: "no-store" }),
|
||||||
|
);
|
||||||
|
if (result.status === "complete") return result.result;
|
||||||
|
if (result.status === "failed" || result.status === "cancelled")
|
||||||
|
throw Error(result.error ?? "Generation did not complete.");
|
||||||
|
if (result.status !== "pending") throw Error("Invalid generation job status.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (jobUrl && deadline.aborted) {
|
||||||
|
void transport(jobUrl, { method: "DELETE", signal: AbortSignal.timeout(3000) }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { requestGeneration } from "./generation-transport.ts";
|
||||||
import {
|
import {
|
||||||
DEFAULT_DESIGN_BRIEF,
|
DEFAULT_DESIGN_BRIEF,
|
||||||
type DesignBrief,
|
type DesignBrief,
|
||||||
@@ -280,21 +281,18 @@ export function setupGuidance(
|
|||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
await hooks.review?.(undefined);
|
await hooks.review?.(undefined);
|
||||||
const response = await fetch(new URL("./api/generate", document.baseURI), {
|
const result = await requestGeneration(
|
||||||
method: "POST",
|
new URL("./api/generate", document.baseURI),
|
||||||
headers: { "content-type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
|
||||||
system,
|
system,
|
||||||
locks,
|
locks,
|
||||||
brief: requestedBrief,
|
brief: requestedBrief,
|
||||||
baseRevision,
|
baseRevision,
|
||||||
kind,
|
kind,
|
||||||
instruction: kind === "edit" ? value("edit-instruction").value : undefined,
|
instruction: kind === "edit" ? value("edit-instruction").value : undefined,
|
||||||
}),
|
},
|
||||||
signal,
|
signal,
|
||||||
});
|
);
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok) throw Error(result.error ?? "Generation failed.");
|
|
||||||
if (current !== epoch || signal.aborted) return;
|
if (current !== epoch || signal.aborted) return;
|
||||||
if (result.diagnostics.length) {
|
if (result.diagnostics.length) {
|
||||||
status =
|
status =
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { generationResponse, requestGeneration } from "../src/generation-transport.ts";
|
||||||
|
|
||||||
|
test("HTML gateway failures produce a clear diagnostic rather than a JSON syntax error", async () => {
|
||||||
|
for (const status of [502, 503, 504])
|
||||||
|
await expect(
|
||||||
|
generationResponse(new Response("<html>Gateway error</html>", { status })),
|
||||||
|
).rejects.toThrow(`HTTP ${status}`);
|
||||||
|
await expect(
|
||||||
|
generationResponse(new Response("<html>private proxy text</html>", { status: 504 })),
|
||||||
|
).rejects.not.toThrow("private proxy text");
|
||||||
|
});
|
||||||
|
test("generation uses short job polls and preserves legacy response compatibility", async () => {
|
||||||
|
const batch = { alternatives: [1, 2, 3], diagnostics: [] };
|
||||||
|
let calls = 0;
|
||||||
|
const transport = (async (_url: unknown, init?: RequestInit) => {
|
||||||
|
calls++;
|
||||||
|
if (calls === 1) {
|
||||||
|
expect(new Headers(init?.headers).get("prefer")).toBe("respond-async");
|
||||||
|
return Response.json(
|
||||||
|
{ job: { id: "11111111-1111-4111-8111-111111111111" } },
|
||||||
|
{ status: 202 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(init?.method).toBeUndefined();
|
||||||
|
return Response.json({ status: "complete", result: batch });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
expect(
|
||||||
|
await requestGeneration(
|
||||||
|
new URL("https://example.test/design-studio/api/generate"),
|
||||||
|
{},
|
||||||
|
new AbortController().signal,
|
||||||
|
transport,
|
||||||
|
),
|
||||||
|
).toEqual(batch);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
expect(
|
||||||
|
await requestGeneration(
|
||||||
|
new URL("https://example.test/api/generate"),
|
||||||
|
{},
|
||||||
|
new AbortController().signal,
|
||||||
|
(async () => Response.json(batch)) as unknown as typeof fetch,
|
||||||
|
),
|
||||||
|
).toEqual(batch);
|
||||||
|
});
|
||||||
|
test("cancelling a pending job sends cancellation without replacing a draft", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
let cancelled = false;
|
||||||
|
const transport = (async (_url: unknown, init?: RequestInit) => {
|
||||||
|
if (init?.method === "DELETE") {
|
||||||
|
cancelled = true;
|
||||||
|
return Response.json({ status: "cancelled" });
|
||||||
|
}
|
||||||
|
controller.abort();
|
||||||
|
return Response.json({ job: { id: "11111111-1111-4111-8111-111111111111" } }, { status: 202 });
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
await expect(
|
||||||
|
requestGeneration(
|
||||||
|
new URL("https://example.test/api/generate"),
|
||||||
|
{},
|
||||||
|
controller.signal,
|
||||||
|
transport,
|
||||||
|
),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user