Home Assistant Assist TTS stale `ResultStream` / expired `tts_proxy` token

Home Assistant Assist TTS stale ResultStream / expired tts_proxy token

Summary

We encountered an intermittent Home Assistant Assist voice-response failure with an ESPHome voice assistant.

Speech recognition and intent processing completed normally, and Home Assistant returned a valid-looking TTS URL such as:

http://10.0.0.78:8123/api/tts_proxy/2tTCV_PABCV7d-epT3NDFw.flac

However, when the ESPHome device attempted to retrieve that URL, Home Assistant returned:

HTTP 404

Restarting/reinitializing the Assist pipeline temporarily resolved the problem, but it would return after the system had been running for a while.

The issue was ultimately traced to Home Assistant’s Assist pipeline retaining a reference to a TTS ResultStream after the corresponding token had already been removed from the TTS manager.


Symptoms

On the ESPHome voice assistant, the pipeline itself completed successfully:

Speech recognised as: "What time is it?"
Response: "5:33 PM"
Response URL: http://10.0.0.78:8123/api/tts_proxy/2tTCV_PABCV7d-epT3NDFw.flac

But playback immediately failed:

PATCHED AudioReader starting URL:
http://10.0.0.78:8123/api/tts_proxy/2tTCV_PABCV7d-epT3NDFw.flac

HTTP response status=404
Unexpected HTTP status code: 404

This was not a network-connectivity problem. The ESP32 successfully contacted Home Assistant; Home Assistant itself returned the 404.


Diagnosis

We temporarily instrumented Home Assistant’s TTS proxy handling to log creation and lookup of TTS tokens.

A failing token provided the key evidence:

TTS TOKEN CREATE token=2tTCV_PABCV7d-epT3NDFw.flac count=4

Later Home Assistant cleaned it up:

Cleaning up 2tTCV_PABCV7d-epT3NDFw.flac

But much later, Assist handed that same token to the voice satellite again.

When the ESPHome device requested it:

TTS TOKEN GET token=2tTCV_PABCV7d-epT3NDFw.flac count=0 keys=[]
TTS TOKEN GET RESULT token=2tTCV_PABCV7d-epT3NDFw.flac found=False

That explained the 404 exactly.

The token wasn’t being lost during the HTTP request. It had legitimately been cleaned up earlier, while the Assist pipeline still retained the stale ResultStream containing that token.


Relevant Home Assistant code

The Assist pipeline creates its TTS stream during preparation:

self.tts_stream = tts.async_create_stream(
    hass=self.hass,
    engine=engine,
    language=self.pipeline.tts_language,
    options=tts_options,
)

Later, during intent processing, the existing stream was used based on:

if self.tts_stream and self.tts_stream.supports_streaming_input:
    ...

And text_to_speech() assumed the stream was valid:

assert self.tts_stream is not None

The problem is that:

self.tts_stream is not None

does not guarantee its token still exists in Home Assistant’s active TTS stream registry.

The object can survive after its underlying token has been cleaned up.


Workaround/fix

We added a validity check before using the existing TTS stream.

The important test is:

tts.async_get_stream(self.hass, self.tts_stream.token)

If that returns None, the existing ResultStream is stale and a new one must be created.

Fix 1: Before intent-response streaming

Before deciding whether to use streaming TTS:

if (
    self.tts_stream is None
    or tts.async_get_stream(self.hass, self.tts_stream.token) is None
):
    _LOGGER.warning(
        "Refreshing stale TTS ResultStream before intent streaming: old_token=%s",
        self.tts_stream.token if self.tts_stream else None,
    )
    await self.prepare_text_to_speech()

if self.tts_stream and self.tts_stream.supports_streaming_input:
    tts_input_stream: asyncio.Queue[str | None] | None = asyncio.Queue()
else:
    tts_input_stream = None

Fix 2: Defensive check in text_to_speech()

We also added the same protection immediately before final TTS generation:

async def text_to_speech(
    self, tts_input: str, override_media_path: Path | None = None
) -> None:
    """Run text-to-speech portion of pipeline."""
    if (
        self.tts_stream is None
        or tts.async_get_stream(self.hass, self.tts_stream.token) is None
    ):
        _LOGGER.warning(
            "Refreshing stale TTS ResultStream before speech: old_token=%s",
            self.tts_stream.token if self.tts_stream else None,
        )
        await self.prepare_text_to_speech()

    assert self.tts_stream is not None

This ensures a stale stream is replaced before its expired token can be sent to the voice satellite.


Evidence that the fix works

After applying the patch, we deliberately allowed streams to age out.

Home Assistant subsequently detected stale streams:

WARNING [homeassistant.components.assist_pipeline.pipeline]
Refreshing stale TTS ResultStream before intent streaming:
old_token=gw7HGRcQsYEOR9iWeq4TkQ.flac

and immediately created a replacement:

TTS TOKEN CREATE token=JpThyjWctvHkCSA1HIvBDQ.flac count=1

The same behavior occurred repeatedly:

Refreshing stale TTS ResultStream before intent streaming:
old_token=_ZbBvAEXLF3uiqT7fdlkLg.flac

TTS TOKEN CREATE token=LCXLFiKXh6ppFZQpAM2c8w.flac count=1

and:

Refreshing stale TTS ResultStream before intent streaming:
old_token=bbEk_aYDW5mf66yg9Pj7ow.flac

TTS TOKEN CREATE token=rK8y2AzaxPFxswIXPuiqQg.flac count=1
TTS TOKEN GET token=rK8y2AzaxPFxswIXPuiqQg.flac ...
TTS TOKEN GET RESULT token=rK8y2AzaxPFxswIXPuiqQg.flac found=True

That last sequence is particularly useful: Home Assistant recognized the stale stream, generated a new token, and the HTTP request subsequently found that token.


Validation after a Home Assistant Core update

A Core update replaced the modified pipeline.py, as expected.

Inspection of the updated source showed the stale-stream protection was not present, so we reapplied the patch.

After restarting Home Assistant Core, both modifications were confirmed:

1126: "Refreshing stale TTS ResultStream before intent streaming: old_token=%s",
1468: "Refreshing stale TTS ResultStream before speech: old_token=%s",

A subsequent Assist request completed successfully:

Speech recognised as: "What time is it?"
Response: "9:15 PM"

ESPHome received the new TTS URL and Home Assistant returned:

HTTP response status=200

The audio pipeline then successfully processed it:

Reading FLAC file type
Decoded audio has 1 channels, 48000 Hz sample rate, and 16 bits per sample
Announcement PCM reached assistant_speaker
Announcement finished playing

Temporary persistence across Core updates

Because directly modifying:

/usr/src/homeassistant/homeassistant/components/assist_pipeline/pipeline.py

is not persistent across Home Assistant Core updates, we created a script under /config that:

  1. Backs up the current pipeline.py.
  2. Checks whether the patch is already present.
  3. Applies both stale-stream checks if necessary.
  4. Runs python -m py_compile against the modified file.
  5. Restores the backup if the syntax check fails.
  6. Refuses to blindly patch if the expected upstream source blocks have changed.

The script can therefore be rerun after a Core update, but the updated Home Assistant source should always be checked first in case the issue has been fixed upstream.


Likely root cause

The apparent lifecycle mismatch is:

Assist Pipeline
     │
     ├── creates ResultStream
     │        │
     │        └── token = ABC.flac
     │
     ├── retains ResultStream
     │
     │       time passes
     │
     ▼
TTS Manager cleanup
     │
     └── removes ABC.flac
              │
              ▼
Assist Pipeline still has ResultStream
              │
              └── exposes ABC.flac again
                         │
                         ▼
ESPHome GET /api/tts_proxy/ABC.flac
                         │
                         ▼
                       404

The workaround changes that final reuse to:

Existing ResultStream
        │
        ▼
Is token still registered?
     /       \
   yes        no
    │          │
 reuse      create new
 stream     ResultStream
                │
                ▼
           fresh token
                │
                ▼
             HTTP 200

Conclusion

The failure was not caused by ESPHome requesting the TTS URL incorrectly and was not fundamentally an HTTP/network issue.

Home Assistant Assist could retain a TTS ResultStream whose token had already been removed by TTS cleanup. Assist could later expose the expired token as a valid tts_proxy URL, resulting in an unavoidable 404 when the satellite requested it.

Checking that the stream’s token is still registered with the TTS manager and recreating the ResultStream when it is stale prevented the failure in our testing.

The ESPHome-side playback/recovery changes were useful for diagnosing and recovering from HTTP failures, but the stale-token check in Home Assistant addresses the failure at its source.

Feature requests should be made here: home-assistant · Discussions · GitHub

They are unlikely to be noticed on this forum.