fix: retry tui chat history during startup

This commit is contained in:
Shakker
2026-04-20 03:33:27 +01:00
committed by Shakker
parent 52cca21ea8
commit 3730d6d17a
4 changed files with 150 additions and 5 deletions

View File

@@ -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;

View File

@@ -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,
}),
);
}

View File

@@ -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);
});
});

View File

@@ -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) {