feat(quota): implement reset grant reading logic with rate limit handling and add tests

This commit is contained in:
Supra4E8C
2026-10-14 18:41:01 +08:00
parent e5c7694b09
commit 1d45bef307
5 changed files with 185 additions and 12 deletions
@@ -5,13 +5,14 @@ import { useAuthStore } from '@/stores/useAuthStore';
import { useNotificationStore } from '@/stores';
import { apiClient } from '@/services/api/client';
import {
readClaudeResetGrants,
AnthropicResetGrantError,
type AnthropicResetGrantStatus,
} from '@/services/api/claudeResetGrants';
import type { AuthFileItem } from '@/types';
import { normalizeAuthIndex } from '@/utils/quota';
import { resetGrantOperations, RETRY_WINDOW_MS } from './resetGrantOperations';
import { selectResetGrant } from './selectResetGrant';
import { readResetGrantsForCard, scheduleResetGrantRead } from './resetGrantReads';
/** Card-owned reads; the session-scoped journal owns spending and ambiguous retries. */
export function useClaudeResetGrants(
@@ -44,19 +45,28 @@ export function useClaudeResetGrants(
let cancelled = false;
const current = () =>
!cancelled && version === generation.current && session === apiClient.getConnectionRevision();
void readClaudeResetGrants(authIndex).then(
(result) => {
if (current()) {
setStatus(result);
setMessage('');
const cancelRead = scheduleResetGrantRead(() => {
void readResetGrantsForCard(authIndex).then(
(result) => {
if (current()) {
setStatus(result);
setMessage('');
}
},
(error: unknown) => {
if (current()) {
setMessage(
error instanceof AnthropicResetGrantError && error.code === 'rate_limited'
? 'rate_limited'
: 'read_error'
);
}
}
},
() => {
if (current()) setMessage('read_error');
}
);
);
}, current);
return () => {
cancelled = true;
cancelRead();
generation.current += 1;
};
}, [authIndex, key, enabled, disabled, sessionActive, session, refreshToken, reload]);
@@ -0,0 +1,45 @@
import { apiClient } from '@/services/api/client';
import { readClaudeResetGrants } from '@/services/api/claudeResetGrants';
export const RESET_GRANT_READ_DELAY_MS = 2000;
/** One delayed attempt per effect activation, never an automatic retry. */
export function scheduleResetGrantRead(start: () => void, current: () => boolean) {
const timeout = setTimeout(() => {
if (current()) start();
}, RESET_GRANT_READ_DELAY_MS);
return () => clearTimeout(timeout);
}
/** Only card reads are shared. Spending still performs its own fresh preflight reads. */
export function createResetGrantReader(
deps = {
revision: () => apiClient.getConnectionRevision(),
read: readClaudeResetGrants,
}
) {
let session = deps.revision();
const inFlight = new Map<string, ReturnType<typeof readClaudeResetGrants>>();
return (authIndex: string) => {
const revision = deps.revision();
if (revision !== session) {
session = revision;
inFlight.clear();
}
const existing = inFlight.get(authIndex);
if (existing) return existing;
const request = deps
.read(authIndex)
.then((result) => {
if (deps.revision() !== revision) throw new Error('session');
return result;
})
.finally(() => {
if (inFlight.get(authIndex) === request) inFlight.delete(authIndex);
});
inFlight.set(authIndex, request);
return request;
};
}
export const readResetGrantsForCard = createResetGrantReader();
+2 -1
View File
@@ -60,7 +60,7 @@ export interface AnthropicResetGrantStatus {
cooldownUntil: string | null;
}
export type AnthropicResetGrantErrorCode = 'auth' | 'upstream' | 'malformed';
export type AnthropicResetGrantErrorCode = 'auth' | 'upstream' | 'malformed' | 'rate_limited';
/** A read failure. `message` is fixed text; upstream detail is never attached. */
export class AnthropicResetGrantError extends Error {
@@ -245,6 +245,7 @@ async function readAccount(authIndex: string, path: string): Promise<Record<stri
},
{ timeout: 12000 }
);
if (response.statusCode === 429) throw new AnthropicResetGrantError('rate_limited');
if (response.statusCode < 200 || response.statusCode >= 300 || !isRecord(response.body)) {
throw new AnthropicResetGrantError('upstream');
}
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from 'bun:test';
import {
createResetGrantReader,
scheduleResetGrantRead,
RESET_GRANT_READ_DELAY_MS,
} from '../src/features/quota/providers/claude/resetGrantReads';
import type { AnthropicResetGrantStatus } from '../src/services/api/claudeResetGrants';
const status: AnthropicResetGrantStatus = {
eligible: false,
ineligibleReason: null,
atLimit: false,
grants: [],
nextGrantId: null,
weeklyResetsAt: null,
cooldownUntil: null,
};
const deferred = () => {
let resolve!: (value: AnthropicResetGrantStatus) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<AnthropicResetGrantStatus>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
};
test('waits 2000ms; cleanup cancels and stale sessions cannot dispatch', async () => {
expect(RESET_GRANT_READ_DELAY_MS).toBe(2000);
let reads = 0;
let session = 1;
const start = () => {
reads += 1;
};
const cancel = scheduleResetGrantRead(start, () => true);
const cancelStale = scheduleResetGrantRead(start, () => session === 1);
const cancelActive = scheduleResetGrantRead(start, () => true);
try {
expect(reads).toBe(0);
cancel(); // Effect cleanup: refresh, unmount, or connection change.
session = 2; // Also guard a connection change before React cleanup runs.
await Bun.sleep(1900);
expect(reads).toBe(0);
await Bun.sleep(200);
expect(reads).toBe(1);
await Bun.sleep(100);
expect(reads).toBe(1);
} finally {
cancel();
cancelStale();
cancelActive();
}
});
test('shares concurrent credential reads only within a session and never caches results', async () => {
let revision = 1;
const requests: ReturnType<typeof deferred>[] = [];
const read = createResetGrantReader({
revision: () => revision,
read: () => {
const request = deferred();
requests.push(request);
return request.promise;
},
});
const old = read('a');
expect(read('a') === old).toBe(true);
const other = read('b');
expect(requests).toHaveLength(2);
revision = 2;
const fresh = read('a');
expect(fresh === old).toBe(false);
const staleResult = old.catch((error: unknown) => error);
requests[0].resolve(status);
expect(await staleResult).toMatchObject({ message: 'session' });
expect(read('a') === fresh).toBe(true); // Old cleanup must not delete the new session read.
const otherResult = other.catch((error: unknown) => error);
requests[1].resolve(status);
expect(await otherResult).toMatchObject({ message: 'session' });
requests[2].resolve(status);
expect(await fresh).toBe(status);
const next = read('a');
expect(requests).toHaveLength(4);
requests[3].resolve(status);
await next;
});
test('failed reads are released without automatic retry', async () => {
let calls = 0;
const read = createResetGrantReader({
revision: () => 1,
read: async () => {
calls += 1;
throw new Error('rate limited');
},
});
await expect(read('a')).rejects.toThrow('rate limited');
expect(calls).toBe(1);
await expect(read('a')).rejects.toThrow('rate limited');
expect(calls).toBe(2);
});
+16
View File
@@ -352,3 +352,19 @@ test('Claude card uses Codex count and action styles and shared confirmation, no
expect(hook).not.toContain('<Modal');
expect(hook).not.toContain('status.grants.map');
});
test('account reads map upstream 429 to a sanitized typed rate-limit failure', async () => {
let calls = 0;
apiCallApi.request = async () => {
calls += 1;
return { statusCode: 429, body: 'private upstream detail', bodyText: '', header: {} };
};
for (const read of [readClaudeResetGrants, readClaudeOrganization]) {
await expect(read('index')).rejects.toMatchObject({
name: 'AnthropicResetGrantError',
code: 'rate_limited',
message: 'Anthropic reset-grant read failed (rate_limited)',
});
}
expect(calls).toBe(2); // Neither read retries.
});