fix(FN-673): remove beta header and add retry logic for Claude usage

- Remove outdated beta header from dashboard Header component
- Add retry logic with exponential backoff for Claude API rate limits
- Differentiate between rate limit errors and other Claude usage errors
- Add comprehensive tests for retry logic and error handling
- Add changeset for the fix
This commit is contained in:
gsxdsm
2026-04-01 07:10:57 -07:00
parent 800146550e
commit 70d047fe27
3 changed files with 450 additions and 19 deletions

View File

@@ -228,6 +228,26 @@ function readClaudeKeychainCredentials(): any | null {
}
}
/** Max number of retries for transient 429 responses */
const CLAUDE_MAX_RETRIES = 3;
/** Initial retry delay in ms (doubles each attempt) */
const CLAUDE_INITIAL_RETRY_MS = 1000;
/**
* Sleep for the given duration. Exported for test mocking.
*/
export const _sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
// Allow tests to swap the sleep implementation
let sleepFn = _sleep;
export function _setSleepFn(fn: typeof _sleep): void {
sleepFn = fn;
}
export function _resetSleepFn(): void {
sleepFn = _sleep;
}
async function fetchClaudeUsage(): Promise<ProviderUsage> {
const usage: ProviderUsage = {
name: "Claude",
@@ -280,29 +300,61 @@ async function fetchClaudeUsage(): Promise<ProviderUsage> {
}
try {
const res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
authorization: `Bearer ${oauthCreds.accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
},
});
// Retry loop for transient 429 responses
let res: { status: number; headers: Record<string, string>; body: string } | undefined;
let lastStatus = 0;
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — run 'claude' to re-login";
return usage;
for (let attempt = 0; attempt < CLAUDE_MAX_RETRIES; attempt++) {
res = await httpsRequest("https://api.anthropic.com/api/oauth/usage", {
method: "GET",
headers: {
authorization: `Bearer ${oauthCreds.accessToken}`,
},
});
lastStatus = res.status;
// Auth errors are not transient — fail immediately
if (res.status === 401 || res.status === 403) {
usage.status = "error";
usage.error = "Auth expired — run 'claude' to re-login";
return usage;
}
// 429 is potentially transient — retry with exponential backoff
if (res.status === 429) {
if (attempt < CLAUDE_MAX_RETRIES - 1) {
// Use retry-after header if available, otherwise exponential backoff
const retryAfter = res.headers["retry-after"];
let delayMs: number;
if (retryAfter && !isNaN(Number(retryAfter))) {
delayMs = Number(retryAfter) * 1000;
} else {
delayMs = CLAUDE_INITIAL_RETRY_MS * Math.pow(2, attempt);
}
await sleepFn(delayMs);
continue;
}
// All retries exhausted
usage.status = "error";
usage.error = "Rate limited — try again later";
return usage;
}
// Any other non-200 status — fail immediately (not transient)
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}`;
return usage;
}
// Success — break out of retry loop
break;
}
if (res.status === 429) {
if (!res || lastStatus !== 200) {
usage.status = "error";
usage.error = "Rate limited — try again later";
return usage;
}
if (res.status !== 200) {
usage.status = "error";
usage.error = `HTTP ${res.status}`;
usage.error = `HTTP ${lastStatus}`;
return usage;
}