Skip to content
Open
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
41 changes: 41 additions & 0 deletions src/tts/lucylab-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,47 @@ describe("LucylabClient", () => {
.rejects.toThrow(/timeout|exp-3/);
});

it("timeout error includes job id, last status, and attempt count", async () => {
nock("https://api.lucylab.io")
.post("/json-rpc").reply(200, { jsonrpc: "2.0", id: "1", result: { projectExportId: "exp-timeout", characterCount: 2, blockCount: 1 } });

nock("https://api.lucylab.io")
.post("/json-rpc").times(200).reply(200, { jsonrpc: "2.0", id: "x", result: { jobId: "exp-timeout", state: "pending" } });

const fastCfg = { ...cfg, pollTimeoutMs: 200, pollIntervalMs: 50 };
const client = new LucylabClient(fastCfg);
let errorMsg = "";
try {
await client.generate("hi", join(tmpDir, "out.mp3"));
} catch (e) {
errorMsg = (e as Error).message;
}
expect(errorMsg).toMatch(/exp-timeout/);
expect(errorMsg).toMatch(/pending/);
expect(errorMsg).toMatch(/Attempts=\d+/);
expect(errorMsg).toMatch(/timeout/i);
});

it("timeout error exposes HTTP status when all polls fail with 401", async () => {
nock("https://api.lucylab.io")
.post("/json-rpc").reply(200, { jsonrpc: "2.0", id: "1", result: { projectExportId: "exp-auth", characterCount: 2, blockCount: 1 } });

nock("https://api.lucylab.io")
.post("/json-rpc").times(200).reply(401, { error: "Unauthorized" });

const fastCfg = { ...cfg, pollTimeoutMs: 200, pollIntervalMs: 50 };
const client = new LucylabClient(fastCfg);
let errorMsg = "";
try {
await client.generate("hi", join(tmpDir, "out.mp3"));
} catch (e) {
errorMsg = (e as Error).message;
}
expect(errorMsg).toMatch(/exp-auth/);
expect(errorMsg).toMatch(/401/);
expect(errorMsg).toMatch(/no successful poll/);
});

it("throws if state=failed", async () => {
nock("https://api.lucylab.io")
.post("/json-rpc").reply(200, { jsonrpc: "2.0", id: "1", result: { projectExportId: "exp-4", characterCount: 2, blockCount: 1 } });
Expand Down
66 changes: 54 additions & 12 deletions src/tts/lucylab-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,22 +85,64 @@ export class LucylabClient implements TtsClient {

private async pollUntilDone(projectExportId: string): Promise<{ url: string; srtUrl?: string }> {
const start = Date.now();
let lastStatus: string = "unknown";
let lastError: string | undefined;
let lastHttpStatus: number | undefined;
let lastHttpSnippet: string | undefined;
let pollAttempts = 0;
let hadSuccessfulPoll = false;

while (Date.now() - start < this.cfg.pollTimeoutMs) {
const status = await this.rpc<ExportStatus>(
"getExportStatus",
{ projectExportId },
`poll-${Date.now()}`,
);
if (status.state === "completed") {
if (!status.url) throw new Error(`LucyLab returned state=completed without url for ${projectExportId}`);
return { url: status.url, srtUrl: status.srtUrl };
}
if (status.state === "failed") {
throw new Error(`LucyLab export ${projectExportId} failed: ${status.error ?? "unknown"}`);
try {
const status = await this.rpc<ExportStatus>(
"getExportStatus",
{ projectExportId },
`poll-${Date.now()}`,
);
pollAttempts++;
hadSuccessfulPoll = true;
lastStatus = status.state;
lastError = status.error;
lastHttpStatus = undefined;
lastHttpSnippet = undefined;

if (status.state === "completed") {
if (!status.url) throw new Error(`LucyLab returned state=completed without url for ${projectExportId}`);
return { url: status.url, srtUrl: status.srtUrl };
}
if (status.state === "failed") {
throw new Error(`LucyLab export ${projectExportId} failed: ${status.error ?? "unknown"}`);
}
} catch (e) {
// Re-throw non-timeout errors from completed/failed states (they won't be AxiosErrors)
const axiosErr = e as AxiosError;
if (!axiosErr.isAxiosError && !(e instanceof Error && (e.message.includes("state=completed") || e.message.includes("failed:")))) {
throw e;
}
if (e instanceof Error && (e.message.includes("state=completed") || e.message.includes("failed:"))) {
throw e;
}
if (axiosErr.isAxiosError) {
pollAttempts++;
lastHttpStatus = axiosErr.response?.status;
const rawData = axiosErr.response?.data;
lastHttpSnippet = rawData
? String(typeof rawData === "object" ? JSON.stringify(rawData) : rawData).slice(0, 120)
: axiosErr.message;
}
}
await sleep(this.cfg.pollIntervalMs);
}
throw new Error(`LucyLab export ${projectExportId} polling timeout after ${this.cfg.pollTimeoutMs}ms`);

const elapsed = Math.round((Date.now() - start) / 1000);
const pollInfo = hadSuccessfulPoll
? `last status="${lastStatus}"${lastError ? `, error="${lastError}"` : ""}, HTTP=ok`
: `no successful poll, last HTTP=${lastHttpStatus ?? "none"}, response="${lastHttpSnippet ?? "none"}"`;
throw new Error(
`LucyLab export ${projectExportId} polling timeout after ${elapsed}s (${this.cfg.pollTimeoutMs}ms limit). ` +
`Attempts=${pollAttempts}. ${pollInfo}. ` +
`Check: (a) API key valid? (b) job status on LucyLab dashboard? (c) network connectivity?`,
);
}

private async download(url: string, outPath: string): Promise<void> {
Expand Down