0.8.0 · macOSThe Plan Usage key on the Stream Deck renders my Claude plan’s 5-hour and 7-day windows with a countdown to the next reset. HerdDeck polls Anthropic’s OAuth usage endpoint to fill it. That endpoint is undocumented, which is a story I have already told on this blog and will be correcting later in this post.
The log showed this, live, on my machine:
successes: 1136
HTTP 429: 543
recent ticks: update error update update error update update error update error update updateTwo facts in that block, and the second one is the whole post.
A third of requests were rate-limited. And the failures arrived interleaved, not in runs.
Consecutive-error backoff answers “is the service down”#
The mechanism I had was textbook exponential backoff on a consecutive-error counter. Every failure increments it and lengthens the interval. Any success resets it to zero.
That shape encodes an assumption: failures cluster. It is designed for an outage, where the service goes away, you back off further and further, and when it returns you snap back to normal cadence. Under those conditions the counter climbs, the interval grows, and the mechanism does its job.
Now feed it update error update update error update. With a success rate around two in three, the counter never survives long enough to reach two. The cadence oscillates between the 60-second base and a single doubling, forever, permanently pinned at the limit.
| Logged cadence line | Occurrences |
|---|---|
errors=1 | 492 |
errors=4 | 5 |
492 to 5. The counter was not counting an outage, because there was no outage. Every one of those 543 rejections was the server saying “you are asking too often”, which is a completely different question, and no amount of tuning the first answer addresses the second.
Three fixes, in descending order of how much they mattered#
One: the interval was absurd. A 60-second poll for a key rendering 5-hour and 7-day windows. Those numbers move far slower than a minute. The poll is now five minutes, and the comment in the source carries the measurement that justified it:
// 5 minutes, not 60s. The 5-hour and 7-day windows this renders move
// far too slowly to justify a minute-by-minute poll, and 60s put the
// daemon permanently at Anthropic's rate limit: 1136 successes against
// 543 HTTP 429s on this machine, arriving interleaved. With two daemons
// on one account it was double that.
const PLAN_POLL_INTERVAL_MS = 5 * 60_000;Roughly 80% of the request volume disappeared into that one constant. Everything below is smaller.
Two: obey the server. Anthropic states the correct wait in Retry-After, and the fetcher had been capturing that header all along, formatting it into a log string, and throwing it away. It now gets parsed and clamped, never faster than the base interval and never slower than the cap:
this.currentIntervalMs = Math.min(Math.max(retryAfterMs, this.intervalMs), this.maxBackoffMs);Three: do not poll when nobody is looking. A daemon with no Stream Deck attached was polling anyway. In a two-machine setup that is the herdr host spending half the account’s request budget rendering a key that is not on screen. The gate is read per tick rather than latched at startup, so plugging a deck in does not require a daemon restart, and a skipped tick deliberately never reaches the cadence logic at all. A skipped tick is evidence of neither success nor throttling, and counting it as clean would let a headless daemon walk its way back down to an aggressive cadence without issuing a single request.
What a review found that twelve tests did not#
I shipped an adaptive floor alongside those fixes. Every failure raised it by 1.5x, five consecutive clean ticks earned one step back down. Back off fast, recover slowly. It had twelve tests and they all passed.
Then I asked for an adversarial review of the arithmetic, and got three findings, none of which a test could have caught, because in every case the tests agreed with the code.
The tests ran against a configuration the same commit had changed. The floor was tuned and verified at a 60-second base interval. The same commit moved production to 300 seconds. The cap was 10 minutes flat at the time. At 60 seconds that gives you a ladder with real rungs. At 300 seconds, the consecutive-error term is base * 2^errors, so the first failure lands on 600 seconds, which is the cap exactly, and every subsequent failure lands there too.
Five distinct intervals become two. The blue ladder is what twelve tests exercised. The red one is what ran on my machine. Same code, same commit, and the consecutive-error term never affected an outcome again.
The tuning knob was mathematically inert. Up-steps and down-steps were the same multiplicative size, so the constant that looked like the tuning parameter cancelled out of the stability condition entirely. Only the streak length could affect whether the mechanism converged. A knob that cannot turn anything is worse than no knob, because sooner or later somebody turns it and reasons from the result.
And there was a reason to delete it that had nothing to do with rate limiting. The floor rose on any failure, including failures that say nothing about request rate. An expired OAuth token pinned the cadence at the ceiling and kept it there for tens of minutes after I had re-authenticated.
So the mechanism was deleted rather than tuned, and what replaced it is less code than what it removed.
Two things about the review itself are worth more than the findings.
It was asked to attack a specific claim with the arithmetic demanded rather than the verdict: work through the failure rate, does this converge or pin at the cap? It came back with a Markov chain, a break-even threshold of 12.9%, and the observation that my convergence worry was unfounded while a different problem was fatal. A reviewer told to find problems will find some. A reviewer told to check a specific number will sometimes tell you the number is fine and the question was wrong.
And the reviewer corrected itself. Its first pass closed by suggesting that wiring one constructor argument would have delivered more than the whole mechanism. Asked to confirm before that shaped the fix, it checked, found that the channel it depended on does not exist anywhere in the repo, and led its second response by retracting the claim. That retraction was the most valuable paragraph in the review. A review that cannot be wrong is not a review.
The part I have to say about my own cleanup#
Given that the finding above was “you claimed a guarantee your test never made”, I am not going to claim a clean deletion.
The floor’s logic is gone. Its residue is not. The class docstring still describes the adaptive floor as a live mechanism, in direct contradiction of the method docstring a hundred lines below it. The cap constant is still named MAX_BACKOFF_FLOOR_MS. A passing test is still titled “a sustained clean streak earns the floor back”, testing something that no longer exists under a name that says it does.
None of that changes behaviour. All of it is exactly the kind of thing that convinces the next reader, including the next me, that a mechanism is present when it is not.
The correction I owe an earlier post#
In May I wrote I polled an undocumented endpoint for 18 hours. The data was on stdin. The argument was that /api/oauth/usage was the wrong primitive and Claude Code’s statusline already pushes rate_limits.five_hour and rate_limits.seven_day every turn, for free.
The narrow claim still holds. Those two fields are documented and they are there.
The conclusion I drew from it overreached. I wrote that the endpoint had become “the fallback path now, not the primary one”, and HerdDeck found the inverse. The account this runs against returns seven buckets:
five_hour, seven_day, seven_day_opus, seven_day_sonnet,
seven_day_cowork, seven_day_oauth_apps, seven_day_omeletteThe per-model weekly windows have no documented statusline equivalent. The statusline is a subset of that endpoint, not a replacement for it, and if you want to know how much Opus specifically you have left this week, the undocumented endpoint is the only place that answers.
HerdDeck was dropping five of those seven for weeks, because the code that picked weekly buckets out of the response matched on the substring weekly and every per-model key spells it seven_day_. The filter now matches both halves:
if (!/weekly/i.test(key) && !/^seven[_-]?day[_-]./i.test(key)) continue;There is a second, smaller correction. That May post called its statusline freshness gate “the load-bearing change”. HerdDeck carried the same-shaped gate forward, and it has fired zero times. Not rarely. Zero. Nothing populates the timestamp it reads, because the statusline auto-patcher that used to populate it was one of the subsystems the rebuild deleted.
I want to be precise about what that does and does not mean. It is not evidence that the gate was wrong in ClaudeDeck, where it was wired to a feeder that existed. It is that I carried the seam across a rewrite and left the feeder behind, then spent weeks suffering the exact problem the seam was built to prevent while looking straight at machinery that was inert the entire time. The gate’s docstring now says so in capital letters instead of implying a defence it does not provide.
Lessons#
- Consecutive-error backoff answers “is the service down”. If your failures arrive interleaved with successes, you are being told “you are asking too often”, and no amount of tuning the first answer addresses the second.
- Look at the distribution of your error counter, not just its maximum. 492 occurrences of
errors=1against five oferrors=4says the mechanism never engaged, and it says it in one grep. - The server usually knows the right answer.
Retry-Afterbeats any heuristic that guesses at it, and it is often already in a variable you are formatting into a log line. - A test suite that runs against a different constant than production is measuring a system nobody has. Check what the same commit changed before trusting what its tests proved.
- If a tuning constant cancels out of the maths, delete it. A knob that cannot turn anything is worse than no knob, because someone will turn it and believe the result.
- Deleting a mechanism means deleting the docstrings, constant names and test titles that advertise it. Residue outlives behaviour and it is what the next reader believes.
- When you port a design across a rewrite, port the thing that feeds it or delete the seam. A defence with no input is indistinguishable from a defence, right up until you need it.
References#
- Claude Code statusline docs, the documented
rate_limitscontract: https://code.claude.com/docs/en/statusline - RFC 9110 section 10.2.3,
Retry-Aftersemantics: https://httpwg.org/specs/rfc9110.html#field.retry-after - The post this one corrects: I polled an undocumented endpoint for 18 hours. The data was on stdin.
- The rebuild that deleted the statusline auto-patcher: I deleted six subsystems by swapping one dependency.
- HerdDeck poller and cadence logic:
packages/daemon/src/planUsagePoller.ts - HerdDeck usage fetcher,
Retry-Afterparsing and the weekly-bucket filter:packages/daemon/src/claudeAiFetcher.ts
