Skip to content

Usage-limit rescue is scrape-only and unreliable; the StopFailure hook already fires for usage limits (error: "rate_limit") but is only wired to the overload path #50

Description

@shaunmower

Summary

The usage-limit path (waiting state, hours-scale reset) depends entirely on screen-scraping the banner out of the pane tail. The session-limit banner is emitted by Claude Code as an inline, one-shot isApiErrorMessage turn in response to a submission — it is not a persistently-redrawn banner — so whether any 5s poll happens to catch it is a coin-flip. When the poll loses that race, detection never fires, the tool never sends, the banner is never re-emitted, and the session sits idle until the user returns manually.

Meanwhile the StopFailure hook already fires for usage limits with error: "rate_limit" and carries the session_id — a reliable, timestamped, non-scraping signal that is currently only used to drive the overload (seconds-scale backoff) path, never the wait-until-reset path.

Proposed fix: treat a rate_limit StopFailure marker as a first-class usage-limit trigger — read the reset time from that session's transcript (via the session_id the marker already carries), enter the existing waiting state until the reset, then send the retry. This removes the scrape from the critical path for usage limits entirely.

Relationship to #38

This overlaps #38 (the fixed RATE_LIMIT_TAIL_LINES = 12 window) but is more fundamental, not a duplicate. #38 makes the scrape miss less often by widening the window. The event-driven path proposed here removes the scrape from the usage-limit critical path entirely, so the banner's on-screen position stops mattering at all — it can't be pushed out of a window that's no longer consulted. In that sense this supersedes #38 for usage-limit detection when the hook is installed.

#38 still stands on its own, because the scrape doesn't go away completely: it remains for the /rate-limit-options menu detection (isRateLimitOptionsPrompt / menuStepsToWaitOption), overload detection, and as the fallback when the hook isn't installed. So the two are complementary — this issue makes usage-limit detection reliable via the event channel; #38 hardens the scrape that's still needed elsewhere.

Evidence (two concurrent sessions, same account-wide limit, v0.5.1)

Two panes (%0, %32) hit the same session limit within ~3 minutes. Both fired the StopFailure hook — the markers, straight off disk:

// events/_0.json
{ "pane": "%0",  "error": "rate_limit", "session_id": "", "ts": }   // written 10:33:57
// events/_32.json
{ "pane": "%32", "error": "rate_limit", "session_id": "", "ts": }   // written 10:36:27

error: "rate_limit" confirms a usage/session limit raises StopFailure and passes isRetryableError() (events.js). The banner text lives in each session's transcript:

You've hit your session limit · resets 12:10pm (Australia/Melbourne)

Outcome:

  • %0 recovered — but via the scraping path getting lucky (log shows Rate limit detected … → menu select → wait → Sent retry message (attempt 1)User already continued). Its hook marker was never consumed (still on disk).
  • %32 did not recover — its monitor (confirmed alive throughout) logged nothing but its startup line, sent nothing, and the session sat idle from 10:36 until manual resume ~2h later. Its hook marker was also never consumed.

So the hook fired for both panes and rescued neither; the one recovery came from a scrape that happened to win the race. On a prior night the same setup lost the race on the interactive pane and idled for 9 hours.

The two code-level gaps

  1. Hook markers for usage limits are effectively ignored. readEvent is only reached in the monitoring branch (processOneTick, monitor.js ~L295–311). A marker written while the monitor is in waiting (or outside the eventMaxAgeSeconds window) is never consumed and goes stale on disk — which is what happened to both markers above.
  2. Even when consumed, a hook event is routed to the wrong machine. The readEvent branch sets status = 'overload' and schedules nextOverloadWaitMs (seconds), then relies on a subsequent scrape (isRateLimited, monitor.js ~L196/L220) to hand off to enterUsageWait. It never parses the reset time from the event, so a rate_limit event backs off in seconds against a limit that resets in hours.

Proposed fix (sketch)

  • In the monitoring (and waiting) handling, check for a rate_limit StopFailure marker before the overload routing.
  • On such a marker, resolve the transcript path from session_id (~/.claude/projects/<cwd-slug>/<session_id>.jsonl), read the most recent isApiErrorMessage text, and feed it through the existing parseResetTime / calculateWaitMs → enter waiting until the reset, then the existing retry send.
  • Keep the scrape as a fallback for cases where the hook isn't installed.

This reuses the entire existing wait-and-retry mechanism; the change is only the trigger (reliable event instead of scrape) and the reset-time source (transcript instead of on-screen banner).

Environment

  • claude-auto-retry v0.5.1 (global npm install)
  • Claude Code 2.1.205
  • macOS (Apple Silicon), tmux, zsh
  • StopFailure hook installed via claude-auto-retry install-hook

Happy to open a PR (with tests) if the approach looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions