Skip to content

Commit 469f08f

Browse files
lroolleclaude
andcommitted
feat: v0.16.0 — atomic fetch locks (multi-instance 429 stampede)
Launching N Claude Code instances together produced 429 bursts: each CLI fires 2 usage requests of its own on boot, and the statusline's check-then-touch lock — with the touch landing hundreds of ms after the check, behind a slow token read — let every instance through to fetch concurrently on top of that. acquire_lock makes acquisition atomic (noclobber create) with stale- lock reaping, and it happens BEFORE the token read. Applied to usage, prepaid, and OAuth refresh locks — the last one matters most: with rotating refresh tokens, two concurrent refreshes can invalidate each other. Regression test: 8 concurrent fetches with a slow token read make exactly one API call (fails on the old code). Also: docs/api/oauth-usage.md — observed /api/oauth/usage contract synced with Claude Code v2.1.201, captured via local mitm proxy, sanitized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e97e50e commit 469f08f

6 files changed

Lines changed: 338 additions & 28 deletions

File tree

CHANGELOG.md

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

3+
## v0.16.0 — 2026-07-06 — atomic fetch locks (multi-instance 429 stampede)
4+
5+
Launching several Claude Code instances together produced 429 bursts. Two
6+
compounding causes, confirmed against a mitm capture of CLI v2.1.201:
7+
8+
1. **Each CLI instance already fires 2 usage requests on boot** (two internal
9+
clients: `claude-cli/... (external, cli)` and `claude-code/...`). N
10+
instances = 2N requests before any statusline fetch.
11+
2. **The statusline's lock was check-then-touch, not atomic** — and the
12+
`touch` happened *after* `get_oauth_token`, hundreds of ms after the
13+
check. Every freshly launched instance passed the check inside that
14+
window and fetched concurrently, adding up to N more requests to the
15+
same burst.
16+
17+
The statusline can only fix its own share:
18+
19+
- New `acquire_lock`: noclobber (`set -C`) create — atomic acquire-or-fail,
20+
with stale-lock reaping (orphaned locks from crashed fetchers).
21+
- Lock is acquired **before** the token read in `fetch_usage_for_session`
22+
and `fetch_prepaid_balance`; released on every early-return path.
23+
- `refresh_oauth_credentials_file` uses it too: two concurrent OAuth
24+
refreshes are worse than a missed one — with rotating refresh tokens the
25+
loser's grant can invalidate the winner's.
26+
- Regression test: 8 concurrent fetches with a deliberately slow token read
27+
make exactly 1 API call (fails on the old code). Plus a 20-contender
28+
single-winner lock test.
29+
30+
New: `docs/api/oauth-usage.md` — the observed `/api/oauth/usage` wire
31+
contract (request headers, both CLI client forms, full response schema,
32+
`limits[]` semantics, 429 obligations), synced with Claude Code v2.1.201.
33+
Sanitized; no credentials or account identifiers.
34+
35+
5 new tests (254 total).
36+
337
## v0.15.1 — 2026-07-05 — scoped quota badge moves next to the model
438

539
`fb[67%]` now renders right after the model+context block instead of at the

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ red, matching its Claude Code TUI color) = model family; everything else is
109109
| OAuth + macOS Keychain | -- | Yes |
110110
| Quota bump flash (+N) | -- | Yes |
111111
| Model-scoped weekly quota (`fb`/`op`/`sn`) | -- | Yes |
112-
| 249 bats tests + CI | -- | Yes |
112+
| 254 bats tests + CI | -- | Yes |
113113

114114
## Configuration
115115

@@ -282,7 +282,7 @@ run. Setting only `CLAUDE_CACHE_DIR` keeps the legacy single-dir behavior.
282282
npm exec --yes bats -- t/
283283
```
284284

285-
249 tests across `t/statusline.bats` (237 statusline + integration) and
285+
254 tests across `t/statusline.bats` (242 statusline + integration) and
286286
`t/install.bats` (12 installer). CI runs on push and PR to `main`.
287287

288288
## Project Structure
@@ -298,6 +298,7 @@ t/helpers.bash Sources real functions from statusline.sh
298298
CHANGELOG.md Release notes
299299
CONTRIBUTING.md Contribution guide
300300
docs/devlog/ Implementation history
301+
docs/api/oauth-usage.md Observed /api/oauth/usage contract (synced: CLI v2.1.201)
301302
```
302303

303304
## Contributing

docs/api/oauth-usage.md

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# `/api/oauth/usage` — HTTP contract
2+
3+
The endpoint this statusline polls for 5h / 7d / per-model quota. This file
4+
documents the observed wire contract so parser changes can be checked against
5+
ground truth instead of guesswork.
6+
7+
- Synced with: **Claude Code v2.1.201** (captured 2026-07-06 via a local
8+
mitm reverse proxy in front of the official CLI)
9+
- All credentials, org IDs, and request IDs below are redacted or fabricated.
10+
- Fields marked *reserved* were observed only as `null`; names are as sent by
11+
the server.
12+
13+
## Request
14+
15+
```
16+
GET https://api.anthropic.com/api/oauth/usage
17+
```
18+
19+
| Header | Value | Notes |
20+
|--------|-------|-------|
21+
| `Authorization` | `Bearer <oauth access token>` | The `claudeAiOauth.accessToken` from Claude Code credentials |
22+
| `Content-Type` | `application/json` | |
23+
| `User-Agent` | see below | |
24+
| `anthropic-beta` | `oauth-2025-04-20` | sent by one of the CLI's two internal clients, not required |
25+
26+
No query parameters. No request body.
27+
28+
### The CLI itself calls this twice per launch
29+
30+
One `claude` v2.1.201 session was observed issuing **two** usage requests on
31+
boot, from two distinct internal clients:
32+
33+
```
34+
user-agent: claude-cli/2.1.201 (external, cli) + anthropic-beta: oauth-2025-04-20
35+
user-agent: claude-code/2.1.201
36+
```
37+
38+
Both received 200 with identical schema. This matters for rate-limit math:
39+
launching N Claude Code instances produces 2N usage requests from the CLIs
40+
alone before any statusline fetch happens. The statusline mimics the second
41+
form (`claude-code/<version>`) and adds at most one request per account per
42+
TTL window (shared cache + atomic lock, see below).
43+
44+
## Response — 200
45+
46+
`Content-Type: application/json`. Notable response headers:
47+
`anthropic-organization-id: <org uuid>` (redacted), `request-id: req_...`.
48+
49+
```json
50+
{
51+
"five_hour": {
52+
"utilization": 96.0,
53+
"resets_at": "2026-07-06T10:59:59.800387+00:00",
54+
"limit_dollars": null,
55+
"used_dollars": null,
56+
"remaining_dollars": null
57+
},
58+
"seven_day": {
59+
"utilization": 65.0,
60+
"resets_at": "2026-07-08T15:59:59.800406+00:00",
61+
"limit_dollars": null,
62+
"used_dollars": null,
63+
"remaining_dollars": null
64+
},
65+
"limits": [
66+
{
67+
"kind": "session",
68+
"group": "session",
69+
"percent": 96,
70+
"severity": "critical",
71+
"resets_at": "2026-07-06T10:59:59.800387+00:00",
72+
"scope": null,
73+
"is_active": true
74+
},
75+
{
76+
"kind": "weekly_all",
77+
"group": "weekly",
78+
"percent": 65,
79+
"severity": "normal",
80+
"resets_at": "2026-07-08T15:59:59.800406+00:00",
81+
"scope": null,
82+
"is_active": false
83+
},
84+
{
85+
"kind": "weekly_scoped",
86+
"group": "weekly",
87+
"percent": 83,
88+
"severity": "warning",
89+
"resets_at": "2026-07-08T15:59:59.800706+00:00",
90+
"scope": {
91+
"model": { "id": null, "display_name": "Fable" },
92+
"surface": null
93+
},
94+
"is_active": false
95+
}
96+
],
97+
"extra_usage": {
98+
"is_enabled": false,
99+
"monthly_limit": 20000,
100+
"used_credits": 0.0,
101+
"utilization": 0.0,
102+
"currency": "USD",
103+
"decimal_places": 2,
104+
"disabled_reason": "out_of_credits",
105+
"daily": null,
106+
"weekly": null
107+
},
108+
"spend": {
109+
"used": { "amount_minor": 0, "currency": "USD", "exponent": 2 },
110+
"limit": { "amount_minor": 20000, "currency": "USD", "exponent": 2 },
111+
"percent": 0,
112+
"severity": "normal",
113+
"enabled": false,
114+
"disabled_reason": "out_of_credits",
115+
"cap": { "money": null, "credits": { "amount_minor": 20000, "exponent": 2 } },
116+
"balance": null,
117+
"auto_reload": null,
118+
"disclaimer": "Usage credits cover you when you hit your plan limits. [...]",
119+
"can_purchase_credits": false,
120+
"can_toggle": false
121+
},
122+
"member_dashboard_available": false,
123+
124+
"seven_day_opus": null,
125+
"seven_day_sonnet": null,
126+
"seven_day_oauth_apps": null,
127+
"seven_day_cowork": null,
128+
"seven_day_omelette": null,
129+
"omelette_promotional": null,
130+
"tangelo": null,
131+
"iguana_necktie": null,
132+
"nimbus_quill": null,
133+
"cinder_cove": null,
134+
"amber_ladder": null
135+
}
136+
```
137+
138+
### Field notes
139+
140+
**`five_hour` / `seven_day`** — the two account-wide windows. `utilization`
141+
is a float percentage; `resets_at` is ISO-8601 with microseconds and offset.
142+
The `*_dollars` fields have only been observed `null` on subscription plans.
143+
144+
**`limits[]`** — the newer, generic limit contract (this is what the
145+
statusline parses first):
146+
147+
| Field | Values observed | Meaning |
148+
|-------|-----------------|---------|
149+
| `kind` | `session`, `weekly_all`, `weekly_scoped` | which limit |
150+
| `group` | `session`, `weekly` | window family |
151+
| `percent` | integer | utilization |
152+
| `severity` | `normal`, `warning`, `critical` | server-side level |
153+
| `resets_at` | ISO-8601 | window end |
154+
| `scope` | `null` or `{model:{id,display_name},surface}` | `weekly_scoped` carries the model, e.g. `display_name: "Fable"`; `id` observed `null` |
155+
| `is_active` | bool | whether this is the currently binding limit |
156+
157+
A `weekly_scoped` limit shares its `resets_at` with `weekly_all` — it is the
158+
same 7-day window, scoped to one model.
159+
160+
**Legacy per-model fields**`seven_day_opus` / `seven_day_sonnet` used to
161+
carry `{utilization, resets_at}`; since the `limits[]` array appeared they
162+
arrive `null`. Parsers should prefer `limits[]` and keep the old fields only
163+
as a fallback for cached data.
164+
165+
**Reserved fields**`tangelo`, `iguana_necktie`, `omelette_promotional`,
166+
`nimbus_quill`, `cinder_cove`, `amber_ladder`, `seven_day_oauth_apps`,
167+
`seven_day_cowork`, `seven_day_omelette`: observed only as `null`. Do not
168+
depend on them; listed so a future non-null appearance is recognized as a
169+
contract change.
170+
171+
## Response — 429 (rate limited)
172+
173+
Not present in the proxy capture (the CLI wasn't throttled that run), but
174+
observed regularly by this statusline's own fetches when several instances
175+
run: plain 429 with a `Retry-After: <seconds>` header (value `300` observed).
176+
177+
Client obligations, as implemented here:
178+
179+
- Serve the cached response while throttled; never retry inside the
180+
`Retry-After` window.
181+
- Escalate the retry gap on consecutive failures (this repo: 120s → 240s →
182+
480s → 600s cap, `Retry-After` wins when longer).
183+
- Coordinate across processes: one shared cache + one atomic lock per
184+
account dir, so N concurrent statusline renders produce at most one fetch.
185+
186+
## Related endpoints used by this statusline (not in this capture)
187+
188+
| Endpoint | Purpose |
189+
|----------|---------|
190+
| `GET /api/oauth/profile` | account display name, org UUID, tier |
191+
| `GET /api/oauth/organizations/<org_uuid>/prepaid/credits` | prepaid balance for the extra-usage badge (requires `x-organization-uuid` header) |

statusline.sh

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -407,12 +407,9 @@ refresh_oauth_credentials_file() {
407407

408408
mkdir -p "$CLAUDE_ACCOUNT_DIR"
409409
local lock_file="$CLAUDE_ACCOUNT_DIR/oauth_refresh.lock"
410-
if [ -f "$lock_file" ]; then
411-
local lock_age=$(($(date +%s) - $(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || echo 0)))
412-
[ "$lock_age" -lt 30 ] && return 1
413-
rm -f "$lock_file"
414-
fi
415-
touch "$lock_file"
410+
# Atomic: two concurrent refreshes are worse than a missed one — with
411+
# rotating refresh tokens the loser's grant can invalidate the winner's.
412+
acquire_lock "$lock_file" 30 || return 1
416413

417414
scope_string=$(jq -r '.claudeAiOauth.scopes // [] | join(" ")' "$cred_file" 2>/dev/null)
418415
if [ -z "$scope_string" ]; then
@@ -677,25 +674,25 @@ fetch_usage_for_session() {
677674
return 0
678675
fi
679676

680-
if [ -f "$lock_file" ]; then
681-
local lock_age=$(($(date +%s) - $(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || echo 0)))
682-
if [ $lock_age -lt 10 ]; then
683-
debug_log "fetch_usage_for_session: lock active, skip"
684-
[ -f "$cache_file" ] && cat "$cache_file"
685-
return 0
686-
fi
687-
rm -f "$lock_file"
677+
# Acquire the lock BEFORE the token read: get_oauth_token can take
678+
# hundreds of ms (file reads, possibly a refresh round-trip), and N
679+
# instances launched together all sat in that gap under the old
680+
# check-then-touch, then fetched concurrently — the main source of 429
681+
# bursts (each CLI already fires 2 usage requests of its own on boot).
682+
if ! acquire_lock "$lock_file" 10; then
683+
debug_log "fetch_usage_for_session: lock contended, skip"
684+
[ -f "$cache_file" ] && cat "$cache_file"
685+
return 0
688686
fi
689687

690688
local token=$(get_oauth_token)
691689
if [ -z "$token" ]; then
692690
debug_log "fetch_usage_for_session: no token available"
691+
rm -f "$lock_file"
693692
[ -f "$cache_file" ] && cat "$cache_file"
694693
return 1
695694
fi
696695

697-
touch "$lock_file"
698-
699696
# Use CLI version from input for User-Agent, fall back to generic
700697
local ua="claude-code/${cli_version:-2.1.170}"
701698

@@ -775,24 +772,20 @@ fetch_prepaid_balance() {
775772
return 0
776773
fi
777774

778-
if [ -f "$lock_file" ]; then
779-
local lock_age=$(($(date +%s) - $(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || echo 0)))
780-
if [ $lock_age -lt 10 ]; then
781-
[ -f "$cache_file" ] && cat "$cache_file"
782-
return 0
783-
fi
784-
rm -f "$lock_file"
775+
# Same atomic-acquire-before-token-read as fetch_usage_for_session.
776+
if ! acquire_lock "$lock_file" 10; then
777+
[ -f "$cache_file" ] && cat "$cache_file"
778+
return 0
785779
fi
786780

787781
local token=$(get_oauth_token)
788782
if [ -z "$token" ]; then
789783
debug_log "fetch_prepaid_balance: no token available"
784+
rm -f "$lock_file"
790785
[ -f "$cache_file" ] && cat "$cache_file"
791786
return 1
792787
fi
793788

794-
touch "$lock_file"
795-
796789
local ua="claude-code/${cli_version:-2.1.170}"
797790
local response=$(curl -s -w "\n%{http_code} %header{retry-after}" -X GET \
798791
"https://api.anthropic.com/api/oauth/organizations/${org_uuid}/prepaid/credits" \
@@ -1240,6 +1233,29 @@ get_adaptive_ttl() {
12401233
fi
12411234
}
12421235

1236+
# Atomically acquire a lock file, or return 1 if another process holds a
1237+
# fresh one. noclobber (set -C) makes create-if-absent a single atomic step —
1238+
# the old check-then-touch pattern left a window (hundreds of ms when a slow
1239+
# token read sat between check and touch) where N freshly launched instances
1240+
# all passed the check and stampeded the API with concurrent fetches.
1241+
# A lock older than $2 is presumed orphaned (fetcher died), reaped, and
1242+
# re-contended once; losing that race just skips this cycle — the winner is
1243+
# fetching anyway.
1244+
acquire_lock() {
1245+
local lock_file="$1" max_age="${2:-10}"
1246+
if (set -C; : >"$lock_file") 2>/dev/null; then
1247+
return 0
1248+
fi
1249+
local mtime age
1250+
mtime=$(stat -c %Y "$lock_file" 2>/dev/null || stat -f %m "$lock_file" 2>/dev/null || echo 0)
1251+
age=$(($(date +%s) - mtime))
1252+
if [ "$age" -ge "$max_age" ] 2>/dev/null; then
1253+
rm -f "$lock_file"
1254+
(set -C; : >"$lock_file") 2>/dev/null && return 0
1255+
fi
1256+
return 1
1257+
}
1258+
12431259
# Remove a lock left behind by a fetch that died before cleanup. Without this,
12441260
# a single orphaned *.lock permanently freezes the cache (the fetch gate skips
12451261
# launching while the lock exists, and never reaps it). $2 = max age seconds.

t/helpers.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ debug_log() {
3939
# Source individual functions by extracting them from statusline.sh.
4040
# This is deliberate: we test the actual production code, not copies.
4141
eval "$(awk '
42-
/^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|format_cache_active_time|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|reap_stale_lock|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier)\(\)/ { capture=1 }
42+
/^(abbreviate_model_id|get_runtime_model|format_reset_relative|format_reset_absolute|get_reset_seconds|format_duration|should_show_extra|get_cache_health|infer_cache_ttl_class|format_cache_active_time|build_cache_indicator|get_usage_color|get_seven_day_color|seven_day_elapsed|seven_day_pace|weekend_secs_ahead|get_adaptive_ttl|acquire_lock|reap_stale_lock|fetch_usage_for_session|merge_stdin_rate_limits|rotate_usage_log|build_seven_day_profile|seven_day_forecast|premium_band_level|abbrev_effort|effort_color|_epoch_from_ts|_fmt_epoch|render_bar|format_money_minor|oauth_token_expired|refresh_oauth_credentials_file|is_default_1m_family|get_context_limit|is_1m_model|rotate_debug_log|build_display_path|quota_bump_notice|record_fetch_error|fetch_error_remaining|fetch_error_badge|model_scope_abbrev|build_scoped_quota_display|build_usage_display|build_extra_usage_display|build_user_info|get_user_tier)\(\)/ { capture=1 }
4343
capture { print }
4444
capture && /^}$/ { capture=0 }
4545
' "$SCRIPT_DIR/statusline.sh")"

0 commit comments

Comments
 (0)