cd /news/developer-tools/claude-code-hit-its-limit-and-the-no… · home topics developer-tools article
[ARTICLE · art-71529] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer building a Telegram notification tool for Claude Code discovered that the tool silently failed to detect usage limits because it read the session transcript before Claude Code finished writing it. The transcript read occurred 19.7 milliseconds before the file was fully written, causing the tool to miss the rate-limit envelope and send only a generic error ping instead of a limit-specific alert.

read8 min views1 publishedJul 24, 2026

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.

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 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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-code-hit-its-…] indexed:0 read:8min 2026-07-24 ·