The Fifth Request

The page said Watering now. The run it described had finished minutes earlier, and the reason the page had not noticed was that I was holding it.

I had built a small dashboard over an irrigation controller’s cloud API, so I could check the sprinklers from a phone instead of walking out to the wall unit. The commit that fixes what follows is timestamped fourteen minutes after the commit that shipped the dashboard. In those fourteen minutes the page started going stale: timestamps freezing, countdowns describing a world that had moved on. I did what anyone does with a stale page. I pulled to refresh.

Once a minute is the whole allowance

The API permits five requests per five minutes, per endpoint. I learned the exact shape of that by exceeding it.

My server polled the status endpoint once a minute. Sixty seconds felt conservative when I picked it. It is the sort of interval you choose to be considerate, comfortably slower than anything a vendor could call abuse. It is also, precisely, five requests per five minutes.

Not near the ceiling. The ceiling, exactly, with nothing left over.

That alone would have been survivable, since nothing else was supposed to talk upstream. But the HTTP routes also fetched live behind a thirty second cache, and the browser page polls its own status route every thirty seconds. So each open tab reached the cache just as it expired and sent a request of its own. One phone with the dashboard open roughly doubled a budget that was already fully committed.

Which produces a genuinely stupid property: the data was freshest when nobody was looking at it. Left alone, the poller spent its five and the cache stayed current. The moment a person opened the page to see what was happening, the account went into lockout and the page stopped learning anything new. And the natural response to a page that has stopped updating is to refresh it, which spends more of a budget that is already gone.

The symptom of polling too much and the symptom of not polling at all are identical: a screen full of numbers that are no longer moving. Every instinct I had for diagnosing stale data pushed harder on the thing causing it.

The lockout arrived as HTTP 200

Exceed the limit and the API does not answer 429. It answers 200, with a plain-text sentence in the body explaining that you have exceeded the maximum number of requests.

My client called response.json() on that. So the failure that reached my logs was a parser error, and for a while I went looking for a malformed payload, on the theory that the controller had returned some legacy field I was not handling. The payload was fine. It was an English sentence asking me to slow down, and I had written code that could only hear JSON.

The client now reads the body as text and parses second, so a non-JSON response surfaces as its own message with the first 160 characters attached. The rate limit says what it is, in the log line, in words. That change is four lines, and it is the only reason the rest of this was findable.

Old data does not arrive labelled old

There is a subtler failure underneath, and it only shows up once your data is old, which is the condition my design had quietly made permanent.

The payload sends no timestamps. It sends offsets: seconds until this zone’s next run, seconds left on the one that is running. I was computing the next run as now + secondsUntilNextRun. Those offsets were true when the payload was fetched, not when I read it, so as the cached payload aged, every countdown on the page slid forward by exactly the cache age. The numbers never looked broken. They stayed confident and went wrong at a rate of one second per second.

That is how Watering now outlived the run: a cached running state, a remaining time anchored to the wrong instant, and a banner with no reason to doubt either.

Both are anchored to the fetch time now. Countdowns subtract the cache age, so a seventy-five second old payload does not report a run that ended seventy-five seconds ago. A running zone whose remaining time has fully decayed is demoted to idle rather than served as running with zero seconds left. And when the payload is stale the banner stops asserting and starts hedging, reporting the clock time it last saw water and flagging the reading as stale, which is uglier and true.

Four for the machine, one for the person

The fix was not a cleverer cache. It was deciding who the five requests belong to.

The poller is now the only caller of that endpoint. Every HTTP route reads whatever it last cached and never goes upstream, so no amount of phone traffic can spend a single request. The poller runs at seventy-five seconds: four requests per five minutes.

The fifth is deliberately left unspent. It funds exactly one thing, a single refresh immediately after an actuation, and only when the cached payload is already at least sixty seconds old. That is the one moment a person needs a fresh read sooner than the next poll: you press run, and you want to watch the zone come on rather than trust that it probably did. The rest of the day, a fifth of the quota sits there doing nothing, which looks like waste on a graph and is the entire point.

A rate-limit error now parks the poller for a full five minutes instead of hammering a door that was just shut. The server keeps serving the last good payload through the backoff with a stale flag and the upstream error text attached, so the page can say it is showing old data instead of performing confidence.

Nothing in my code was rude. The interval was polite, the cache was reasonable, the retry was patient. Each piece behaved well alone, and together they consumed one hundred percent of a shared allowance and left nothing for the only reader who was ever going to look. A rate limit reads like a speed limit for your background job. It is really a budget you are splitting with your future self at the worst possible moment, standing in the yard, wondering whether the water is on.

Four requests belong to the poller. The fifth belongs to whoever pressed the button.


← all writing