diff --git a/src/tts/lucylab-client.test.ts b/src/tts/lucylab-client.test.ts index 8a6e8ed..ca7d324 100644 --- a/src/tts/lucylab-client.test.ts +++ b/src/tts/lucylab-client.test.ts @@ -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 } }); diff --git a/src/tts/lucylab-client.ts b/src/tts/lucylab-client.ts index 4b5ebce..75c7914 100644 --- a/src/tts/lucylab-client.ts +++ b/src/tts/lucylab-client.ts @@ -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( - "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( + "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 {