diff --git a/claude/statusline.isaacaudet.sh b/claude/statusline.isaacaudet.sh index a1fd3c0..22927e8 100755 --- a/claude/statusline.isaacaudet.sh +++ b/claude/statusline.isaacaudet.sh @@ -17,6 +17,7 @@ BRANCH_MAX_LEN=28 # truncate branch names longer than this CWD_MAX_LEN=20 # truncate the cwd basename longer than this GIT_CACHE_SECS=10 # seconds to cache git status (git diff is slow on large repos) USAGE_CACHE_SECS=300 # seconds to cache the usage API response (the 5h/7d bars) +USAGE_RETRY_SECS=60 # seconds to wait before retrying a failed usage API fetch TOKEN_BAR_WIDTH=8 # width of token progress bar # Where the git and usage-API caches live. Overridable via the environment so a @@ -483,6 +484,7 @@ fi # and on how many lines, is decided by the measured ladder at the end. if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then api_cache="$CACHE_DIR/statusline-usage-cache.json" + fail_marker="$CACHE_DIR/statusline-usage-fail" needs_refresh=true usage_data="" @@ -501,23 +503,53 @@ if $SHOW_RATE_LIMITS && [ "$width_tier" != "narrow" ]; then fi if $needs_refresh; then - token=$(get_oauth_token) - if [ -n "$token" ] && [ "$token" != "null" ]; then - response=$(curl -s --max-time 10 \ - -H "Accept: application/json" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" \ - -H "anthropic-beta: oauth-2025-04-20" \ - -H "User-Agent: claude-code/2.1.34" \ - "https://api.anthropic.com/api/oauth/usage" 2>/dev/null) - if [ -n "$response" ] && echo "$response" | jq . >/dev/null 2>&1; then - # Only cache successful (non-error) responses - if ! echo "$response" | jq -e '.error' >/dev/null 2>&1; then - usage_data="$response" - echo "$response" > "$api_cache" + # Hold off after a failed fetch. With no network curl can burn its + # --max-time before giving up, and a redraw happens on every keystroke, + # so retrying each time would stall the whole status line. The marker is + # separate from the cached response, rather than a touch of it: a cold + # /tmp has no response to touch, which is exactly when there is also no + # stale data to fall back on and the timeout is paid in full. + attempt=true + if [ -f "$fail_marker" ]; then + fail_mtime=$(stat -c %Y "$fail_marker" 2>/dev/null || stat -f %m "$fail_marker" 2>/dev/null) + [ $(( $(date +%s) - fail_mtime )) -lt "$USAGE_RETRY_SECS" ] && attempt=false + fi + + if $attempt; then + token=$(get_oauth_token) + if [ -n "$token" ] && [ "$token" != "null" ]; then + # Marked before the call, cleared when it lands. A fetch is in + # flight for as long as curl takes to time out, and this cache + # dir is shared by every session, so redraws that start inside + # that window are real: they now serve stale data instead of + # each launching their own doomed request. Written on every + # attempt, so a repeated failure restarts the backoff, and only + # by an attempt, so the redraws it suppresses cannot keep + # re-stamping it and it always lapses. + # + # No credentials is not a failure worth suppressing: it costs no + # timeout, and the next redraw after a login should show the + # bars rather than wait out a retry window. Hence inside the + # token check. + : > "$fail_marker" + response=$(curl -s --max-time 10 \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $token" \ + -H "anthropic-beta: oauth-2025-04-20" \ + -H "User-Agent: claude-code/2.1.34" \ + "https://api.anthropic.com/api/oauth/usage" 2>/dev/null) + if [ -n "$response" ] && echo "$response" | jq . >/dev/null 2>&1; then + # Only cache successful (non-error) responses + if ! echo "$response" | jq -e '.error' >/dev/null 2>&1; then + usage_data="$response" + echo "$response" > "$api_cache" + rm -f "$fail_marker" + fi fi fi fi + # Fall back to stale cache if refresh failed — skip if it's an error response if [ -z "$usage_data" ] && [ -f "$api_cache" ]; then stale=$(cat "$api_cache" 2>/dev/null) diff --git a/claude/tests/statusline.isaacaudet.cache_test.sh b/claude/tests/statusline.isaacaudet.cache_test.sh index 3a5bdf4..837bb80 100755 --- a/claude/tests/statusline.isaacaudet.cache_test.sh +++ b/claude/tests/statusline.isaacaudet.cache_test.sh @@ -19,6 +19,7 @@ OVERHEAD=$(( 2 * PAD + 1 )) # written there is shown in the running TUI as real usage until it expires. export STATUSLINE_CACHE_DIR="$(mktemp -d)" CACHE="$STATUSLINE_CACHE_DIR/statusline-usage-cache.json" +FAIL_MARKER="$STATUSLINE_CACHE_DIR/statusline-usage-fail" REPO="$(mktemp -d)" STUB="$(mktemp -d)" @@ -33,9 +34,17 @@ git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init # usage API. The token is faked too, so the keychain is never read. export CLAUDE_CODE_OAUTH_TOKEN=test-token export CURL_CALLS="$STUB/calls" +export CURL_MARKER_SEEN="$STUB/marker-seen" +export FAIL_MARKER cat > "$STUB/curl" <<'SH' #!/bin/bash echo call >> "$CURL_CALLS" +# Record whether the backoff marker was already written when the fetch +# started, which is what a concurrent redraw would see. +[ -f "$FAIL_MARKER" ] && echo seen >> "$CURL_MARKER_SEEN" +# With $CURL_FAIL set, behave as curl does with no network: nothing on +# stdout and a non-zero exit. +[ -n "$CURL_FAIL" ] && exit 6 printf '%s' "$CURL_REPLY" SH chmod +x "$STUB/curl" @@ -71,15 +80,25 @@ cached_response_aged() { "seven_day":{"utilization":7.0,"resets_at":"2026-08-28T02:00:00+00:00"}, "extra_usage":{"is_enabled":false},"limits":[]} JSON - AGE="$1" FILE="$CACHE" python3 -c ' + age_file "$CACHE" "$1" + rm -f "$FAIL_MARKER" + : > "$CURL_CALLS" + : > "$CURL_MARKER_SEEN" +} + +# Backdate $1 by $2 seconds. +age_file() { + FILE="$1" AGE="$2" python3 -c ' import os, time f = os.environ["FILE"]; t = time.time() - int(os.environ["AGE"]) os.utime(f, (t, t))' - : > "$CURL_CALLS" } calls() { wc -l < "$CURL_CALLS" | tr -d ' '; } +# No cached response and no backoff marker: a cold /tmp. +cold_cache() { rm -f "$CACHE" "$FAIL_MARKER"; : > "$CURL_CALLS"; : > "$CURL_MARKER_SEEN"; } + # ------------------------------------------------------------------- 5min TTL echo "Usage cache TTL (5 minutes):" @@ -131,6 +150,102 @@ case "$out" in *"5h"*) bad "usable 67: no bars at the narrow tier" "$out" ;; [ "$(calls)" = "0" ] && ok "usable 67: no API call when no bars are shown" \ || bad "usable 67: no API call when no bars are shown" "$out" +# --------------------------------------------------------- offline retry backoff +# With no network, curl can burn --max-time 10 before giving up. A failed fetch +# is remembered so redraws in the next minute do not each pay for that. +echo +echo "A failed fetch backs off instead of retrying every redraw:" + +export CURL_FAIL=1 + +cached_response_aged 360 +out=$(render 245) +[ "$(calls)" = "1" ] && ok "offline: first redraw attempts the fetch" \ + || bad "offline: first redraw attempts the fetch" "$out" +case "$out" in *"5%"*) ok "offline: falls back to the stale response" ;; + *) bad "offline: falls back to the stale response" "$out" ;; esac +out=$(render 245) +[ "$(calls)" = "1" ] && ok "offline: the next redraw does not retry" \ + || bad "offline: the next redraw does not retry" "$out" +case "$out" in *"5%"*) ok "offline: still shows the stale response while backed off" ;; + *) bad "offline: still shows the stale response while backed off" "$out" ;; esac + +# The marker must be the thing that ages, not be re-stamped by the redraws it +# suppresses — otherwise the backoff never lapses and the bars never return. +age_file "$FAIL_MARKER" 90 +out=$(render 245) +[ "$(calls)" = "2" ] && ok "offline: retries once the backoff lapses" \ + || bad "offline: retries once the backoff lapses" "$out" +out=$(render 245) +[ "$(calls)" = "2" ] && ok "offline: the second failure restarts the backoff" \ + || bad "offline: the second failure restarts the backoff" "$out" + +# The marker goes down before the request, not after it: a fetch is in flight +# for as long as curl takes to time out, and every redraw starting inside that +# window would otherwise launch its own. The cache dir is shared between +# sessions, so those redraws are not hypothetical. Starting from no marker at +# all, so only this attempt's own write can satisfy it. +cold_cache +out=$(render 245) +case "$(cat "$CURL_MARKER_SEEN")" in *seen*) ok "offline: the in-flight fetch marked itself before calling" ;; + *) bad "offline: the in-flight fetch marked itself before calling" "$out" ;; esac + +# A cold /tmp is the case a touch of the response file cannot cover: there is +# no response to touch, so every redraw would pay the timeout. +cold_cache +out=$(render 245) +[ "$(calls)" = "1" ] && ok "offline, no cache: first redraw attempts the fetch" \ + || bad "offline, no cache: first redraw attempts the fetch" "$out" +out=$(render 245) +[ "$(calls)" = "1" ] && ok "offline, no cache: the next redraw does not retry" \ + || bad "offline, no cache: the next redraw does not retry" "$out" +case "$out" in *"5h"*) bad "offline, no cache: no bars, having no data" "$out" ;; + *) ok "offline, no cache: no bars, having no data" ;; esac + +unset CURL_FAIL + +# Back online, a marker that has lapsed must not keep suppressing fetches. +cold_cache +: > "$FAIL_MARKER" +age_file "$FAIL_MARKER" 90 +out=$(render 245) +case "$out" in *"42%"*) ok "back online: fetches and renders the fresh percentage" ;; + *) bad "back online: fetches and renders the fresh percentage" "$out" ;; esac +[ ! -f "$FAIL_MARKER" ] && ok "back online: a success clears the backoff marker" \ + || bad "back online: a success clears the backoff marker" +cold_cache +out=$(render 245) +case "$(cat "$CURL_MARKER_SEEN")" in *seen*) ok "back online: a succeeding fetch is marked while it runs" ;; + *) bad "back online: a succeeding fetch is marked while it runs" "$out" ;; esac +[ ! -f "$FAIL_MARKER" ] && ok "back online: and unmarked once it lands" \ + || bad "back online: and unmarked once it lands" "$out" + +# ------------------------------------------------------------ nothing to fetch +# No credentials is not a network failure: it costs no timeout, and the next +# redraw could succeed the instant a login lands. Backing off would hide the +# bars for a minute for nothing. +echo +echo "No credentials is not a failure to back off from:" + +cat > "$STUB/security" <<'SH' +#!/bin/bash +exit 44 +SH +chmod +x "$STUB/security" +NOHOME="$(mktemp -d)" + +cold_cache +out=$(stdin_json "$REPO" \ + | env -u CLAUDE_CODE_OAUTH_TOKEN HOME="$NOHOME" TERM_WIDTH=$(( 245 + OVERHEAD )) \ + bash "$SCRIPT" 2>&1 | sed $'s/\033\[[0-9;]*m//g') +[ "$(calls)" = "0" ] && ok "no token: no fetch attempted" \ + || bad "no token: no fetch attempted" "$out" +[ ! -f "$FAIL_MARKER" ] && ok "no token: no backoff marker written" \ + || bad "no token: no backoff marker written" "$out" + +rm -f "$STUB/security" +rm -rf "$NOHOME" + echo echo " $pass passed, $fail failed" [ "$fail" -eq 0 ]