Live Data Backbone: Cache Layers, Circuit Breakers, and SSE for Broadcast

Posted on Mar 2, 2026

Yesterday was the scaffold. Today the data actually flows.

The Braves Booth Intelligence dashboard is worthless without live data. And live data during a broadcast has exactly one rule: never show an error screen. No spinners. No 500s. No “service unavailable.” The broadcast doesn’t pause while your API recovers.

That constraint shaped every design decision today. Ten commits, two merged PRs, and one architecture principle: every data source gets a cache layer, a circuit breaker, and a graceful degradation path.

The Data Sources

Four feeds power the dashboard:

SourceWhat It ProvidesUpdate Frequency
MLB Stats APIRosters, schedules, standings, player statsPre-game + between innings
GUMBO FeedLive game state — pitch-by-pitch, runners, scoreEvery pitch (~20s)
Statcast (pybaseball)Exit velocity, launch angle, sprint speed, park factorsPre-game batch + on-demand
Weather + UmpireWind, temperature, ump strike zone tendenciesPre-game only

Each one fails differently. MLB Stats API rate-limits you. GUMBO feeds lag during high-traffic games. Pybaseball downloads sometimes timeout. Weather APIs go down during storms — exactly when you need them most.

HTTP Client + TTL Cache

Every external call goes through a centralized HTTP client with an in-memory TTL cache. The critical pattern:

async def get(self, url: str, ttl: int | None = None) -> dict:
    entry = self._cache.get(url)
    if entry and not entry.expired:
        return entry.data
    try:
        resp = await self._session.get(url)
        resp.raise_for_status()
        self._cache[url] = CacheEntry(data=resp.json(), ttl=ttl)
        return resp.json()
    except httpx.HTTPError:
        if entry:  # Stale data beats no data
            return entry.data
        raise

When a fresh fetch fails, stale data is returned instead of an error. During a broadcast, showing a stat from 30 seconds ago is infinitely better than showing nothing. TTLs vary: roster data caches for an hour, GUMBO game state for 15 seconds, weather for 30 minutes.

Circuit Breaker Pattern

The cache handles transient failures. Circuit breakers handle sustained outages. When an external API fails repeatedly, stop calling it. Don’t pile up timeouts.

class CircuitBreaker:
    def __init__(self, failure_threshold: int = 5, recovery_timeout: float = 60.0):
        self.state = CircuitState.CLOSED
        self.failure_count = 0

    async def call(self, func, *args, **kwargs):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
            else:
                raise CircuitOpenError(self.recovery_timeout)
        try:
            result = await func(*args, **kwargs)
            self.failure_count = 0
            self.state = CircuitState.CLOSED
            return result
        except Exception:
            self.failure_count += 1
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
            raise

Three states. CLOSED means healthy — calls pass through. After five consecutive failures, the circuit OPENS and calls are rejected immediately. After 60 seconds, HALF_OPEN lets one test call through. The Statcast endpoint wraps pybaseball with this pattern because pybaseball downloads large CSV files from Baseball Savant, and those downloads fail unpredictably.

GUMBO Feed Polling

MLB’s GUMBO feed is the heartbeat of live game data. JSON endpoint, updates every pitch with the full game state. The poller runs on a background task with adaptive intervals — 5 seconds between pitches, 30 seconds during commercial breaks.

The key design choice: the poller tracks _last_play_id and only publishes to the event bus when the at-bat index changes. No duplicate events. The frontend doesn’t re-render unless there’s new data to show.

SSE Event Bus

Server-Sent Events push data to the frontend. Not WebSockets. SSE is simpler, auto-reconnects, and the data flow is one-directional — exactly what a dashboard needs.

class EventBus:
    def __init__(self):
        self._subscribers: dict[str, list[asyncio.Queue]] = {}

    async def publish(self, channel: str, data: dict):
        for queue in self._subscribers.get(channel, []):
            await queue.put(data)

    async def subscribe(self, channel: str) -> AsyncGenerator:
        queue = asyncio.Queue()
        self._subscribers.setdefault(channel, []).append(queue)
        try:
            while True:
                data = await queue.get()
                yield f"event: {channel}\ndata: {json.dumps(data)}\n\n"
        finally:
            self._subscribers[channel].remove(queue)

The event bus is the glue. GUMBO poller publishes game state. Statcast publishes pitch metrics. The frontend opens one SSE connection per channel and renders updates as they arrive.

Pre-Game Prefetcher + Graceful Degradation

Caches are useless when cold. The prefetcher warms them: make prefetch GAME=748231. It fetches rosters, player stats, matchup history, park factors, weather, and umpire tendencies — all in parallel, all cached with appropriate TTLs. Run it 30 minutes before first pitch. By the time the mic goes hot, every API call hits cache.

Every endpoint follows a degradation contract. The matchup history endpoint tries three sources in order: local cache, MLB Stats API, then Statcast aggregation. If all three fail, it returns {"available": false, "reason": "data_unavailable"}. The frontend renders a dash instead of a number. No crash. No error modal. The frontend never handles HTTP errors — it only handles data presence.

Side Quest: cad-dxf-agent v0.4.0

Between data layer commits, I shipped cad-dxf-agent v0.4.0. Test coverage went from 89% to 95% across all modules. Docker ODA install is now optional with a file-size guard so the image doesn’t bloat when ODA isn’t needed. Added a web app quick reference card for end users.

Small release. Clean release. The kind you ship between innings.

What’s Next

The data layer is done. Every source has caching, circuit breaking, and degradation paths. Tomorrow: wiring these feeds into the frontend panels so the dashboard actually shows live numbers. Then testing under simulated game load — because the real test is whether it holds up when 300 pitches flow through in three hours.

First pitch is coming. The data backbone is ready.