From 1d45bef307edf14bb7b915aef19def0e50f21090 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Wed, 14 Oct 2026 18:41:01 +0800 Subject: [PATCH] feat(quota): implement reset grant reading logic with rate limit handling and add tests --- .../providers/claude/ClaudeResetGrants.tsx | 32 ++++-- .../quota/providers/claude/resetGrantReads.ts | 45 ++++++++ src/services/api/claudeResetGrants.ts | 3 +- tests/claudeResetGrantReads.test.ts | 101 ++++++++++++++++++ tests/claudeResetGrants.test.ts | 16 +++ 5 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 src/features/quota/providers/claude/resetGrantReads.ts create mode 100644 tests/claudeResetGrantReads.test.ts diff --git a/src/features/quota/providers/claude/ClaudeResetGrants.tsx b/src/features/quota/providers/claude/ClaudeResetGrants.tsx index 651fdebe..580b1177 100644 --- a/src/features/quota/providers/claude/ClaudeResetGrants.tsx +++ b/src/features/quota/providers/claude/ClaudeResetGrants.tsx @@ -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]); diff --git a/src/features/quota/providers/claude/resetGrantReads.ts b/src/features/quota/providers/claude/resetGrantReads.ts new file mode 100644 index 00000000..cba6656b --- /dev/null +++ b/src/features/quota/providers/claude/resetGrantReads.ts @@ -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>(); + 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(); diff --git a/src/services/api/claudeResetGrants.ts b/src/services/api/claudeResetGrants.ts index 0499c26f..8ab5187c 100644 --- a/src/services/api/claudeResetGrants.ts +++ b/src/services/api/claudeResetGrants.ts @@ -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= 300 || !isRecord(response.body)) { throw new AnthropicResetGrantError('upstream'); } diff --git a/tests/claudeResetGrantReads.test.ts b/tests/claudeResetGrantReads.test.ts new file mode 100644 index 00000000..48e3d2f3 --- /dev/null +++ b/tests/claudeResetGrantReads.test.ts @@ -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((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[] = []; + 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); +}); diff --git a/tests/claudeResetGrants.test.ts b/tests/claudeResetGrants.test.ts index 2baf0502..5024197a 100644 --- a/tests/claudeResetGrants.test.ts +++ b/tests/claudeResetGrants.test.ts @@ -352,3 +352,19 @@ test('Claude card uses Codex count and action styles and shared confirmation, no expect(hook).not.toContain(' { + 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. +});