DEV Community

Jerome
Jerome

Posted on

Claude Code Hit Its Limit — and the Notification Never Came.

A while back I wrote about claude-code-notify, a small tool I built because I kept losing time to work I wasn't watching. It pings me on Telegram when a Claude Code turn finishes, when it needs my input, or when it dies with an error — so I can kick off a long task, switch windows, and stop thinking about it. The push reaches me whichever session has focus; being reachable when I'm not looking at the terminal is the whole point.

One case slipped through: the usage limit.

Hit your account's usage limit and every running session stops at once. The tool did fire — my error hook caught the dead turn and sent its generic "stopped with error" ping. But that ping tells you something broke, not that it was a limit, and not when it'll lift. Then a few hours later the limit quietly resets and nothing fires at all. So the shape was always the same: hit the limit, get a vague error, put the phone down, forget. By the time I wandered back, the limit had been open for who-knows-how-long — the exact dead time the tool exists to kill.

So I set out to add the one notification that would close the gap: a limit-specific message — something like usage limit reached, resets 5:20am — and a second ping when it actually reset.

It should have been an afternoon. Instead, just to add this one notification, I fell into a string of silent failures: the tool breaking in the one way a notification tool can't afford to, and never telling me it had.

The version that never fired

I wired it up the way you'd expect. On the error hook, read the session transcript, find the last assistant message, check whether it's a rate-limit envelope — Claude Code tags those with isApiErrorMessage: true and error: "rate_limit" — and if so, pull the reset time out of the text and send the limit-specific ping. I tested it against a saved transcript, it worked, I shipped it.

Then I hit two real usage limits over the next week. Both times: the generic "stopped with error" ping, and nothing else. No limit-specific message, no reset ping. The new detection had run and decided, both times, that this was not a usage limit — on a turn that had died of nothing else.

The generic ping was the tell. It's the consolation prize of a detection that has silently failed: the turn errored, so the old error hook fired regardless, which meant every real miss still looked like a notification arrived. I only noticed anything was wrong because I knew a limit-specific message was supposed to be there and wasn't.

So I added debug logging around the transcript read and waited to hit the limit again. When I did, the log showed the read returning nothing — no rate-limit envelope in the transcript at the moment my hook looked. Then I compared the read timestamp in my log against the file's own modification time. In that one incident, my code had read the transcript at 00:13:14.735881; the file's mtime was 00:13:14.755575. I had read it 19.7 milliseconds before Claude Code finished writing it. One measurement, one incident — I don't know the distribution — but the direction was unambiguous: the read beat the write.

Here's the mechanism, and it's the whole article. transcript_path in the hook payload guarantees the file exists. It does not guarantee the current turn is in it. Claude Code writes the transcript asynchronously, and the StopFailure hook can fire before the rate-limit envelope has been flushed to disk — the same local disk, same machine, no network anywhere in it. My detection was reading a file Claude Code hadn't finished writing, seeing no rate-limit envelope, classifying that as "not a limit," and moving on. Correct, given what was on disk. Useless, given what was true.

The patch, and the fix I should have started with

The first fix was the obvious one: if the first read comes up empty, wait and read again. One retry at 200 milliseconds — _STOP_FAILURE_RETRY_DELAYS = (0.2,), roughly ten times the gap I'd measured — gave the write enough slack to land by the second look. It worked; the next few limits sent proper reset pings.

But a 200ms sleep to paper over a race I didn't fully understand is a patch, not an answer. Before building anything else on top of it, I went and read Claude Code's official hooks documentation — which I should have done before writing a line of transcript-parsing code, because the answer was sitting in it twice over.

The race is documented behavior, not something I'd stumbled into. The reference says the transcript file "is written asynchronously and may lag the in-memory conversation, so it may not yet include the current turn's most recent messages when a hook fires," and it says what to do instead: use last_assistant_message "instead of reading the transcript." There's even a public issue for the exact symptom — anthropics/claude-code#15813 — though a stale bot closed it for inactivity rather than any fix, so this is just how it works: documented, and unowned.

The bigger miss was the part I'd assumed away. StopFailure hands you the error in the payload. It arrives in the hook's stdin JSON, right next to transcript_path: a structured error field drawn from a fixed enum — rate_limit is one of the values — plus last_assistant_message, which on an error turn holds the API error string itself. No file, no flush, no race. I'd built the entire transcript-reading path on the belief that the hook gave me nothing structured and I'd have to reconstruct the error myself. It had been handing it to me the whole time.

So I flipped the priority. The payload is the primary source now: if error is rate_limit and last_assistant_message is there, classify and send, with no disk read at all. The transcript read, retry and all, stays only as the fallback for the edge the payload doesn't cover — the racy path runs when it has to and never when it doesn't.

Then it fired when it shouldn't have

The classifier had a second bug waiting, and this one broke in the opposite direction: one day it sent me a usage-limit notification that was a lie.

The turn hadn't hit my account's usage limit at all. It had tried to use Fable 5 — a model that bills against its own usage-credits balance instead of the subscription allowance — without credits. Claude Code's message said so plainly: Fable 5 requires usage credits. Run /usage-credits to continue or switch models with /model. My tool pinged "usage limit reached" anyway.

It fooled the classifier because Claude Code tags this error with the same fields a real limit uses. Same isApiErrorMessage: true, same error: "rate_limit", same 429 status. If your rule is "it's a limit when error is rate_limit" — which was exactly my rule, on both the payload and the transcript path — a per-model credits gate is indistinguishable from an account limit. That one field is doing double duty for two conditions that mean completely different things to me.

What set them apart sat a level deeper. On disk, the genuine limits I'd inspected carried no errorDetails at all; the credits error carried one, with a structured code inside it: error.details.error_code == "credits_required". That field, and only that field, is the ground truth.

So the fix is one extra clause, on both paths: it's a usage limit only when error is rate_limit and the error body isn't a credits_required error. It keys on the structured code, never on the words "usage credits" in the message — text-matching is how you build a classifier that breaks the moment the wording changes, and not doing it is a standing rule in this project. The most specific structured field wins.

What both failures had in common

Put the two bugs side by side. One never fired; one fired when it shouldn't. Opposite symptoms — the same mistake. Both are what happens when you read one ambiguous, half-documented signal that another process hands you and trust your first reading of it. The miss trusted an empty file. The false alarm trusted an overloaded field. Wiring the hook was never the hard part; classifying the signal on the other end was the entire job.

And here's what stings: neither bug was catchable by the test suite as it stood. My fixtures encoded the happy path — a fully-written transcript with a clean rate-limit envelope. A fixture is an already-written file, so it can't reproduce a race against a file still being written. And I had no credits-error fixture at all, because I didn't know that error existed until Claude Code sent me one. The tests were green the whole time. They were confirming that my code agreed with my assumptions, which is a different thing from confirming it was right.

Both bugs surfaced the same way: a real event I couldn't have predicted, plus enough debug logging already in place to see what actually happened when it hit. The retry, the payload switch, the credits check — those were the easy part once I could see the signal. The discipline that made them possible was instrumenting the thing before I understood it, so the next strange event would leave a trace instead of a shrug. That, more than any single fix, is the skill.

The worked-out version — retry, payload-first, the credits check — lives in claude-code-notify on GitHub, MIT-licensed: github.com/Jeromefromcn/claude-code-notify. What I'd still like to collect is other people's version of the same story: a notification that fired for the wrong reason, or stayed quiet for the right one. That's usually where the interesting bug is hiding — in the signal you trusted without reading twice.

Top comments (0)