Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/commands/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,33 @@ describe("runDeploy", () => {
expect(console.log).toHaveBeenCalledWith("Deployment completed: dep-1");
});

it("includes capacity error when --wait times out with queued job error", async () => {
vi.useFakeTimers();

getDeployment.mockResolvedValue({
id: "dep-1",
org_id: "org-1",
project_id: "proj-1",
build_id: "build-1",
status: "queued",
mode: "drain",
bullmq_job_id: "job-1",
error:
"CLUSTER_NO_CAPACITY: deploy_admission cpu_deficit=255m memory_deficit=0",
created_at: "2026-01-01T00:00:00Z",
completed_at: null,
});

const promise = runDeploy({
wait: true,
pollIntervalMs: 1,
timeoutMs: 5_000,
});
const rejection = expect(promise).rejects.toThrow(/CLUSTER_NO_CAPACITY/);
await vi.advanceTimersByTimeAsync(10_000);
await rejection;
});

it("includes last queued status when --wait times out", async () => {
vi.useFakeTimers();

Expand Down
1 change: 1 addition & 0 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ async function pollDeployment(
progressId: job.build_id,
}),
getRetryAfterMs: (job) => job.retry_after_ms,
getLastDetail: (job) => job.error,
onPoll: (job) => logVerbose(`deployment ${jobId}: status=${job.status}`),
baseIntervalMs: options.pollIntervalMs,
timeoutMs: options.timeoutMs,
Expand Down
31 changes: 31 additions & 0 deletions src/lib/poll-backoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,37 @@ describe("pollWithBackoff", () => {
expect(pollCount).toBeLessThan(5);
});

it("includes last detail after status when timeout elapses", async () => {
vi.useFakeTimers();

const promise = pollWithBackoff({
poll: async () => ({
status: "queued",
build_id: "build-1",
error:
"CLUSTER_NO_CAPACITY: deploy_admission cpu_deficit=255m memory_deficit=0",
}),
isTerminal: () => false,
getProgress: (job) => ({
status: job.status,
progressId: job.build_id,
}),
getLastDetail: (job) => job.error,
baseIntervalMs: 1000,
timeoutMs: 5_000,
timeoutMessage: "timed out",
runtime: {
sleep: (ms) => vi.advanceTimersByTimeAsync(ms),
now: () => Date.now(),
random: () => 0.5,
},
});

await expect(promise).rejects.toThrow(
/timed out \(last status=queued; CLUSTER_NO_CAPACITY: deploy_admission cpu_deficit=255m memory_deficit=0\)/,
);
});

it("includes last status=none when timeout elapses before any poll", async () => {
await expect(
pollWithBackoff({
Expand Down
12 changes: 11 additions & 1 deletion src/lib/poll-backoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export async function pollWithBackoff<T>(options: {
isTerminal: (value: T) => boolean;
getProgress: (value: T) => PollProgressSnapshot;
getRetryAfterMs?: (value: T) => number | null | undefined;
/** Optional extra detail appended after last status on timeout (e.g. deploy job error). */
getLastDetail?: (value: T) => string | null | undefined;
onPoll?: (value: T) => void;
baseIntervalMs: number;
timeoutMs: number;
Expand All @@ -130,9 +132,11 @@ export async function pollWithBackoff<T>(options: {
let attemptIndex = 0;
let previousProgress: PollProgressSnapshot | null = null;
let lastProgress: PollProgressSnapshot | null = null;
let lastValue: T | null = null;

while (runtime.now() - started < options.timeoutMs) {
const value = await options.poll();
lastValue = value;
options.onPoll?.(value);
if (options.isTerminal(value)) {
return value;
Expand All @@ -156,8 +160,14 @@ export async function pollWithBackoff<T>(options: {
await runtime.sleep(delayMs);
}

const lastDetail =
lastValue != null ? options.getLastDetail?.(lastValue) : undefined;
const detailSuffix =
lastDetail != null && lastDetail.trim() !== ""
? `; ${lastDetail.trim()}`
: "";
const lastStatusSuffix = lastProgress
? ` (last status=${lastProgress.status})`
? ` (last status=${lastProgress.status}${detailSuffix})`
: ` (last status=none)`;
throw new Error(`${options.timeoutMessage}${lastStatusSuffix}`);
}
Loading