How a New Field in Our Flag Service's Schema Silently Froze a Checkout Rollout at 10% for Six Hours
← Back
September 6, 2026Python9 min read

How a New Field in Our Flag Service's Schema Silently Froze a Checkout Rollout at 10% for Six Hours

Published September 6, 20269 min read

14:02 UTC. #eng-incidents: "Third report this hour of an item vanishing from someone's cart at checkout. Didn't we ship the fix for this at 08:00?" The flag dashboard said new-cart-serialization: 100%, updated six hours earlier. On paper, the bug had been dead since breakfast.


the setup

checkout-api runs 24 pods behind a load balancer with no session affinity. Cart state travels between steps as a signed token, not a server session, which is deliberate: any pod can handle any step of a checkout for the same reason any pod can handle any request. A known bug had been quietly costing us orders for weeks. The old cart serializer wrote each line item as a separate key, and under specific interleavings of "add item" and "apply discount" requests, the last item added could get silently dropped before payment. The fix was a rewrite to serialize the whole cart as one atomic JSON blob, gated behind a flag: new-cart-serialization, rolled out gradually through our internal flag service.

Every pod runs a background thread that polls the flag service every 30 seconds (plus a few seconds of jitter, so all 24 pods don't hit it at once) and caches the result in memory. The flag itself is re-evaluated fresh on every request rather than pinned once per checkout session, which is normally harmless because canary percentages change slowly. It stopped being harmless the moment the cached value on every pod stopped changing at all.


the scramble

First theory: the fix itself had an edge case. Carts with more than ten items, carts with two stacked discount codes, carts touched from two browser tabs. Nobody could reproduce it locally against the new serializer, and the reports kept coming in at a steady trickle, not the shape of a rare edge case.

Second theory: the frontend was serving a stale bundle from cache, still running the old add-to-cart logic client-side. A quick check of response headers ruled that out in under five minutes: cache-control: no-store on the JS bundle, and the deployed asset hash matched what had shipped that morning. The frontend was fine. Whatever was wrong lived somewhere between the flag check and the serializer, and nobody had looked there yet because the dashboard said that path had been at 100% since 08:00.


the hunt

Someone added a debug route that dumps a pod's in-memory flag cache and the timestamp of its last successful refresh, then fanned it out across the fleet.

fanning out to all 24 pods
$ for i in $(seq 1 24); do
    curl -s http://checkout-api-$i.internal:8080/internal/flag-state | jq -c .
  done

{"flag":"new-cart-serialization","value":"10%","lastRefreshedAt":"2026-09-06T08:00:03Z"}
{"flag":"new-cart-serialization","value":"10%","lastRefreshedAt":"2026-09-06T08:00:07Z"}
{"flag":"new-cart-serialization","value":"10%","lastRefreshedAt":"2026-09-06T08:00:11Z"}
... (21 more, all "10%", all lastRefreshedAt between 08:00:03 and 08:00:29)

Every pod was stuck at the old 10% canary value. Every pod's last successful refresh landed within the same 26-second window, which lined up exactly with the moment the rollout bump to 100% had gone out. Not a slow drift, not a partial rollout. Every background refresh thread in the fleet had done exactly one more successful cycle after the bump, then nothing, ever again.

Pod stdout logs from that window had the answer, buried in normal INFO-level noise because nothing paged on it:

checkout-api-07 stdout, 08:00:33 UTC
INFO  flag_refresher: fetched config, 412 bytes
Exception in thread FlagRefresher:
Traceback (most recent call last):
  File "flag_client/refresher.py", line 41, in run
    config = FlagConfig(**payload)
TypeError: __init__() got an unexpected keyword argument 'rolloutStrategy'

Days earlier, the flag service's platform team had shipped support for a future feature, per-attribute sticky bucketing, and added a rolloutStrategy field to the config schema. No existing flag actually used it yet, so no client had ever seen it in a real payload. The 100% bump on new-cart-serialization was the first rollout change to set that field, because sticky bucketing was the whole reason this flag was finally going to 100% instead of sitting at a partial canary forever. The moment that payload reached a pod, its refresh thread tried to construct a dataclass from it.

flag_client/refresher.py (before)
@dataclass
class FlagConfig:
    name: str
    value: str
    updatedAt: str

class FlagRefresher(threading.Thread):
    def run(self):
        while True:
            try:
                payload = fetch_flag_config()  # network call, can raise
            except RequestException:
                log.debug("flag fetch failed, keeping cached value")
                time.sleep(REFRESH_INTERVAL)
                continue

            config = FlagConfig(**payload)   # not inside the try block
            _cache[config.name] = config
            time.sleep(REFRESH_INTERVAL)

The try/except only wrapped the network call. Parsing the response into FlagConfig sat outside it, so an unrecognized keyword argument raised straight out of run(). In Python, an uncaught exception in a thread doesn't crash the process, it just ends that one thread. No supervisor was restarting it. The pod's health check endpoint never touched the flag cache, so the load balancer kept treating it as perfectly healthy. Every pod died the same way within the same half-minute window, then sat there, healthy and serving traffic, permanently reporting a flag value from before the bump.


the find

Two failures had to line up. The client's config parser used strict construction with no tolerance for fields it didn't recognize, so a forward-compatible schema change on the server became a breaking change on every client still running the older parser. And the refresh loop's error handling covered the failure mode someone had thought about, a flaky network call, but not the one that actually happened, a payload the code didn't know how to read. Once the thread died, there was no signal anywhere that it had. Not a metric, not a log line above INFO, not a health check. The flag service's own dashboard had no way to know either: it recorded what it had sent, not what any client had confirmed applying. "100% rolled out" was a description of the control plane's intent, not a fact about the fleet.


the fix

First, the parser stopped rejecting fields it doesn't understand. A schema should be able to grow without breaking clients that haven't caught up yet.

flag_client/refresher.py (after)
@dataclass
class FlagConfig:
    name: str
    value: str
    updatedAt: str

    @classmethod
    def from_payload(cls, payload: dict) -> "FlagConfig":
        known = {f.name for f in fields(cls)}
        return cls(**{k: v for k, v in payload.items() if k in known})

Second, the entire refresh cycle, fetch and parse together, sits inside one try/except. A bad payload now logs an error and gets retried next interval instead of ending the thread.

flag_client/refresher.py (after)
class FlagRefresher(threading.Thread):
    def run(self):
        while True:
            try:
                payload = fetch_flag_config()
                config = FlagConfig.from_payload(payload)
                _cache[config.name] = config
                _last_success[config.name] = time.time()
            except Exception:
                log.exception("flag refresh cycle failed, keeping cached value")
            time.sleep(REFRESH_INTERVAL)

Third, staleness became observable instead of invisible. Every pod now exports how long it's been since each flag last refreshed successfully, and an alert fires if any pod exceeds five minutes, well before "silently stuck" can turn into "stuck for six hours."

flag_client/refresher.py
def refresh_age_seconds(flag_name: str) -> float:
    return time.time() - _last_success.get(flag_name, 0)

# scraped by Prometheus, alert fires above 300s
flag_refresh_age_seconds.labels(flag="new-cart-serialization").set(
    refresh_age_seconds("new-cart-serialization")
)

Fourth, the flag service's own dashboard changed what "rolled out" means. It now shows the oldest lastRefreshedAt reported back by any client that's called in during the last five minutes, alongside the value it thinks it sent. A rollout is confirmed once the fleet has actually said so, not once the control plane has finished sending it.


the aftermath

6h 2m Rollout silently stuck at 10% while the dashboard read 100%
24/24 Pods with a dead refresh thread, all within a 26-second window
61 Carts confirmed to have dropped a line item during the window
300s New alert threshold on flag refresh staleness

No page fired during those six hours because nothing was broken in a way any existing check understood. Pods were healthy, latency was normal, error rates were flat. The only signal was a support queue slowly filling with a bug everyone believed was already fixed, which is a much slower and much less trustworthy alarm than a metric crossing a threshold.

  • A schema is a contract with every client currently deployed, not just the one you're writing against today. Adding a field is only backward compatible if every parser out there agrees to ignore what it doesn't recognize.
  • A try/except around "the call that usually fails" isn't the same as one around "the whole operation." The failure that actually happens is rarely the one the error handling was written for.
  • An uncaught exception in a background thread is a silent, permanent failure by default. If nothing supervises the thread and nothing measures its output's freshness, it can die once and stay dead indefinitely without a single alert.
  • A control plane reporting "rolled out" is reporting what it sent, not what was received. Treat the two as different facts until something confirms they match.

The five-minute staleness threshold is deliberately tight against a 30-second refresh interval. Ten missed cycles in a row is already a strong signal something died, and the gap between "a thread died" and "someone found out" is the entire size of this incident.

Share this
← All Posts9 min read