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:
- Backs up the current
pipeline.py. - Checks whether the patch is already present.
- Applies both stale-stream checks if necessary.
- Runs
python -m py_compileagainst the modified file. - Restores the backup if the syntax check fails.
- 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.