A watchdog for LLM streams that go quiet instead of failing

Local Fitness · No. 059

Shipped

This release came out of a review of three weeks of scheduled-job logs on my fitness agent, which generates a morning briefing on a timer. The job was failing roughly half its runs, and the only signal was the absence of a notification. The generation step opened a stream to the model, the stream stopped delivering messages, and nothing ever raised. One attempt, no timeout, no retry.

The fix is three small pieces that fit any streaming model call: a watchdog on the gap between messages, a bounded retry around the whole attempt, and an error message that names the real failure. That is what this guide builds.

Why a total timeout is the wrong tool here

The obvious instinct is to wrap the call in a deadline. Give it five minutes, fail if it runs long.

That works badly for generation. A long response legitimately takes minutes, and the length varies with the input, so any total budget you pick is either so tight it kills good runs or so loose that a dead stream still burns the whole window before you notice. My successful runs completed in 47 to 110 seconds. A dead one sat there for four minutes and produced nothing.

The useful signal is not total elapsed time. It is the gap between consecutive messages. A healthy stream is chatty: the Anthropic streaming API emits message_start, then a run of content_block_delta events, and may emit ping events at any point in the stream. Silence for two minutes in the middle of that is not the model thinking. It is a stream that has stopped.

So set your bound on the quiet period, and pick the number from your own observed latency. Mine is 120 seconds against runs that normally finish in under two minutes, which makes it unambiguous.

Step 1: wrap the stream in an idle watchdog

The whole trick is an async generator that sits between you and the stream. It pulls one message at a time under asyncio.wait_for, so the clock resets on every message rather than running against the whole response.

# watchdog.py
"""An idle watchdog for any async stream."""
from __future__ import annotations

import asyncio


class StreamIdleTimeout(RuntimeError):
    """The stream went silent past the idle timeout, so the run is dead."""


async def iter_with_idle_timeout(source, timeout_s: float):
    """Yield from `source`, raising StreamIdleTimeout when the gap between
    two consecutive messages exceeds `timeout_s`. A timeout of 0 or less
    disables the watchdog and passes messages straight through."""
    it = source.__aiter__()
    try:
        while True:
            try:
                if timeout_s > 0:
                    msg = await asyncio.wait_for(it.__anext__(), timeout=timeout_s)
                else:
                    msg = await it.__anext__()
            except StopAsyncIteration:
                return
            except TimeoutError:
                raise StreamIdleTimeout(
                    f"no stream message for {timeout_s:.0f}s, the stream is dead"
                ) from None
            yield msg
    finally:
        aclose = getattr(it, "aclose", None)
        if aclose is not None:
            try:
                await aclose()
            except Exception:
                pass

Three details in there earn their place.

Driving the iterator by hand with it.__anext__() is what makes per-message timing possible. A plain async for gives you no seam to put a clock in.

asyncio.wait_for cancels the awaitable it was given when the timeout fires, and the docs are explicit that it “waits for aw to be cancelled” before raising, so the total wait can exceed your timeout slightly. That is fine here. What matters is that the pending read does not keep running behind your back.

The finally block closes the underlying iterator. Python’s docs are blunt about skipping this: if an async generator exits early through an exception, its cleanup code can run “during the event loop shutdown when the async-generator garbage collection hook is called,” and the caller is told to explicitly call aclose() to detach it from the loop. You are always exiting this one early, because that is the entire point.

Step 2: retry the attempt, not the stream

A dead stream cannot be resumed by consuming it again. You need a fresh one, which means the retry has to sit around the code that opens the stream.

The other job of this layer is to name the failure. Empty output is a dead stream, and it is worth saying so out loud, because the next thing that happens to empty output is that it gets handed to a parser, and the parser reports a parse error. My old code raised “no JSON found in agent response,” which sent me to the wrong half of the system for weeks.

# runner.py
"""Bounded retry around a whole generation attempt."""
from __future__ import annotations

import asyncio
import logging

LOG = logging.getLogger(__name__)


class EmptyGeneration(RuntimeError):
    """The stream ended without producing any output."""


async def collect(stream, timeout_s: float) -> str:
    """Drain a watchdog-wrapped stream into one string."""
    from watchdog import iter_with_idle_timeout

    chunks = []
    async for msg in iter_with_idle_timeout(stream, timeout_s):
        chunks.append(msg)
    text = "".join(chunks)
    if not text.strip():
        raise EmptyGeneration(
            "generator produced no output (0 chars): the stream died before "
            "emitting anything. This is not a parsing problem."
        )
    return text


async def generate_with_retries(
    open_stream, *, timeout_s: float = 120.0, attempts: int = 3, delay_s: float = 20.0
) -> str:
    """Call `open_stream()` up to `attempts` times, watchdogged each time.

    `open_stream` is a zero-argument callable returning a fresh async
    iterable. It must be a callable, not a stream, because a dead stream
    cannot be re-consumed.
    """
    for attempt in range(1, attempts + 1):
        try:
            return await collect(open_stream(), timeout_s)
        except Exception as e:
            LOG.warning("attempt %d/%d failed: %s", attempt, attempts, e)
            if attempt == attempts:
                raise
            await asyncio.sleep(delay_s)
    raise AssertionError("unreachable")

Note the bound. Three attempts, then it gives up and raises. Google’s SRE book is direct about this: “Limit retries per request. Don’t retry a given request indefinitely.” An unbounded retry against a model API that is failing because it is overloaded is a way to make the overload worse, and the chapter walks through exactly how that amplification compounds. Three attempts twenty seconds apart still finishes well inside my job’s window.

For a real call, open_stream is the callable that starts your model request. With the Anthropic Python SDK that is the client.messages.stream(...) context manager; with an agent SDK it is whatever returns the message iterator. The watchdog does not care what is producing the messages.

Use it, then verify it

Point it at three fake streams: one healthy, one that hangs forever, and one that hangs on its first call and works on its second.

# demo.py
"""Three fake streams, one runner: healthy, hangs, then recovers."""
from __future__ import annotations

import asyncio
import logging

from runner import generate_with_retries

logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(message)s")


async def healthy():
    for word in ["Sleep ", "was ", "short. ", "Run ", "easy."]:
        await asyncio.sleep(0.05)
        yield word


async def hangs():
    yield "Sleep "
    await asyncio.sleep(3600)  # never delivers another message
    yield "never gets here"


def make_stream_factory():
    """First call hangs, second call is healthy. Mimics a transient death."""
    calls = {"n": 0}

    def factory():
        calls["n"] += 1
        return hangs() if calls["n"] == 1 else healthy()

    return factory


async def main():
    text = await generate_with_retries(healthy, timeout_s=1.0, attempts=3, delay_s=0.1)
    print(f"healthy stream  -> {text!r}")

    flaky = make_stream_factory()
    text = await generate_with_retries(flaky, timeout_s=1.0, attempts=3, delay_s=0.1)
    print(f"recovered       -> {text!r}")

    try:
        await generate_with_retries(hangs, timeout_s=1.0, attempts=2, delay_s=0.1)
    except Exception as e:
        print(f"gave up         -> {type(e).__name__}: {e}")


asyncio.run(main())

Put the three files in one directory and run it. On Python 3.14 that produced:

WARNING attempt 1/3 failed: no stream message for 1s, the stream is dead
WARNING attempt 1/2 failed: no stream message for 1s, the stream is dead
WARNING attempt 2/2 failed: no stream message for 1s, the stream is dead
healthy stream  -> 'Sleep was short. Run easy.'
recovered       -> 'Sleep was short. Run easy.'
gave up         -> StreamIdleTimeout: no stream message for 1s, the stream is dead

The warnings go to stderr and the results to stdout, so your terminal may interleave them differently. The whole run took under four seconds, which is the real check: without the watchdog, the first hanging stream would still be sitting there an hour later.

Gotchas

The exception you catch is not asyncio.TimeoutError on modern Python. As of 3.11, asyncio.wait_for “raises TimeoutError instead of asyncio.TimeoutError. The two names are now aliases so both happen to work, but if you write except asyncio.TimeoutError out of habit you are relying on an alias rather than the documented behavior. Catch the builtin.

Passing a stream instead of a factory quietly breaks the retry. This is the easy version of the mistake to make, because the signature still typechecks and the happy path still passes. Attempt two re-consumes the already-exhausted iterator, gets nothing, and you get an empty result reported as a generation failure. Symptom: retries that never succeed and always fail identically and instantly. The escape is in the signature above, open_stream is a callable, and your test for it is the flaky-factory case, not the healthy one.

Do not cache a failure. This release also added a disk cache in front of a separate model call that renders a coaching line, after the review counted nine identical calls on one day of repeated report renders. A cache in front of a flaky call is a trap if you write the result unconditionally: one dead stream pins its fallback text until something invalidates it. Cache on success only, and key on a hash of the fully-built prompt so any input change regenerates.

A silent failure needs a loud channel. The watchdog and the retry make the job fail correctly, which still left me with a job that failed quietly. Failure now fires a distinct desktop notification and exits non-zero, and the scheduler runs a second no-op-if-already-done pass hours later. If your only success signal is a thing appearing, then absence is your only failure signal, and absence is easy to miss for weeks. Ask me how I know.

Sources

Changelog

  • release: 0.23.0 — facet-review loop: brief resilience, plan-vs-actual charts, plan-tool + prompt fixes (dev → main) (#117) (6df9c87)