diff --git a/.changeset/fresh-tts-chunked-retries.md b/.changeset/fresh-tts-chunked-retries.md new file mode 100644 index 000000000..d947bc10c --- /dev/null +++ b/.changeset/fresh-tts-chunked-retries.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Restart chunked TTS retries with a fresh attempt queue so retried synthesis uses a fresh request ID and does not write to a closed failed-attempt queue. diff --git a/agents/src/tts/tts.ts b/agents/src/tts/tts.ts index 3528e9c5c..180b82b10 100644 --- a/agents/src/tts/tts.ts +++ b/agents/src/tts/tts.ts @@ -601,6 +601,7 @@ export abstract class ChunkedStream implements AsyncIterableIterator(); protected abortController = new AbortController(); @@ -625,7 +626,17 @@ export abstract class ChunkedStream implements AsyncIterableIterator this.mainTask().finally(() => this.queue.close())); + ThrowsPromise.resolve().then(() => this.mainTask().finally(() => this.#metricsQueue.close())); + } + + private drainAttemptQueue(attemptQueue: AsyncIterableQueue): Promise { + return ThrowsPromise.resolve().then(async () => { + for await (const audio of attemptQueue) { + if (!this.#metricsQueue.closed) { + this.#metricsQueue.put(audio); + } + } + }); } private _mainTaskImpl = async (span: Span) => { @@ -636,8 +647,12 @@ export abstract class ChunkedStream implements AsyncIterableIterator(); + this.queue = attemptQueue; + const drainAttemptQueue = this.drainAttemptQueue(attemptQueue); + try { - return await tracer.startActiveSpan( + const result = await tracer.startActiveSpan( async (attemptSpan) => { attemptSpan.setAttribute(traceTypes.ATTR_RETRY_COUNT, i); try { @@ -649,28 +664,34 @@ export abstract class ChunkedStream implements AsyncIterableIterator 0 && i < this._connOptions.maxRetry; - if (this._connOptions.maxRetry === 0 || !error.retryable) { + if (!shouldRetry) { this.emitError({ error, recoverable: false }); throw error; - } else if (i === this._connOptions.maxRetry) { - this.emitError({ error, recoverable: false }); - throw new APIConnectionError({ - message: `failed to generate TTS completion after ${this._connOptions.maxRetry + 1} attempts`, - options: { retryable: false }, - }); - } else { - // Don't emit error event for recoverable errors during retry loop - // to avoid ERR_UNHANDLED_ERROR or premature session termination - this.logger.warn( - { tts: this.#tts.label, attempt: i + 1, error }, - `failed to generate TTS completion, retrying in ${retryInterval}ms`, - ); } + const retryInterval = intervalForRetry(this._connOptions, i); + // Don't emit error event for recoverable errors during retry loop + // to avoid ERR_UNHANDLED_ERROR or premature session termination + this.logger.warn( + { tts: this.#tts.label, attempt: i + 1, error }, + `failed to generate TTS completion, retrying in ${retryInterval}ms`, + ); + if (retryInterval > 0) { await delay(retryInterval); } @@ -727,7 +748,7 @@ export abstract class ChunkedStream implements AsyncIterableIterator