Skip to content

Commit 366e429

Browse files
lroolleclaude
andcommitted
feat: v0.13.0 — escalating fetch cooldown + categorized !badges
Live logs showed 23% of usage fetches 429ing in clusters exactly one backoff apart: the fixed 120s cooldown retried straight into a still-throttled window. The 2.1.197 binary confirms the contract is unchanged and the CLI has no 429 handling either — the client must pace itself. - Consecutive failures escalate 120s -> 240s -> 480s -> 600s cap; err state persists until a success so escalation compounds - Retry-After captured via curl %header{} and honored when longer - Stale-data indicator says why: !429 / !auth / !5xx / !net - Bump flash glyph ▲ -> + (triangle renders poorly in some fonts), bound tight to its badge Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 95474e2 commit 366e429

5 files changed

Lines changed: 257 additions & 61 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,41 @@
11
# Changelog
22

3+
## v0.13.0 — 2026-07-02 — escalating fetch cooldown + categorized !badges
4+
5+
Live logs showed 23% of usage fetches failing 429 in clusters exactly one
6+
backoff apart: the fixed 120s cooldown expired and retried straight into a
7+
still-throttled window. Cross-checked against the 2.1.197 binary: the
8+
endpoint contract is unchanged and the CLI's own `fetchUtilization` has no
9+
429 handling either (its only retry is 401→refresh→retry) — the CLI and every
10+
concurrent statusline render share one per-account throttle bucket, so the
11+
client must pace itself.
12+
13+
### Added
14+
15+
- **Escalating error cooldown**: consecutive fetch failures back off
16+
120s → 240s → 480s → 600s (cap) instead of a fixed 120s. The err state
17+
survives until a fetch succeeds, so the escalation compounds; success
18+
clears it. Applies to both the usage and prepaid fetch paths.
19+
- **`Retry-After` honored**: failures now capture the header via curl's
20+
`%header{retry-after}` (>= 7.83; older curls degrade cleanly) and the
21+
cooldown extends to match when the server asks for longer. Failure logs
22+
now include the header, consecutive-failure count, and computed cooldown.
23+
- **Categorized stale-data indicator**: the bare `!` after quota/extra now
24+
says why: `!429` rate limited, `!auth` token rejected (401/403), `!5xx`
25+
server error, `!net` connection failed. Legacy bare-epoch err files still
26+
render plain `!`.
27+
- err files are now JSON (`{at, code, count, cooldown}`); pre-v0.13.0
28+
bare-epoch files are read compatibly (fixed 120s window, bare `!`).
29+
30+
### Changed
31+
32+
- **Bump flash glyph: ```+`** (the Unicode triangle renders poorly in
33+
some terminal fonts), still reverse-video, and now bound tight to its
34+
badge — `5h[44%@1h18m]+2` — so it can't visually float toward the next
35+
badge.
36+
37+
226 tests (8 new), shellcheck baseline reduced by one.
38+
339
## v0.12.1 — 2026-07-02 — bump flash: reverse-video ▲N outside brackets
440

541
### Changed

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ Every component earns its place:
8282
| Effort | Compact lowercase badge: `lo` / `md` / `xh` / `max` / `ultra` / `auto` (`high` is the default and stays hidden). Dim for routine levels; `max` / `ultra` use the pressure color; `fast` shows in fast mode. |
8383
| Context bar | Merged with model. Green / yellow / red by window pressure. On 1M models the bar also carries the premium input-pricing band: yellow past 200k tokens, red past 800k — the % alone looks calm (320k = 32%) while every request bills at the premium rate. A real 0% (e.g. right after `/compact` resets the window) renders a visibly empty bar `[░░░░░░0%]` — the snap to empty *is* the refresh signal; the bar hides only when no context data exists at all. |
8484
| User tier | Neutral white-weight (MAX bold, PRO normal, dim otherwise) — identity, never a status color. Truncated display name. |
85-
| Quota | Integer percentages. The 5h badge always carries its window countdown while a window is live — `5h[42%@1h20m]` reads "42% used, resets in 1h20m" — because on a 5h horizon the time remaining is the number you plan the current sitting around. Same `@remaining` language as the 7d badge; relative, not wall-clock, so no mental subtraction. When a window's utilization climbs between renders, a reverse-video `▲N` glyph appears outside the badge for ~60s: `5h[44%@1h18m] ▲2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows explicit time remaining in the window: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now". Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. |
85+
| Quota | Integer percentages. The 5h badge always carries its window countdown while a window is live — `5h[42%@1h20m]` reads "42% used, resets in 1h20m" — because on a 5h horizon the time remaining is the number you plan the current sitting around. Same `@remaining` language as the 7d badge; relative, not wall-clock, so no mental subtraction. When a window's utilization climbs between renders, a reverse-video `+N` token appears right after the badge for ~60s: `5h[44%@1h18m]+2` means "you just burned 2%". A drop (window reset) stays quiet — the fresh low number is its own signal. **7d is forecast, not leveled**: a learned per-weekday burn profile (EWMA over your own usage history) plus your recent 24h burn project whether the quota outlasts the window — your heavy Tuesday counts more than a generic average. The verdict is color alone; under pressure the badge shows explicit time remaining in the window: `7d[44%@5d]` red means "at your pace, dry days before the reset 5 days from now". Cold start (<14 days history) falls back to window-average pacing. Recovery color when reset is imminent. |
8686
| Extra usage | Monthly spend, limit, prepaid balance. `--extra auto` shows when quota runs out. |
8787
| Cache health | Detects observed prompt-cache rebuilds and cache-read drops. `cache!` on break, `cache~` when building. If a future Claude Code stdin includes TTL breakdown, `--cache always` can show `cache:1h@14:20`; current stdin usually exposes aggregate cache tokens only. Hidden when healthy by default. |
8888

@@ -107,8 +107,8 @@ red, matching its Claude Code TUI color) = model family; everything else is
107107
| 5 themes, 9 bar styles | -- | Yes |
108108
| Prompt cache break detection | -- | Yes |
109109
| OAuth + macOS Keychain | -- | Yes |
110-
| Quota bump flash (N) | -- | Yes |
111-
| 230 bats tests + CI | -- | Yes |
110+
| Quota bump flash (+N) | -- | Yes |
111+
| 238 bats tests + CI | -- | Yes |
112112

113113
## Configuration
114114

@@ -184,9 +184,13 @@ environment variables are intent, not proof of what the server accepted.
184184
| 50 -- 79% | 1 min |
185185
| >= 80% | 30 sec |
186186

187-
Error backoff: 2 min. Cache writes: atomic `mv`.
187+
Error cooldown escalates with consecutive failures — 2 min, 4 min, 8 min, 10 min
188+
cap — and a server `Retry-After` (429s carry one) extends it further. The
189+
cooldown resets on the next successful fetch. Cache writes: atomic `mv`.
188190

189-
Indicators: `~` after quota = refresh in flight. `!` = last fetch failed.
191+
Indicators: `~` after quota = refresh in flight. A failed fetch shows why the
192+
data may be stale: `!429` rate limited, `!auth` token rejected, `!5xx` server
193+
error, `!net` connection failed.
190194

191195
### Try It Locally
192196

@@ -277,7 +281,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
277281
npm exec --yes bats -- t/
278282
```
279283

280-
230 tests across `t/statusline.bats` (218 statusline + integration) and
284+
238 tests across `t/statusline.bats` (226 statusline + integration) and
281285
`t/install.bats` (12 installer). CI runs on push and PR to `main`.
282286

283287
## Project Structure

statusline.sh

Lines changed: 109 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ DIM_CYAN='\033[2;36m'
369369
WHITE='\033[0;37m'
370370
BOLD_WHITE='\033[1;37m'
371371
# Reverse video for the quota bump flash — inverts fg/bg at whatever the
372-
# badge's current color is, so the ▲N glyph pops without adding a new hue.
372+
# badge's current color is, so the +N token pops without adding a new hue.
373373
REVERSE='\033[7m'
374374
NO_REVERSE='\033[27m'
375375
RESET='\033[0m'
@@ -570,6 +570,73 @@ get_user_profile() {
570570
fi
571571
}
572572

573+
# --- fetch error state -------------------------------------------------------
574+
# Failed fetches leave a JSON err file: {at, code, count, cooldown}. Consecutive
575+
# failures escalate the cooldown (120s -> 240s -> 480s -> 600s cap) instead of
576+
# retrying into a still-throttled window every fixed 120s — observed live: 23%
577+
# of usage fetches 429'd in clusters exactly one backoff apart. A server
578+
# Retry-After (429s carry one) extends the cooldown when it's longer. The err
579+
# file survives until a fetch SUCCEEDS, so the count keeps escalating across
580+
# expired cooldowns; success removes it.
581+
582+
record_fetch_error() {
583+
local err_file="$1" http_code="$2" retry_after="$3"
584+
local count=1
585+
if [ -f "$err_file" ] && head -c1 "$err_file" 2>/dev/null | grep -q '{'; then
586+
local prev=$(jq -r '.count // 0' "$err_file" 2>/dev/null)
587+
[ "$prev" -ge 1 ] 2>/dev/null && count=$((prev + 1))
588+
fi
589+
local cooldown
590+
if [ "$count" -ge 4 ]; then
591+
cooldown=600
592+
else
593+
cooldown=$((120 * (1 << (count - 1))))
594+
fi
595+
# Sanitize Retry-After: digits only (old curl without %header{} support
596+
# passes the literal format string through; headers can also be dates).
597+
case "$retry_after" in *[!0-9]* | '') retry_after="" ;; esac
598+
if [ -n "$retry_after" ] && [ "$retry_after" -gt "$cooldown" ] 2>/dev/null; then
599+
cooldown="$retry_after"
600+
fi
601+
debug_log "fetch error: $(basename "$err_file"): code=${http_code:-?} retry_after=${retry_after:-none} consecutive=$count cooldown=${cooldown}s"
602+
jq -n -c --argjson at "$(date +%s)" --arg code "${http_code:-}" \
603+
--argjson count "$count" --argjson cooldown "$cooldown" \
604+
'{at:$at,code:$code,count:$count,cooldown:$cooldown}' >"$err_file" 2>/dev/null
605+
}
606+
607+
# Seconds of cooldown remaining; 0 = clear to retry. Legacy err files (bare
608+
# epoch from pre-v0.13.0) get the old fixed 120s window.
609+
fetch_error_remaining() {
610+
local err_file="$1"
611+
[ -f "$err_file" ] || { echo 0; return; }
612+
local at=0 cooldown=120
613+
if head -c1 "$err_file" 2>/dev/null | grep -q '{'; then
614+
eval "$(jq -r '@sh "at=\(.at // 0)", @sh "cooldown=\(.cooldown // 120)"' "$err_file" 2>/dev/null)"
615+
else
616+
at=$(cat "$err_file" 2>/dev/null || echo 0)
617+
fi
618+
local remaining=$((at + cooldown - $(date +%s)))
619+
[ "$remaining" -gt 0 ] 2>/dev/null || remaining=0
620+
echo "$remaining"
621+
}
622+
623+
# Short reason for the stale-data indicator: !429 (rate limited), !auth
624+
# (401/403), !5xx (server), !net (connection failed). Bare ! when the code
625+
# is unknown (legacy err file).
626+
fetch_error_badge() {
627+
local err_file="$1"
628+
[ -f "$err_file" ] || return
629+
local code=""
630+
head -c1 "$err_file" 2>/dev/null | grep -q '{' && code=$(jq -r '.code // ""' "$err_file" 2>/dev/null)
631+
case "$code" in
632+
429) echo "!429" ;;
633+
401|403) echo "!auth" ;;
634+
5??) echo "!5xx" ;;
635+
000) echo "!net" ;;
636+
*) echo "!" ;;
637+
esac
638+
}
639+
573640
# Usage quota is ACCOUNT-scoped (the /api/oauth/usage endpoint returns the
574641
# same 5h/7d/extra data regardless of session). Cache it once in a shared
575642
# file so N concurrent sessions share one fetch instead of issuing N
@@ -600,16 +667,14 @@ fetch_usage_for_session() {
600667
fi
601668
fi
602669

603-
# Exponential backoff on errors
604-
if [ -f "$err_file" ]; then
605-
local err_at=$(cat "$err_file" 2>/dev/null || echo 0)
606-
local err_age=$(($(date +%s) - err_at))
607-
if [ $err_age -lt 120 ]; then
608-
debug_log "fetch_usage_for_session: in error backoff (${err_age}s < 120s)"
609-
[ -f "$cache_file" ] && cat "$cache_file"
610-
return 0
611-
fi
612-
rm -f "$err_file"
670+
# Escalating cooldown on errors. The err file stays until a success so
671+
# consecutive failures keep escalating; only the cooldown gates retries.
672+
local err_remaining
673+
err_remaining=$(fetch_error_remaining "$err_file")
674+
if [ "$err_remaining" -gt 0 ] 2>/dev/null; then
675+
debug_log "fetch_usage_for_session: in error cooldown (${err_remaining}s remaining)"
676+
[ -f "$cache_file" ] && cat "$cache_file"
677+
return 0
613678
fi
614679

615680
if [ -f "$lock_file" ]; then
@@ -636,19 +701,24 @@ fetch_usage_for_session() {
636701

637702
debug_log "fetch_usage_for_session: fetching (User-Agent: $ua)..."
638703

639-
local response=$(curl -s -w "\n%{http_code}" -X GET \
704+
# %header{retry-after} needs curl >= 7.83; older curl emits the literal
705+
# format string, which record_fetch_error's digit filter discards.
706+
local response=$(curl -s -w "\n%{http_code} %header{retry-after}" -X GET \
640707
"https://api.anthropic.com/api/oauth/usage" \
641708
-H "Authorization: Bearer $token" \
642709
-H "Content-Type: application/json" \
643710
-H "User-Agent: $ua" \
644711
--max-time 5)
645712

646-
local http_code=$(echo "$response" | tail -1)
713+
local status_line=$(echo "$response" | tail -1)
714+
local http_code="${status_line%% *}"
715+
local retry_after="${status_line#* }"
716+
[ "$retry_after" = "$status_line" ] && retry_after=""
647717
local body=$(echo "$response" | sed '$d')
648718

649719
rm -f "$lock_file"
650720

651-
debug_log "API RESPONSE: HTTP $http_code"
721+
debug_log "API RESPONSE: HTTP $http_code${retry_after:+ (retry-after: $retry_after)}"
652722
debug_log "RESPONSE BODY: $body"
653723

654724
if [ "$http_code" = "200" ]; then
@@ -665,8 +735,7 @@ fetch_usage_for_session() {
665735
build_seven_day_profile
666736
return 0
667737
else
668-
debug_log "fetch_usage_for_session: API failed (code: $http_code)"
669-
date +%s >"$err_file" 2>/dev/null
738+
record_fetch_error "$err_file" "$http_code" "$retry_after"
670739
[ -f "$cache_file" ] && cat "$cache_file"
671740
return 1
672741
fi
@@ -699,14 +768,11 @@ fetch_prepaid_balance() {
699768
fi
700769
fi
701770

702-
if [ -f "$err_file" ]; then
703-
local err_at=$(cat "$err_file" 2>/dev/null || echo 0)
704-
local err_age=$(($(date +%s) - err_at))
705-
if [ $err_age -lt 120 ]; then
706-
[ -f "$cache_file" ] && cat "$cache_file"
707-
return 0
708-
fi
709-
rm -f "$err_file"
771+
local err_remaining
772+
err_remaining=$(fetch_error_remaining "$err_file")
773+
if [ "$err_remaining" -gt 0 ] 2>/dev/null; then
774+
[ -f "$cache_file" ] && cat "$cache_file"
775+
return 0
710776
fi
711777

712778
if [ -f "$lock_file" ]; then
@@ -728,20 +794,23 @@ fetch_prepaid_balance() {
728794
touch "$lock_file"
729795

730796
local ua="claude-code/${cli_version:-2.1.170}"
731-
local response=$(curl -s -w "\n%{http_code}" -X GET \
797+
local response=$(curl -s -w "\n%{http_code} %header{retry-after}" -X GET \
732798
"https://api.anthropic.com/api/oauth/organizations/${org_uuid}/prepaid/credits" \
733799
-H "Authorization: Bearer $token" \
734800
-H "Content-Type: application/json" \
735801
-H "User-Agent: $ua" \
736802
-H "x-organization-uuid: $org_uuid" \
737803
--max-time 5)
738804

739-
local http_code=$(echo "$response" | tail -1)
805+
local status_line=$(echo "$response" | tail -1)
806+
local http_code="${status_line%% *}"
807+
local retry_after="${status_line#* }"
808+
[ "$retry_after" = "$status_line" ] && retry_after=""
740809
local body=$(echo "$response" | sed '$d')
741810

742811
rm -f "$lock_file"
743812

744-
debug_log "PREPAID API RESPONSE: HTTP $http_code"
813+
debug_log "PREPAID API RESPONSE: HTTP $http_code${retry_after:+ (retry-after: $retry_after)}"
745814
debug_log "PREPAID RESPONSE BODY: $body"
746815

747816
if [ "$http_code" = "200" ]; then
@@ -752,8 +821,7 @@ fetch_prepaid_balance() {
752821
cat "$cache_file"
753822
return 0
754823
else
755-
debug_log "fetch_prepaid_balance: API failed (code: $http_code)"
756-
date +%s >"$err_file" 2>/dev/null
824+
record_fetch_error "$err_file" "$http_code" "$retry_after"
757825
[ -f "$cache_file" ] && cat "$cache_file"
758826
return 1
759827
fi
@@ -1655,10 +1723,11 @@ build_usage_display() {
16551723
local rel=$(format_reset_relative "$five_reset")
16561724
[ -n "$rel" ] && reset_suffix="${DIM}@${rel}${color}"
16571725
fi
1658-
# Bump flash: reverse-video ▲N outside the brackets — spatially
1659-
# distinct and unmissable without adding a fourth color lane.
1726+
# Bump flash: reverse-video +N right after the badge — bound tight so
1727+
# it can't read as belonging to the next badge, unmissable without
1728+
# adding a fourth color lane. ASCII + (a ▲ glyph rendered poorly).
16601729
local bump_part=""
1661-
[ "$five_bump" -gt 0 ] 2>/dev/null && bump_part=" ${color}${REVERSE}${five_bump}${NO_REVERSE}"
1730+
[ "$five_bump" -gt 0 ] 2>/dev/null && bump_part="${color}${REVERSE}+${five_bump}${NO_REVERSE}"
16621731
parts+=("${DIM}5h${color}[${five_int}%${reset_suffix}]${bump_part}${RESET}")
16631732
fi
16641733

@@ -1727,7 +1796,7 @@ build_usage_display() {
17271796
reset_suffix="${DIM}@${rem}${color}"
17281797
fi
17291798
local bump_part=""
1730-
[ "$seven_bump" -gt 0 ] 2>/dev/null && bump_part=" ${color}${REVERSE}${seven_bump}${NO_REVERSE}"
1799+
[ "$seven_bump" -gt 0 ] 2>/dev/null && bump_part="${color}${REVERSE}+${seven_bump}${NO_REVERSE}"
17311800
parts+=("${DIM}7d${color}[${seven_int}%${reset_suffix}]${bump_part}${RESET}")
17321801
fi
17331802

@@ -2334,11 +2403,9 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
23342403
[ "$age" -ge "$ttl" ] && should_fetch=true
23352404
fi
23362405

2337-
# Respect error backoff
2338-
if [ "$should_fetch" = true ] && [ -f "$err_file" ]; then
2339-
err_at=$(cat "$err_file" 2>/dev/null || echo 0)
2340-
err_age=$(($(date +%s) - err_at))
2341-
[ $err_age -lt 120 ] && should_fetch=false
2406+
# Respect the escalating error cooldown
2407+
if [ "$should_fetch" = true ] && [ "$(fetch_error_remaining "$err_file")" -gt 0 ] 2>/dev/null; then
2408+
should_fetch=false
23422409
fi
23432410

23442411
if [ "$should_fetch" = true ]; then
@@ -2368,7 +2435,7 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
23682435
if [ -f "$lock_file" ]; then
23692436
quota_component="${quota_component}${DIM}~${RESET}"
23702437
elif [ -f "$err_file" ]; then
2371-
quota_component="${quota_component}${DIM_RED}!${RESET}"
2438+
quota_component="${quota_component}${DIM_RED}$(fetch_error_badge "$err_file")${RESET}"
23722439
fi
23732440
fi
23742441

@@ -2390,10 +2457,8 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
23902457
[ "$prepaid_age" -ge 300 ] && should_fetch_prepaid=true
23912458
fi
23922459

2393-
if [ "$should_fetch_prepaid" = true ] && [ -f "$prepaid_err" ]; then
2394-
prepaid_err_at=$(cat "$prepaid_err" 2>/dev/null || echo 0)
2395-
prepaid_err_age=$(($(date +%s) - prepaid_err_at))
2396-
[ $prepaid_err_age -lt 120 ] && should_fetch_prepaid=false
2460+
if [ "$should_fetch_prepaid" = true ] && [ "$(fetch_error_remaining "$prepaid_err")" -gt 0 ] 2>/dev/null; then
2461+
should_fetch_prepaid=false
23972462
fi
23982463

23992464
if [ "$should_fetch_prepaid" = true ]; then
@@ -2410,7 +2475,7 @@ if [ -n "$session_id" ] && [ "$_may_have_oauth" = true ]; then
24102475
if [ -f "$prepaid_lock" ]; then
24112476
extra_display="${extra_display}${DIM}~${RESET}"
24122477
elif [ -f "$prepaid_err" ]; then
2413-
extra_display="${extra_display}${DIM_RED}!${RESET}"
2478+
extra_display="${extra_display}${DIM_RED}$(fetch_error_badge "$prepaid_err")${RESET}"
24142479
fi
24152480
extra_component="$extra_display"
24162481
fi

0 commit comments

Comments
 (0)