Skip to content

Commit 615725f

Browse files
committed
refactor(core): centralize result retries
1 parent de29c02 commit 615725f

4 files changed

Lines changed: 269 additions & 47 deletions

File tree

src/lib/core/retry.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
interface RetryUntilBaseOptions<T> {
5+
/** Return true when the current result completes the retry operation. */
6+
accept: (result: T, attempt: number) => boolean;
7+
/** Delays before each additional attempt. */
8+
retryDelaysMs: readonly number[];
9+
}
10+
11+
export type RetryUntilOptions<T> = RetryUntilBaseOptions<T> & {
12+
/** Sleep function used between attempts. */
13+
sleep: (ms: number) => void;
14+
};
15+
16+
export type RetryUntilAsyncOptions<T> = RetryUntilBaseOptions<T> & {
17+
/** Sleep function used between attempts. */
18+
sleep: (ms: number) => Promise<void>;
19+
};
20+
21+
/**
22+
* Retry a synchronous operation until its result is accepted or the delay
23+
* schedule is exhausted. Returns the final operation result.
24+
*/
25+
export function retryUntil<T>(operation: (attempt: number) => T, options: RetryUntilOptions<T>): T {
26+
let attempt = 1;
27+
let result = operation(attempt);
28+
if (options.accept(result, attempt)) return result;
29+
30+
for (const delayMs of options.retryDelaysMs) {
31+
options.sleep(delayMs);
32+
attempt += 1;
33+
result = operation(attempt);
34+
if (options.accept(result, attempt)) return result;
35+
}
36+
return result;
37+
}
38+
39+
/**
40+
* Retry an asynchronous operation until its result is accepted or the delay
41+
* schedule is exhausted. Returns the final operation result.
42+
*/
43+
export async function retryUntilAsync<T>(
44+
operation: (attempt: number) => T | Promise<T>,
45+
options: RetryUntilAsyncOptions<T>,
46+
): Promise<T> {
47+
let attempt = 1;
48+
let result = await operation(attempt);
49+
if (options.accept(result, attempt)) return result;
50+
51+
for (const delayMs of options.retryDelaysMs) {
52+
await options.sleep(delayMs);
53+
attempt += 1;
54+
result = await operation(attempt);
55+
if (options.accept(result, attempt)) return result;
56+
}
57+
return result;
58+
}

src/lib/shields/inference-convergence.ts

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
} from "../actions/sandbox/connect-inference-route-probe";
88
import { buildOpenshellCommand } from "../adapters/openshell/command-argv";
99

10+
import { retryUntil } from "../core/retry";
11+
1012
const DEFAULT_MAX_ATTEMPTS = 4;
1113
const DEFAULT_RETRY_DELAY_MS = 500;
1214
const INFERENCE_ROUTE_PROBE_TIMEOUT_MS = 10_000;
@@ -65,28 +67,34 @@ export function waitForHermesInferenceRouteConvergence(
6567
? Math.max(0, Math.trunc(configuredRetryDelayMs))
6668
: DEFAULT_RETRY_DELAY_MS;
6769
const buildCommand = options.buildOpenshellCommand ?? buildOpenshellCommand;
68-
const sleep = options.sleep ?? sleepMs;
69-
let httpStatus = 0;
70-
71-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
72-
const probe = options.run(
73-
buildCommand(buildSandboxInferenceRouteProbeArgs(sandboxName, { name: "hermes" })),
74-
{
75-
ignoreError: true,
76-
suppressOutput: true,
77-
timeout: INFERENCE_ROUTE_PROBE_TIMEOUT_MS,
78-
},
79-
);
80-
const parsed = parseSandboxInferenceRouteProbeResult({
81-
status: probe.status,
82-
output: String(probe.stdout ?? ""),
83-
stderr: String(probe.stderr ?? ""),
84-
});
85-
httpStatus = parsed.httpStatus;
86-
const usable = parsed.healthy && httpStatus >= 200 && httpStatus < 300;
87-
if (usable) return { ok: true, attempts: attempt, httpStatus };
88-
if (attempt < maxAttempts) sleep(retryDelayMs);
89-
}
70+
const retryDelaysMs = Array.from({ length: maxAttempts - 1 }, () => retryDelayMs);
9071

91-
return { ok: false, attempts: maxAttempts, httpStatus };
72+
return retryUntil(
73+
(attempt) => {
74+
const probe = options.run(
75+
buildCommand(buildSandboxInferenceRouteProbeArgs(sandboxName, { name: "hermes" })),
76+
{
77+
ignoreError: true,
78+
suppressOutput: true,
79+
timeout: INFERENCE_ROUTE_PROBE_TIMEOUT_MS,
80+
},
81+
);
82+
const parsed = parseSandboxInferenceRouteProbeResult({
83+
status: probe.status,
84+
output: String(probe.stdout ?? ""),
85+
stderr: String(probe.stderr ?? ""),
86+
});
87+
const httpStatus = parsed.httpStatus;
88+
return {
89+
ok: parsed.healthy && httpStatus >= 200 && httpStatus < 300,
90+
attempts: attempt,
91+
httpStatus,
92+
};
93+
},
94+
{
95+
accept: (result) => result.ok,
96+
retryDelaysMs,
97+
sleep: options.sleep ?? sleepMs,
98+
},
99+
);
92100
}

src/lib/verify-deployment.ts

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { parseVersionFromText } from "./adapters/openshell/client";
2121
import { compareChannelSets, type RuntimeChannelStatus } from "./channel-runtime-status";
2222
import type { DashboardDeliveryChain } from "./dashboard/contract";
2323
import { listMessagingChannelsWithoutCredentials } from "./messaging/channels";
24+
25+
import { retryUntilAsync } from "./core/retry";
2426
import {
2527
buildCustomOpenClawRuntimeFailureHints,
2628
classifyOpenClawRuntimeFailure,
@@ -198,14 +200,11 @@ async function verifyGatewayInSandbox(
198200
retryDelaysMs: readonly number[],
199201
sleep: (ms: number) => Promise<void>,
200202
): Promise<{ reachable: boolean; httpCode: number; detail: string }> {
201-
let last = probeGatewayInSandboxOnce(sandboxName, chain, deps);
202-
if (last.reachable) return last;
203-
for (const delayMs of retryDelaysMs) {
204-
await sleep(delayMs);
205-
last = probeGatewayInSandboxOnce(sandboxName, chain, deps);
206-
if (last.reachable) return last;
207-
}
208-
return last;
203+
return retryUntilAsync(() => probeGatewayInSandboxOnce(sandboxName, chain, deps), {
204+
accept: (result) => result.reachable,
205+
retryDelaysMs,
206+
sleep,
207+
});
209208
}
210209

211210
/**
@@ -253,14 +252,11 @@ async function verifyInferenceRoute(
253252
retryDelaysMs: readonly number[],
254253
sleep: (ms: number) => Promise<void>,
255254
): Promise<{ status: InferenceRouteStatus; detail: string }> {
256-
let last = probeInferenceRouteOnce(sandboxName, deps);
257-
if (last.status === "ok") return last;
258-
for (const delayMs of retryDelaysMs) {
259-
await sleep(delayMs);
260-
last = probeInferenceRouteOnce(sandboxName, deps);
261-
if (last.status === "ok") return last;
262-
}
263-
return last;
255+
return retryUntilAsync(() => probeInferenceRouteOnce(sandboxName, deps), {
256+
accept: (result) => result.status === "ok",
257+
retryDelaysMs,
258+
sleep,
259+
});
264260
}
265261

266262
/**
@@ -289,14 +285,11 @@ async function verifyDashboardFromHost(
289285
retryDelaysMs: readonly number[],
290286
sleep: (ms: number) => Promise<void>,
291287
): Promise<{ reachable: boolean; detail: string }> {
292-
let last = probeDashboardFromHostOnce(chain, deps);
293-
if (last.reachable) return last;
294-
for (const delayMs of retryDelaysMs) {
295-
await sleep(delayMs);
296-
last = probeDashboardFromHostOnce(chain, deps);
297-
if (last.reachable) return last;
298-
}
299-
return last;
288+
return retryUntilAsync(() => probeDashboardFromHostOnce(chain, deps), {
289+
accept: (result) => result.reachable,
290+
retryDelaysMs,
291+
sleep,
292+
});
300293
}
301294

302295
/**

test/wait.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import assert from "node:assert";
55
import { createServer, type AddressInfo } from "node:net";
66
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { retryUntil, retryUntilAsync } from "../src/lib/core/retry.js";
8+
79
import {
810
buildLoopbackProbeEnv,
911
sleepMs,
@@ -47,6 +49,167 @@ describe("wait utility", () => {
4749
assert.ok(duration < 50, `duration ${duration}ms > 50ms`);
4850
});
4951

52+
it("retryUntil returns the first accepted result without sleeping (#9218)", () => {
53+
const sleep = vi.fn();
54+
const operation = vi.fn(() => "ready");
55+
56+
const result = retryUntil(operation, {
57+
accept: (value) => value === "ready",
58+
retryDelaysMs: [10, 20],
59+
sleep,
60+
});
61+
62+
expect(result).toBe("ready");
63+
expect(operation).toHaveBeenCalledOnce();
64+
expect(operation).toHaveBeenCalledWith(1);
65+
expect(sleep).not.toHaveBeenCalled();
66+
});
67+
68+
it("retryUntil applies exact delays before a later accepted result (#9218)", () => {
69+
const sleep = vi.fn();
70+
71+
const result = retryUntil((attempt) => (attempt === 3 ? "ready" : "starting"), {
72+
accept: (value) => value === "ready",
73+
retryDelaysMs: [10, 20, 30],
74+
sleep,
75+
});
76+
77+
expect(result).toBe("ready");
78+
expect(sleep.mock.calls).toEqual([[10], [20]]);
79+
});
80+
81+
it("retryUntil returns the final unaccepted result after exhaustion (#9218)", () => {
82+
const sleep = vi.fn();
83+
84+
const result = retryUntil((attempt) => `failure-${attempt}`, {
85+
accept: () => false,
86+
retryDelaysMs: [10, 20],
87+
sleep,
88+
});
89+
90+
expect(result).toBe("failure-3");
91+
expect(sleep.mock.calls).toEqual([[10], [20]]);
92+
});
93+
94+
it("retryUntil runs once with an empty retry schedule (#9218)", () => {
95+
const operation = vi.fn(() => "failure");
96+
97+
const result = retryUntil(operation, {
98+
accept: () => false,
99+
retryDelaysMs: [],
100+
101+
sleep: vi.fn(),
102+
});
103+
104+
expect(result).toBe("failure");
105+
expect(operation).toHaveBeenCalledOnce();
106+
});
107+
108+
it("retryUntil propagates an operation error without sleeping (#9218)", () => {
109+
const sleep = vi.fn();
110+
const error = new Error("operation failed");
111+
112+
expect(() =>
113+
retryUntil(
114+
() => {
115+
throw error;
116+
},
117+
{ accept: () => false, retryDelaysMs: [10], sleep },
118+
),
119+
).toThrow(error);
120+
expect(sleep).not.toHaveBeenCalled();
121+
});
122+
123+
it("retryUntil propagates a sleep error without another attempt (#9218)", () => {
124+
const operation = vi.fn(() => "starting");
125+
const error = new Error("sleep failed");
126+
127+
expect(() =>
128+
retryUntil(operation, {
129+
accept: () => false,
130+
retryDelaysMs: [10],
131+
sleep: () => {
132+
throw error;
133+
},
134+
}),
135+
).toThrow(error);
136+
expect(operation).toHaveBeenCalledOnce();
137+
});
138+
139+
it("retryUntilAsync applies exact delays before a later accepted result (#9218)", async () => {
140+
const sleep = vi.fn(async () => {});
141+
142+
const result = await retryUntilAsync(
143+
async (attempt) => (attempt === 3 ? "ready" : "starting"),
144+
{
145+
accept: (value) => value === "ready",
146+
retryDelaysMs: [10, 20, 30],
147+
sleep,
148+
},
149+
);
150+
151+
expect(result).toBe("ready");
152+
expect(sleep.mock.calls).toEqual([[10], [20]]);
153+
});
154+
155+
it("retryUntilAsync runs once with an empty retry schedule (#9218)", async () => {
156+
const operation = vi.fn(async () => "failure");
157+
158+
const result = await retryUntilAsync(operation, {
159+
accept: () => false,
160+
retryDelaysMs: [],
161+
162+
sleep: vi.fn(async () => {}),
163+
});
164+
165+
expect(result).toBe("failure");
166+
expect(operation).toHaveBeenCalledOnce();
167+
});
168+
169+
it("retryUntilAsync returns the final unaccepted result after exhaustion (#9218)", async () => {
170+
const sleep = vi.fn(async () => {});
171+
172+
const result = await retryUntilAsync(async (attempt) => `failure-${attempt}`, {
173+
accept: () => false,
174+
retryDelaysMs: [10, 20],
175+
sleep,
176+
});
177+
178+
expect(result).toBe("failure-3");
179+
expect(sleep.mock.calls).toEqual([[10], [20]]);
180+
});
181+
182+
it("retryUntilAsync propagates an operation error without sleeping (#9218)", async () => {
183+
const sleep = vi.fn(async () => {});
184+
const error = new Error("operation failed");
185+
186+
await expect(
187+
retryUntilAsync(
188+
async () => {
189+
throw error;
190+
},
191+
{ accept: () => false, retryDelaysMs: [10], sleep },
192+
),
193+
).rejects.toBe(error);
194+
expect(sleep).not.toHaveBeenCalled();
195+
});
196+
197+
it("retryUntilAsync propagates a sleep error without another attempt (#9218)", async () => {
198+
const operation = vi.fn(async () => "starting");
199+
const error = new Error("sleep failed");
200+
201+
await expect(
202+
retryUntilAsync(operation, {
203+
accept: () => false,
204+
retryDelaysMs: [10],
205+
sleep: async () => {
206+
throw error;
207+
},
208+
}),
209+
).rejects.toBe(error);
210+
expect(operation).toHaveBeenCalledOnce();
211+
});
212+
50213
it("waitUntil returns immediately when the condition is already true", () => {
51214
const sleeps: number[] = [];
52215
let attempts = 0;

0 commit comments

Comments
 (0)