fix(sandbox): retain submitted build IDs on polling failure

This commit is contained in:
chenanran555
2026-09-10 11:21:05 +08:00
parent ceef514a50
commit 3e8df956fe
3 changed files with 227 additions and 6 deletions
@@ -355,12 +355,41 @@ async function emitTemplateMutationResult(options: {
),
options.endpoint,
).toString();
const build = await waitForTemplateBuild(
options.client,
options.settings,
buildEndpoint,
options.pollInterval ?? 5,
);
let build: TemplateBuildStatus;
try {
build = await waitForTemplateBuild(
options.client,
options.settings,
buildEndpoint,
options.pollInterval ?? 5,
);
} catch (error) {
const hint =
`Submitted build / 已提交的构建: templateID=${options.response.templateID}, buildID=${options.response.buildID}.\n` +
"Check this build with sandbox template build-status before submitting another build. / 请先通过 sandbox template build-status 查询本次构建,再决定是否重新提交。";
if (error instanceof BailianError) {
throw new BailianError(
error.message,
error.exitCode,
[error.hint, hint].filter(Boolean).join("\n"),
{
api: error.api,
rawResponse: error.rawResponse,
cause: error.cause,
},
);
}
// Leave transport errors intact so runtime retains timeout/network classification.
const recovery = {
templateID: options.response.templateID,
buildID: options.response.buildID,
hint,
};
process.stderr.write(
format === "json" ? `${JSON.stringify(recovery, null, 2)}\n\n` : `${hint}\n`,
);
throw error;
}
if (options.settings.quiet) emitBare(displayValue(options.response.templateID));
else emitResult({ template: options.response, build }, format);
}
@@ -231,3 +231,88 @@ describe("e2e: Sandbox custom gateway transport", () => {
},
);
});
describe("e2e: Sandbox submitted build recovery", () => {
test.each([
{ action: "create", output: "json", failure: "timeout", exitCode: 5 },
{ action: "update", output: "text", failure: "service", exitCode: 1 },
{ action: "create", output: "json", failure: "network", exitCode: 6 },
])(
"$action retains IDs after a $failure in $output output",
async ({ action, output, failure, exitCode }) => {
let submissionCount = 0;
const server = createServer((request, response) => {
request.resume();
response.setHeader("content-type", "application/json");
if (request.method !== "GET") {
submissionCount += 1;
response.end(
JSON.stringify({
templateID: "template-recovery",
buildID: "build-recovery",
buildStatus: "building",
}),
);
} else if (failure === "network") {
request.socket.destroy();
} else if (failure === "service") {
response.writeHead(503);
response.end(
JSON.stringify({
code: 100005,
message: "original service failure",
requestID: "request-recovery",
}),
);
} else {
response.end(JSON.stringify({ status: "building" }));
}
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
servers.push(server);
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected a local TCP server.");
const args =
action === "create"
? ["--name", "recovery", "--cpu-count", "1", "--memory-mb", "2048"]
: ["--template-id", "template-recovery", "--description", "updated"];
const result = await runCommandE2e(
ROUTES,
["sandbox", "template", action, ...args, "--timeout", "1", "--quiet", "--output", output],
makeConfigEnv({
api_key: "sk-recovery-test",
base_url: `http://127.0.0.1:${address.port}`,
}),
);
expect(result.exitCode, result.stderr).toBe(exitCode);
expect(result.stdout).toBe("");
expect(submissionCount).toBe(1);
expect(result.stderr).toContain("templateID=template-recovery, buildID=build-recovery");
expect(result.stderr).toContain("sandbox template build-status");
if (output === "json") {
const diagnostics = result.stderr
.trim()
.split(/\n\s*\n/)
.map((diagnostic) => JSON.parse(diagnostic));
expect(diagnostics.at(-1)).toMatchObject({ error: { code: exitCode } });
if (failure === "timeout") {
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].error.message).toBe("Template build polling timed out.");
} else {
expect(diagnostics[0]).toMatchObject({
templateID: "template-recovery",
buildID: "build-recovery",
});
expect(diagnostics.at(-1).error.message).toContain("Network request failed");
}
} else {
expect(result.stderr).toContain("original service failure");
expect(result.stderr).toContain("HTTP 503 (100005)");
expect(result.stderr).toContain("request-recovery");
}
},
);
});
+107
View File
@@ -445,6 +445,113 @@ describe("Sandbox template build polling", () => {
expect(requestJson).not.toHaveBeenCalled();
});
test.each([
{ name: "create", command: sandboxTemplateCreate },
{ name: "update", command: sandboxTemplateUpdate },
])("$name preserves submitted IDs and the original polling failure", async ({ command }) => {
const submission = { templateID: "template-test", buildID: "build-test" };
const serviceCause = new Error("original cause");
const serviceError = new BailianError("service message", ExitCode.GENERAL, "original hint", {
api: { httpStatus: 503, apiCode: "Unavailable", requestId: "request-test" },
rawResponse: "original response",
cause: serviceCause,
});
const scenarios = [
{
timeout: 0,
response: { status: "building" },
exitCode: ExitCode.TIMEOUT,
message: "Template build polling timed out.",
},
{
timeout: 30,
response: { status: "error", reason: { message: "image download failed" } },
exitCode: ExitCode.GENERAL,
message: "image download failed",
},
{
timeout: 30,
error: serviceError,
exitCode: ExitCode.GENERAL,
message: serviceError.message,
},
];
const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
for (const scenario of scenarios) {
const requestJson = vi.fn().mockResolvedValueOnce(submission);
if (scenario.error) requestJson.mockRejectedValue(scenario.error);
else requestJson.mockResolvedValue(scenario.response);
const operation = command.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, timeout: scenario.timeout },
flags: {
workspaceId: "ws-test",
templateId: "template-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: false,
},
client: { requestJson, url: createUrlResolver() },
} as never);
await expect(operation).rejects.toMatchObject({
message: scenario.message,
exitCode: scenario.exitCode,
hint: expect.stringContaining("templateID=template-test, buildID=build-test"),
});
if (scenario.error) {
await expect(operation).rejects.toMatchObject({
api: serviceError.api,
rawResponse: serviceError.rawResponse,
cause: serviceCause,
hint: expect.stringContaining("original hint"),
});
}
expect(requestJson.mock.calls.filter(([request]) => request.method !== "GET")).toHaveLength(
1,
);
}
expect(stdout).not.toHaveBeenCalled();
});
test.each(["json", "text"] as const)(
"transport failures retain their identity and emit build recovery in %s diagnostics",
async (output) => {
const failure = new TypeError("fetch failed", { cause: { code: "ECONNRESET" } });
const requestJson = vi
.fn()
.mockResolvedValueOnce({ templateID: "template-test", buildID: "build-test" })
.mockRejectedValue(failure);
let stderr = "";
vi.spyOn(process.stderr, "write").mockImplementation((chunk) => {
stderr += String(chunk);
return true;
});
await expect(
sandboxTemplateCreate.run({
identity: { binName: "bl" },
settings: { ...SETTINGS, output },
flags: {
workspaceId: "ws-test",
name: "python",
cpuCount: 1,
memoryMb: 2048,
async: false,
},
client: { requestJson, url: createUrlResolver() },
} as never),
).rejects.toBe(failure);
if (output === "json") {
expect(JSON.parse(stderr)).toMatchObject({
templateID: "template-test",
buildID: "build-test",
});
} else {
expect(stderr).toContain("templateID=template-test, buildID=build-test");
}
},
);
test("async template creation returns after the submit request", async () => {
let stdout = "";
vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {