DEV Community

Cover image for Four words of Python killed every sentence after first
Dhruv
Dhruv

Posted on

Four words of Python killed every sentence after first

Summer Bug Smash: Smash Stories πŸ›πŸ›Ή

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

The bug report was: "it says hello, then it goes deaf."

My AI receptionist answers a real landline. Caller dials, agent greets them properly, caller says "hi, I'd like to check my booking" and then nothing. Forever ♾️. The call stays connected. No error, no exception, no warning. The WebSocket to our speech provider sits there wide open, healthy, receiving audio, and the agent has simply stopped existing.

Turn one worked. Turn two never happened. Every time.

The wrong week

I spent it on the network, because that is where the evidence pointed. A speech socket goes quiet, check the socket. I logged every frame in and out where frames were flowing. I checked for silent disconnects, proxy timeouts, Heroku's 55s idle timeout, mu-law framing, buffer starvation. I added heartbeats.

Then I added reconnect logic, which "fixed" it in the sense that the agent came back four seconds later having forgotten the conversation.

That reconnect was the tell and I ignored it. Something was closing a connection nobody had asked to close. I read it as "the network is flaky" because that was the story I had already decided to believe.

The four words

async def receive_audio_events(self):
    try:
        async for message in self.ws:
            yield parse(message)
    finally:
        self.is_connected = False
Enter fullscreen mode Exit fullscreen mode

Read it the way the author did. This generator drains the speech socket for the lifetime of the call. When it finishes, the call is over, so mark the bridge disconnected. Clean, defensive, obviously right.

Now read how the caller uses it:

async for event in bridge.receive_audio_events():
    if event.type == "EndOfTurn":
        break          # got the turn end, go run the model
Enter fullscreen mode Exit fullscreen mode

break on an async generator runs its finally.

Not when the socket dies. Not when the call ends. Every single turn β€” the moment the caller stopped talking and we broke out to go think β€” Python unwound that generator, ran the cleanup, and set is_connected = False on a socket in perfect health.

Turn two arrives. The code checks is_connected, sees False, and politely declines to listen. The socket is fine. The audio is arriving. The flag is a lie, and the flag is what we trusted.

Four words. One finally: clause that was correct for the loop I had in mind, and fatal for the loop that actually existed.

Why nothing caught it

This is the part I keep thinking about.

There was no exception to catch, because nothing failed. No error to log, because nothing errored. No alert to fire, because every health check we had asked a component whether it was up, and every component was up. The socket was open, the process was fine, memory was flat. The agent had quietly agreed with itself to stop.

The tests were green too. They tested one turn.

The fix, and the better fix

The fix is to move the cleanup to where the connection genuinely ends, rather than where a consumer got bored:

async def receive_audio_events(self):
    # No `finally` here. This generator is broken out of every turn, and
    # `break` runs `finally`. Connection lifetime is closed by close().
    # A consumer that stops iterating is not a closed connection.
    async for message in self.ws:
        yield parse(message)
Enter fullscreen mode Exit fullscreen mode

The better fix was structural, and it is the one I would actually recommend to anyone debugging a live provider integration: I stopped using a human as the regression suite.

For weeks the loop had been β€” someone calls the number, describes what they heard, I dig through logs and guess. That is a debugging cycle measured in hours per iteration with a person as the test harness, and it is why one finally clause survived a week.

What replaced it was unglamorous: a standalone script that opens the provider's WebSocket directly, sends the real prompt and the real tool definitions, and prints every single message that comes back.

async with websockets.connect(
    DEEPGRAM_URL,
    subprotocols=["token", DEEPGRAM_API_KEY],
) as ws:
    await ws.send(json.dumps(settings_msg))   # exactly what the handler sends
    async for message in ws:
        logger.info(message)                  # exactly what it says back
Enter fullscreen mode Exit fullscreen mode

logger.info(message) β€” no filtering, no parsing, no if/elif. That last line is the entire trick, and it is the line the production code was missing.

Because the moment I stopped interpreting the provider's messages and just printed them, the thing our handler had been silently discarding for a week was sitting there in the terminal:

{"type": "Error", "code": "UNPARSABLE_CLIENT_MESSAGE",
 "description": "unknown field `processors`, expected `thresholds`"}
Enter fullscreen mode Exit fullscreen mode

A hundred and fifty lines, most of it the payload. I should have written it in week one, and the reason I didn't is that building the harness always feels like the thing you do instead of fixing the bug.

What I took away

I stopped trusting flags.

is_connected was a variable claiming to know something about the world, and for a week it lied to me while every socket, every process and every health check was fine. A flag can only ever tell you what some earlier code believed.

Now I measure the thing itself. Did audio come back? How long did it take?

It took four words to break it and a week to find them.

Top comments (0)