mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
fix: retry tui chat history during startup
This commit is contained in:
@@ -278,6 +278,65 @@ describe("GatewayClient security checks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatewayClient request errors", () => {
|
||||
it("preserves retry metadata from gateway error responses", async () => {
|
||||
const onClose = vi.fn();
|
||||
const client = createClientWithIdentity("device-main", onClose);
|
||||
client.start();
|
||||
const ws = getLatestWs();
|
||||
ws.emitOpen();
|
||||
ws.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event: "connect.challenge",
|
||||
payload: { nonce: "nonce-1" },
|
||||
}),
|
||||
);
|
||||
const connectFrame = JSON.parse(
|
||||
ws.sent.find((frame) => frame.includes('"method":"connect"')) ?? "{}",
|
||||
) as { id?: string };
|
||||
ws.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: connectFrame.id,
|
||||
ok: true,
|
||||
payload: {
|
||||
type: "hello-ok",
|
||||
auth: { role: "operator", scopes: ["operator.admin"] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const requestPromise = client.request("chat.history", { sessionKey: "main" });
|
||||
const requestFrame = JSON.parse(ws.sent.at(-1) ?? "{}") as { id?: string };
|
||||
|
||||
ws.emitMessage(
|
||||
JSON.stringify({
|
||||
type: "res",
|
||||
id: requestFrame.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "chat.history unavailable during gateway startup",
|
||||
details: { method: "chat.history" },
|
||||
retryable: true,
|
||||
retryAfterMs: 250,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(requestPromise).rejects.toMatchObject({
|
||||
name: "GatewayClientRequestError",
|
||||
gatewayCode: "UNAVAILABLE",
|
||||
retryable: true,
|
||||
retryAfterMs: 250,
|
||||
details: { method: "chat.history" },
|
||||
});
|
||||
|
||||
client.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GatewayClient close handling", () => {
|
||||
beforeEach(() => {
|
||||
wsInstances.length = 0;
|
||||
|
||||
@@ -56,6 +56,8 @@ type GatewayClientErrorShape = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
details?: unknown;
|
||||
retryable?: boolean;
|
||||
retryAfterMs?: number;
|
||||
};
|
||||
|
||||
type SelectedConnectAuth = {
|
||||
@@ -79,15 +81,19 @@ type FingerprintCheckingClientOptions = Omit<ClientOptions, "checkServerIdentity
|
||||
checkServerIdentity?: (servername: string, cert: CertMeta) => Error | undefined;
|
||||
};
|
||||
|
||||
class GatewayClientRequestError extends Error {
|
||||
export class GatewayClientRequestError extends Error {
|
||||
readonly gatewayCode: string;
|
||||
readonly details?: unknown;
|
||||
readonly retryable: boolean;
|
||||
readonly retryAfterMs?: number;
|
||||
|
||||
constructor(error: GatewayClientErrorShape) {
|
||||
super(error.message ?? "gateway request failed");
|
||||
this.name = "GatewayClientRequestError";
|
||||
this.gatewayCode = error.code ?? "UNAVAILABLE";
|
||||
this.details = error.details;
|
||||
this.retryable = error.retryable === true;
|
||||
this.retryAfterMs = error.retryAfterMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,6 +785,8 @@ export class GatewayClient {
|
||||
code: parsed.error?.code,
|
||||
message: parsed.error?.message ?? "unknown error",
|
||||
details: parsed.error?.details,
|
||||
retryable: parsed.error?.retryable,
|
||||
retryAfterMs: parsed.error?.retryAfterMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("../gateway/net.js", async () => {
|
||||
});
|
||||
|
||||
const { GatewayChatClient, resolveGatewayConnection } = await import("./gateway-chat.js");
|
||||
const { GatewayClientRequestError } = await import("../gateway/client.js");
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -129,6 +130,7 @@ describe("resolveGatewayConnection", () => {
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("throws when url override is missing explicit credentials", async () => {
|
||||
@@ -443,4 +445,34 @@ describe("GatewayChatClient", () => {
|
||||
.deviceIdentity,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("retries startup-unavailable chat history until the gateway finishes booting", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const client = new GatewayChatClient({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
token: "test-token",
|
||||
allowInsecureLocalOperatorUi: true,
|
||||
});
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new GatewayClientRequestError({
|
||||
code: "UNAVAILABLE",
|
||||
message: "chat.history unavailable during gateway startup",
|
||||
details: { method: "chat.history" },
|
||||
retryable: true,
|
||||
retryAfterMs: 250,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ messages: [] });
|
||||
|
||||
(client as unknown as { client: { request: typeof request } }).client.request = request;
|
||||
|
||||
const historyPromise = client.loadHistory({ sessionKey: "main", limit: 200 });
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
await expect(historyPromise).resolves.toEqual({ messages: [] });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveExplicitGatewayAuth,
|
||||
} from "../gateway/call.js";
|
||||
import { GatewayClient } from "../gateway/client.js";
|
||||
import { GatewayClientRequestError } from "../gateway/client.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import {
|
||||
GATEWAY_CLIENT_CAPS,
|
||||
@@ -46,6 +47,10 @@ export type GatewayEvent = {
|
||||
seq?: number;
|
||||
};
|
||||
|
||||
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
|
||||
const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500;
|
||||
const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000;
|
||||
|
||||
type ResolvedGatewayConnection = {
|
||||
url: string;
|
||||
token?: string;
|
||||
@@ -63,6 +68,34 @@ function throwGatewayAuthResolutionError(reason: string): never {
|
||||
);
|
||||
}
|
||||
|
||||
function isRetryableStartupUnavailable(
|
||||
err: unknown,
|
||||
method: string,
|
||||
): err is GatewayClientRequestError {
|
||||
if (!(err instanceof GatewayClientRequestError)) {
|
||||
return false;
|
||||
}
|
||||
if (err.gatewayCode !== "UNAVAILABLE" || !err.retryable) {
|
||||
return false;
|
||||
}
|
||||
const details = err.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
return true;
|
||||
}
|
||||
const detailMethod = (details as { method?: unknown }).method;
|
||||
return typeof detailMethod !== "string" || detailMethod === method;
|
||||
}
|
||||
|
||||
function resolveStartupRetryDelayMs(err: GatewayClientRequestError): number {
|
||||
const retryAfterMs =
|
||||
typeof err.retryAfterMs === "number" ? err.retryAfterMs : STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS;
|
||||
return Math.min(Math.max(retryAfterMs, 100), STARTUP_CHAT_HISTORY_MAX_RETRY_MS);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export type GatewaySessionList = {
|
||||
ts: number;
|
||||
path: string;
|
||||
@@ -222,10 +255,23 @@ export class GatewayChatClient {
|
||||
}
|
||||
|
||||
async loadHistory(opts: { sessionKey: string; limit?: number }) {
|
||||
return await this.client.request("chat.history", {
|
||||
sessionKey: opts.sessionKey,
|
||||
limit: opts.limit,
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
return await this.client.request("chat.history", {
|
||||
sessionKey: opts.sessionKey,
|
||||
limit: opts.limit,
|
||||
});
|
||||
} catch (err) {
|
||||
const withinStartupRetryWindow =
|
||||
Date.now() - startedAt < STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS;
|
||||
if (withinStartupRetryWindow && isRetryableStartupUnavailable(err, "chat.history")) {
|
||||
await sleep(resolveStartupRetryDelayMs(err));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listSessions(opts?: SessionsListParams) {
|
||||
|
||||
Reference in New Issue
Block a user