mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-10-16 14:57:05 +08:00
feat(quota): implement reset grant reading logic with rate limit handling and add tests
This commit is contained in:
@@ -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();
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user