Skip to content

Commit 3e8df95

Browse files
committed
fix(sandbox): retain submitted build IDs on polling failure
1 parent ceef514 commit 3e8df95

3 files changed

Lines changed: 227 additions & 6 deletions

File tree

packages/commands/src/commands/sandbox/template.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,12 +355,41 @@ async function emitTemplateMutationResult(options: {
355355
),
356356
options.endpoint,
357357
).toString();
358-
const build = await waitForTemplateBuild(
359-
options.client,
360-
options.settings,
361-
buildEndpoint,
362-
options.pollInterval ?? 5,
363-
);
358+
let build: TemplateBuildStatus;
359+
try {
360+
build = await waitForTemplateBuild(
361+
options.client,
362+
options.settings,
363+
buildEndpoint,
364+
options.pollInterval ?? 5,
365+
);
366+
} catch (error) {
367+
const hint =
368+
`Submitted build / 已提交的构建: templateID=${options.response.templateID}, buildID=${options.response.buildID}.\n` +
369+
"Check this build with sandbox template build-status before submitting another build. / 请先通过 sandbox template build-status 查询本次构建,再决定是否重新提交。";
370+
if (error instanceof BailianError) {
371+
throw new BailianError(
372+
error.message,
373+
error.exitCode,
374+
[error.hint, hint].filter(Boolean).join("\n"),
375+
{
376+
api: error.api,
377+
rawResponse: error.rawResponse,
378+
cause: error.cause,
379+
},
380+
);
381+
}
382+
// Leave transport errors intact so runtime retains timeout/network classification.
383+
const recovery = {
384+
templateID: options.response.templateID,
385+
buildID: options.response.buildID,
386+
hint,
387+
};
388+
process.stderr.write(
389+
format === "json" ? `${JSON.stringify(recovery, null, 2)}\n\n` : `${hint}\n`,
390+
);
391+
throw error;
392+
}
364393
if (options.settings.quiet) emitBare(displayValue(options.response.templateID));
365394
else emitResult({ template: options.response, build }, format);
366395
}

packages/commands/tests/e2e/sandbox-base-url.e2e.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,88 @@ describe("e2e: Sandbox custom gateway transport", () => {
231231
},
232232
);
233233
});
234+
235+
describe("e2e: Sandbox submitted build recovery", () => {
236+
test.each([
237+
{ action: "create", output: "json", failure: "timeout", exitCode: 5 },
238+
{ action: "update", output: "text", failure: "service", exitCode: 1 },
239+
{ action: "create", output: "json", failure: "network", exitCode: 6 },
240+
])(
241+
"$action retains IDs after a $failure in $output output",
242+
async ({ action, output, failure, exitCode }) => {
243+
let submissionCount = 0;
244+
const server = createServer((request, response) => {
245+
request.resume();
246+
response.setHeader("content-type", "application/json");
247+
if (request.method !== "GET") {
248+
submissionCount += 1;
249+
response.end(
250+
JSON.stringify({
251+
templateID: "template-recovery",
252+
buildID: "build-recovery",
253+
buildStatus: "building",
254+
}),
255+
);
256+
} else if (failure === "network") {
257+
request.socket.destroy();
258+
} else if (failure === "service") {
259+
response.writeHead(503);
260+
response.end(
261+
JSON.stringify({
262+
code: 100005,
263+
message: "original service failure",
264+
requestID: "request-recovery",
265+
}),
266+
);
267+
} else {
268+
response.end(JSON.stringify({ status: "building" }));
269+
}
270+
});
271+
await new Promise<void>((resolve, reject) => {
272+
server.once("error", reject);
273+
server.listen(0, "127.0.0.1", resolve);
274+
});
275+
servers.push(server);
276+
const address = server.address();
277+
if (!address || typeof address === "string") throw new Error("Expected a local TCP server.");
278+
const args =
279+
action === "create"
280+
? ["--name", "recovery", "--cpu-count", "1", "--memory-mb", "2048"]
281+
: ["--template-id", "template-recovery", "--description", "updated"];
282+
const result = await runCommandE2e(
283+
ROUTES,
284+
["sandbox", "template", action, ...args, "--timeout", "1", "--quiet", "--output", output],
285+
makeConfigEnv({
286+
api_key: "sk-recovery-test",
287+
base_url: `http://127.0.0.1:${address.port}`,
288+
}),
289+
);
290+
expect(result.exitCode, result.stderr).toBe(exitCode);
291+
expect(result.stdout).toBe("");
292+
expect(submissionCount).toBe(1);
293+
expect(result.stderr).toContain("templateID=template-recovery, buildID=build-recovery");
294+
expect(result.stderr).toContain("sandbox template build-status");
295+
if (output === "json") {
296+
const diagnostics = result.stderr
297+
.trim()
298+
.split(/\n\s*\n/)
299+
.map((diagnostic) => JSON.parse(diagnostic));
300+
expect(diagnostics.at(-1)).toMatchObject({ error: { code: exitCode } });
301+
if (failure === "timeout") {
302+
expect(diagnostics).toHaveLength(1);
303+
expect(diagnostics[0].error.message).toBe("Template build polling timed out.");
304+
} else {
305+
expect(diagnostics[0]).toMatchObject({
306+
templateID: "template-recovery",
307+
buildID: "build-recovery",
308+
});
309+
expect(diagnostics.at(-1).error.message).toContain("Network request failed");
310+
}
311+
} else {
312+
expect(result.stderr).toContain("original service failure");
313+
expect(result.stderr).toContain("HTTP 503 (100005)");
314+
expect(result.stderr).toContain("request-recovery");
315+
}
316+
},
317+
);
318+
});

packages/commands/tests/sandbox.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,113 @@ describe("Sandbox template build polling", () => {
445445
expect(requestJson).not.toHaveBeenCalled();
446446
});
447447

448+
test.each([
449+
{ name: "create", command: sandboxTemplateCreate },
450+
{ name: "update", command: sandboxTemplateUpdate },
451+
])("$name preserves submitted IDs and the original polling failure", async ({ command }) => {
452+
const submission = { templateID: "template-test", buildID: "build-test" };
453+
const serviceCause = new Error("original cause");
454+
const serviceError = new BailianError("service message", ExitCode.GENERAL, "original hint", {
455+
api: { httpStatus: 503, apiCode: "Unavailable", requestId: "request-test" },
456+
rawResponse: "original response",
457+
cause: serviceCause,
458+
});
459+
const scenarios = [
460+
{
461+
timeout: 0,
462+
response: { status: "building" },
463+
exitCode: ExitCode.TIMEOUT,
464+
message: "Template build polling timed out.",
465+
},
466+
{
467+
timeout: 30,
468+
response: { status: "error", reason: { message: "image download failed" } },
469+
exitCode: ExitCode.GENERAL,
470+
message: "image download failed",
471+
},
472+
{
473+
timeout: 30,
474+
error: serviceError,
475+
exitCode: ExitCode.GENERAL,
476+
message: serviceError.message,
477+
},
478+
];
479+
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
480+
for (const scenario of scenarios) {
481+
const requestJson = vi.fn().mockResolvedValueOnce(submission);
482+
if (scenario.error) requestJson.mockRejectedValue(scenario.error);
483+
else requestJson.mockResolvedValue(scenario.response);
484+
const operation = command.run({
485+
identity: { binName: "bl" },
486+
settings: { ...SETTINGS, timeout: scenario.timeout },
487+
flags: {
488+
workspaceId: "ws-test",
489+
templateId: "template-test",
490+
name: "python",
491+
cpuCount: 1,
492+
memoryMb: 2048,
493+
async: false,
494+
},
495+
client: { requestJson, url: createUrlResolver() },
496+
} as never);
497+
await expect(operation).rejects.toMatchObject({
498+
message: scenario.message,
499+
exitCode: scenario.exitCode,
500+
hint: expect.stringContaining("templateID=template-test, buildID=build-test"),
501+
});
502+
if (scenario.error) {
503+
await expect(operation).rejects.toMatchObject({
504+
api: serviceError.api,
505+
rawResponse: serviceError.rawResponse,
506+
cause: serviceCause,
507+
hint: expect.stringContaining("original hint"),
508+
});
509+
}
510+
expect(requestJson.mock.calls.filter(([request]) => request.method !== "GET")).toHaveLength(
511+
1,
512+
);
513+
}
514+
expect(stdout).not.toHaveBeenCalled();
515+
});
516+
517+
test.each(["json", "text"] as const)(
518+
"transport failures retain their identity and emit build recovery in %s diagnostics",
519+
async (output) => {
520+
const failure = new TypeError("fetch failed", { cause: { code: "ECONNRESET" } });
521+
const requestJson = vi
522+
.fn()
523+
.mockResolvedValueOnce({ templateID: "template-test", buildID: "build-test" })
524+
.mockRejectedValue(failure);
525+
let stderr = "";
526+
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
527+
stderr += String(chunk);
528+
return true;
529+
});
530+
await expect(
531+
sandboxTemplateCreate.run({
532+
identity: { binName: "bl" },
533+
settings: { ...SETTINGS, output },
534+
flags: {
535+
workspaceId: "ws-test",
536+
name: "python",
537+
cpuCount: 1,
538+
memoryMb: 2048,
539+
async: false,
540+
},
541+
client: { requestJson, url: createUrlResolver() },
542+
} as never),
543+
).rejects.toBe(failure);
544+
if (output === "json") {
545+
expect(JSON.parse(stderr)).toMatchObject({
546+
templateID: "template-test",
547+
buildID: "build-test",
548+
});
549+
} else {
550+
expect(stderr).toContain("templateID=template-test, buildID=build-test");
551+
}
552+
},
553+
);
554+
448555
test("async template creation returns after the submit request", async () => {
449556
let stdout = "";
450557
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {

0 commit comments

Comments
 (0)