From 35e170b40c6a1b65810e36862ce21c1f6dc51892 Mon Sep 17 00:00:00 2001 From: Jonny Barnes Date: Wed, 2 Sep 2026 13:12:45 +0100 Subject: [PATCH] Back off after a failed usage fetch instead of retrying every redraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed fetch left the cache mtime untouched, so with no network every redraw — and a redraw happens on every keystroke — tried curl again and could wait --max-time 10 for it. Cutting the TTL from an hour to five minutes made that start 55 minutes sooner, so it is worth fixing now. The marker is a separate file rather than a touch of the cached response: a cold /tmp has no response to touch, and that is exactly the case with no stale data to fall back on, where the timeout is paid in full with nothing to show for it. It goes down before the request and is cleared when that lands, not written afterwards. A fetch is in flight for as long as curl takes to give up, and this cache dir is shared by every session, so redraws starting inside that window are real rather than hypothetical: they now serve stale data instead of each launching their own doomed request. Every attempt writes it, so a repeated failure restarts the backoff, and only an attempt does, so the redraws it suppresses cannot keep re-stamping it and it always lapses. Missing credentials stays outside the backoff — it costs no timeout, and the redraw after a login should show the bars rather than wait out a retry window it had no part in earning. Co-Authored-By: Claude Opus 5 (1M context) --- claude/statusline.isaacaudet.sh | 60 ++++++--- .../tests/statusline.isaacaudet.cache_test.sh | 119 +++++++++++++++++- 2 files changed, 163 insertions(+), 16 deletions(-) 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 ]