@cortexkit/anthropic-auth-core 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accounts.d.ts +39 -1
- package/dist/accounts.js +141 -22
- package/dist/cachekeep.d.ts +1 -0
- package/dist/cachekeep.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/quota-manager.d.ts +116 -0
- package/dist/quota-manager.js +432 -0
- package/dist/relay.js +7 -1
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -53,8 +53,13 @@ export type AccountStorage = {
|
|
|
53
53
|
quota?: {
|
|
54
54
|
enabled?: boolean;
|
|
55
55
|
checkIntervalMinutes?: number;
|
|
56
|
+
refreshEveryNRequests?: number;
|
|
56
57
|
minimumRemaining?: Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
|
|
57
58
|
failClosedOnUnknownQuota?: boolean;
|
|
59
|
+
mainQuota?: OAuthQuotaSnapshot;
|
|
60
|
+
mainQuotaCheckedAt?: number;
|
|
61
|
+
mainQuotaToken?: string;
|
|
62
|
+
mainLastQuotaApiError?: AccountOperationError;
|
|
58
63
|
};
|
|
59
64
|
claudeCache?: {
|
|
60
65
|
enabled?: boolean;
|
|
@@ -85,6 +90,7 @@ export type AccountManagerOptions = {
|
|
|
85
90
|
now?: () => number;
|
|
86
91
|
fetchImpl?: typeof fetch;
|
|
87
92
|
configPath?: string;
|
|
93
|
+
quotaManager?: import('./quota-manager.ts').QuotaManager;
|
|
88
94
|
};
|
|
89
95
|
export type AccountRefreshError = {
|
|
90
96
|
accountId: string;
|
|
@@ -124,7 +130,24 @@ export declare function buildRefreshOperationError(input: {
|
|
|
124
130
|
}): AccountOperationError;
|
|
125
131
|
export declare function refreshBackoffActive(error: AccountOperationError | undefined, refreshToken: string | undefined, now: number): boolean;
|
|
126
132
|
export declare function formatRefreshBackoffMessage(error: AccountOperationError, now: number): string;
|
|
133
|
+
export declare function buildQuotaOperationError(input: {
|
|
134
|
+
error: unknown;
|
|
135
|
+
now: number;
|
|
136
|
+
previous?: AccountOperationError;
|
|
137
|
+
}): AccountOperationError;
|
|
138
|
+
export declare function quotaBackoffActive(error: AccountOperationError | undefined, now: number): boolean;
|
|
139
|
+
export declare function formatQuotaBackoffMessage(error: AccountOperationError, now: number): string;
|
|
127
140
|
export declare function getQuotaCheckIntervalMs(storage: AccountStorage | null): number;
|
|
141
|
+
export declare function getPersistedMainQuota(storage: AccountStorage | null): {
|
|
142
|
+
quota: OAuthQuotaSnapshot;
|
|
143
|
+
checkedAt: number;
|
|
144
|
+
tokenFingerprint?: string;
|
|
145
|
+
} | null;
|
|
146
|
+
/**
|
|
147
|
+
* How often (in requests) to force a quota refresh, independent of the timer.
|
|
148
|
+
* Returns 0 when disabled (default).
|
|
149
|
+
*/
|
|
150
|
+
export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number;
|
|
128
151
|
export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
|
|
129
152
|
export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number;
|
|
130
153
|
export declare function fetchOAuthQuotaSnapshot(input: {
|
|
@@ -142,7 +165,13 @@ export declare class FallbackAccountManager {
|
|
|
142
165
|
private readonly refreshPromises;
|
|
143
166
|
private refreshTimer;
|
|
144
167
|
private quotaTimer;
|
|
168
|
+
readonly quotaManager: import('./quota-manager.ts').QuotaManager | null;
|
|
145
169
|
constructor(options?: AccountManagerOptions);
|
|
170
|
+
/**
|
|
171
|
+
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
172
|
+
* yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
|
|
173
|
+
*/
|
|
174
|
+
private seedFallbackQuota;
|
|
146
175
|
load(): Promise<AccountStorage | null>;
|
|
147
176
|
save(storage: AccountStorage): Promise<void>;
|
|
148
177
|
startBackgroundRefresh(): void;
|
|
@@ -150,9 +179,18 @@ export declare class FallbackAccountManager {
|
|
|
150
179
|
getUsableFallbackAccounts(existingStorage?: AccountStorage | null): Promise<OAuthAccount[]>;
|
|
151
180
|
markUsed(account: OAuthAccount): Promise<void>;
|
|
152
181
|
accountPassesQuotaPolicy(account: OAuthAccount, storage: AccountStorage | null): boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Return the account with its quota overlaid from the unified QuotaManager
|
|
184
|
+
* cache (token-bound) when available, so quota-policy decisions use the same
|
|
185
|
+
* source of truth as the staleness check. Falls back to the stored
|
|
186
|
+
* account.quota when no manager is wired or the cache has no entry.
|
|
187
|
+
*/
|
|
188
|
+
private quotaPolicyAccount;
|
|
153
189
|
refreshDueAccounts(): Promise<void>;
|
|
154
190
|
refreshQuotaForDueAccounts(): Promise<void>;
|
|
155
|
-
refreshQuotaForAllAccounts(
|
|
191
|
+
refreshQuotaForAllAccounts(options?: {
|
|
192
|
+
force?: boolean;
|
|
193
|
+
}): Promise<{
|
|
156
194
|
storage: AccountStorage | null;
|
|
157
195
|
errors: AccountRefreshError[];
|
|
158
196
|
}>;
|
package/dist/accounts.js
CHANGED
|
@@ -16,6 +16,9 @@ const DEFAULT_REFRESH_INTERVAL_MINUTES = 10;
|
|
|
16
16
|
const MIN_REFRESH_RETRY_DELAY_MS = 5 * 60_000;
|
|
17
17
|
const MAX_REFRESH_RETRY_DELAY_MS = 60 * 60_000;
|
|
18
18
|
const NON_TRANSIENT_REFRESH_RETRY_DELAY_MS = 24 * 60 * 60_000;
|
|
19
|
+
const MIN_QUOTA_RETRY_DELAY_MS = 60_000;
|
|
20
|
+
const MAX_QUOTA_RETRY_DELAY_MS = 15 * 60_000;
|
|
21
|
+
const NON_TRANSIENT_QUOTA_RETRY_DELAY_MS = 5 * 60_000;
|
|
19
22
|
const DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES = 5;
|
|
20
23
|
const DEFAULT_MINIMUM_REMAINING = {
|
|
21
24
|
five_hour: 0,
|
|
@@ -452,10 +455,51 @@ export function formatRefreshBackoffMessage(error, now) {
|
|
|
452
455
|
const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
|
|
453
456
|
return `Claude OAuth refresh is backed off for ${seconds}s after: ${error.message}`;
|
|
454
457
|
}
|
|
458
|
+
export function buildQuotaOperationError(input) {
|
|
459
|
+
const previousRetryCount = input.previous?.retryCount ?? 0;
|
|
460
|
+
const retryCount = previousRetryCount + 1;
|
|
461
|
+
const delay = isTransientQuotaError(input.error)
|
|
462
|
+
? Math.min(MAX_QUOTA_RETRY_DELAY_MS, MIN_QUOTA_RETRY_DELAY_MS * 2 ** Math.min(retryCount - 1, 6))
|
|
463
|
+
: NON_TRANSIENT_QUOTA_RETRY_DELAY_MS;
|
|
464
|
+
return {
|
|
465
|
+
message: formatErrorMessage(input.error),
|
|
466
|
+
checkedAt: input.now,
|
|
467
|
+
nextRetryAt: input.now + delay,
|
|
468
|
+
retryCount,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
export function quotaBackoffActive(error, now) {
|
|
472
|
+
if (!error?.nextRetryAt || error.nextRetryAt <= now)
|
|
473
|
+
return false;
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
export function formatQuotaBackoffMessage(error, now) {
|
|
477
|
+
const seconds = Math.max(1, Math.ceil(((error.nextRetryAt ?? now) - now) / 1000));
|
|
478
|
+
return `Quota API backed off for ${seconds}s after: ${error.message}`;
|
|
479
|
+
}
|
|
455
480
|
export function getQuotaCheckIntervalMs(storage) {
|
|
456
481
|
const minutes = storage?.quota?.checkIntervalMinutes ?? DEFAULT_QUOTA_CHECK_INTERVAL_MINUTES;
|
|
457
482
|
return Math.max(1, minutes) * 60_000;
|
|
458
483
|
}
|
|
484
|
+
export function getPersistedMainQuota(storage) {
|
|
485
|
+
if (!storage?.quota?.mainQuota || !storage.quota.mainQuotaCheckedAt)
|
|
486
|
+
return null;
|
|
487
|
+
return {
|
|
488
|
+
quota: storage.quota.mainQuota,
|
|
489
|
+
checkedAt: storage.quota.mainQuotaCheckedAt,
|
|
490
|
+
tokenFingerprint: storage.quota.mainQuotaToken,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* How often (in requests) to force a quota refresh, independent of the timer.
|
|
495
|
+
* Returns 0 when disabled (default).
|
|
496
|
+
*/
|
|
497
|
+
export function getQuotaRefreshEveryNRequests(storage) {
|
|
498
|
+
const n = storage?.quota?.refreshEveryNRequests;
|
|
499
|
+
return typeof n === 'number' && Number.isFinite(n) && n > 0
|
|
500
|
+
? Math.floor(n)
|
|
501
|
+
: 0;
|
|
502
|
+
}
|
|
459
503
|
function failClosedOnUnknownQuota(storage) {
|
|
460
504
|
return (storage?.quota?.failClosedOnUnknownQuota ??
|
|
461
505
|
DEFAULT_FAIL_CLOSED_ON_UNKNOWN_QUOTA);
|
|
@@ -597,10 +641,11 @@ function recordRefreshError(account, error, now) {
|
|
|
597
641
|
});
|
|
598
642
|
}
|
|
599
643
|
function recordQuotaRefreshError(account, error, now) {
|
|
600
|
-
account.lastQuotaRefreshError = {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
644
|
+
account.lastQuotaRefreshError = buildQuotaOperationError({
|
|
645
|
+
error,
|
|
646
|
+
now,
|
|
647
|
+
previous: account.lastQuotaRefreshError,
|
|
648
|
+
});
|
|
604
649
|
if (error instanceof ClaudeOAuthRefreshError) {
|
|
605
650
|
recordRefreshError(account, error, now);
|
|
606
651
|
}
|
|
@@ -618,10 +663,33 @@ export class FallbackAccountManager {
|
|
|
618
663
|
refreshPromises = new Map();
|
|
619
664
|
refreshTimer = null;
|
|
620
665
|
quotaTimer = null;
|
|
666
|
+
quotaManager;
|
|
621
667
|
constructor(options = {}) {
|
|
622
668
|
this.now = options.now ?? Date.now;
|
|
623
669
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
624
670
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
671
|
+
this.quotaManager = options.quotaManager ?? null;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
675
|
+
* yet. Prevents unnecessary API calls when the on-disk snapshot is fresh.
|
|
676
|
+
*/
|
|
677
|
+
seedFallbackQuota(account, storage) {
|
|
678
|
+
if (!this.quotaManager)
|
|
679
|
+
return;
|
|
680
|
+
if (this.quotaManager.getFallback(account.id, account.access))
|
|
681
|
+
return;
|
|
682
|
+
if (!account.quota)
|
|
683
|
+
return;
|
|
684
|
+
const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
|
|
685
|
+
if (checkedAt <= 0)
|
|
686
|
+
return;
|
|
687
|
+
const checkInterval = getQuotaCheckIntervalMs(storage);
|
|
688
|
+
this.quotaManager.setFallback(account.id, {
|
|
689
|
+
quota: account.quota,
|
|
690
|
+
refreshAfter: checkedAt + checkInterval,
|
|
691
|
+
checkedAt,
|
|
692
|
+
}, account.access);
|
|
625
693
|
}
|
|
626
694
|
async load() {
|
|
627
695
|
return loadAccounts(this.configPath);
|
|
@@ -671,11 +739,23 @@ export class FallbackAccountManager {
|
|
|
671
739
|
next = await this.refreshAccount(next, storage);
|
|
672
740
|
changed = true;
|
|
673
741
|
}
|
|
674
|
-
|
|
742
|
+
this.seedFallbackQuota(next, storage);
|
|
743
|
+
const stale = this.quotaManager
|
|
744
|
+
? this.quotaManager.isFallbackStale(next.id, next.access)
|
|
745
|
+
: quotaIsStale(next, storage, this.now());
|
|
746
|
+
// Skip the request-time refresh when this account's quota API is
|
|
747
|
+
// backed off (recent 429/5xx). Hitting it again would extend the
|
|
748
|
+
// backoff; evaluate policy on the cached/seeded quota instead. Mirrors
|
|
749
|
+
// the background refreshQuotaForDueAccounts() guard.
|
|
750
|
+
if (stale &&
|
|
751
|
+
!quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
675
752
|
next = await this.refreshAccountQuota(next, storage);
|
|
676
753
|
changed = true;
|
|
677
754
|
}
|
|
678
|
-
|
|
755
|
+
// Single source of truth: evaluate quota policy from the unified
|
|
756
|
+
// QuotaManager cache (the same source as the staleness check above) so
|
|
757
|
+
// an active-route refresh that updated only the cache is not ignored.
|
|
758
|
+
if (this.accountPassesQuotaPolicy(this.quotaPolicyAccount(next), storage))
|
|
679
759
|
usable.push(next);
|
|
680
760
|
}
|
|
681
761
|
catch (error) {
|
|
@@ -708,6 +788,18 @@ export class FallbackAccountManager {
|
|
|
708
788
|
accountPassesQuotaPolicy(account, storage) {
|
|
709
789
|
return quotaSnapshotPassesPolicy(account.quota, storage);
|
|
710
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Return the account with its quota overlaid from the unified QuotaManager
|
|
793
|
+
* cache (token-bound) when available, so quota-policy decisions use the same
|
|
794
|
+
* source of truth as the staleness check. Falls back to the stored
|
|
795
|
+
* account.quota when no manager is wired or the cache has no entry.
|
|
796
|
+
*/
|
|
797
|
+
quotaPolicyAccount(account) {
|
|
798
|
+
if (!this.quotaManager)
|
|
799
|
+
return account;
|
|
800
|
+
const cached = this.quotaManager.getFallback(account.id, account.access)?.quota;
|
|
801
|
+
return cached ? { ...account, quota: cached } : account;
|
|
802
|
+
}
|
|
711
803
|
async refreshDueAccounts() {
|
|
712
804
|
const storage = await this.load();
|
|
713
805
|
if (!storage || !refreshEnabled(storage))
|
|
@@ -719,11 +811,11 @@ export class FallbackAccountManager {
|
|
|
719
811
|
if (!tokenNeedsRefresh(account, storage, this.now()))
|
|
720
812
|
continue;
|
|
721
813
|
if (refreshBackoffActive(account.lastRefreshError, account.refresh, this.now())) {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
814
|
+
// Backoff skips are steady-state while a fallback account is waiting for
|
|
815
|
+
// its next retry. Logging every background tick from every OpenCode
|
|
816
|
+
// process creates noise without adding new diagnostic signal; the
|
|
817
|
+
// failure/backoff itself is recorded when the refresh attempt fails and
|
|
818
|
+
// shown by /claude-quota.
|
|
727
819
|
continue;
|
|
728
820
|
}
|
|
729
821
|
try {
|
|
@@ -767,7 +859,16 @@ export class FallbackAccountManager {
|
|
|
767
859
|
next = await this.refreshAccount(next, storage);
|
|
768
860
|
changed = true;
|
|
769
861
|
}
|
|
770
|
-
if (
|
|
862
|
+
if (quotaBackoffActive(next.lastQuotaRefreshError, this.now())) {
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
this.seedFallbackQuota(next, storage);
|
|
866
|
+
// Use QuotaManager staleness when available (shared cache);
|
|
867
|
+
// fall back to per-account on-disk staleness otherwise.
|
|
868
|
+
const stale = this.quotaManager
|
|
869
|
+
? this.quotaManager.isFallbackStale(next.id, next.access)
|
|
870
|
+
: quotaIsStale(next, storage, this.now());
|
|
871
|
+
if (!stale)
|
|
771
872
|
continue;
|
|
772
873
|
await this.refreshAccountQuota(next, storage);
|
|
773
874
|
changed = true;
|
|
@@ -782,11 +883,12 @@ export class FallbackAccountManager {
|
|
|
782
883
|
if (changed)
|
|
783
884
|
await this.save(storage);
|
|
784
885
|
}
|
|
785
|
-
async refreshQuotaForAllAccounts() {
|
|
886
|
+
async refreshQuotaForAllAccounts(options = {}) {
|
|
786
887
|
const storage = await this.load();
|
|
787
888
|
const errors = [];
|
|
788
889
|
if (!storage || !quotaEnabled(storage))
|
|
789
890
|
return { storage, errors };
|
|
891
|
+
const force = options.force ?? false;
|
|
790
892
|
let changed = false;
|
|
791
893
|
for (const account of storage.accounts) {
|
|
792
894
|
if (account.enabled === false)
|
|
@@ -802,7 +904,10 @@ export class FallbackAccountManager {
|
|
|
802
904
|
next = await this.refreshAccount(next, storage);
|
|
803
905
|
changed = true;
|
|
804
906
|
}
|
|
805
|
-
|
|
907
|
+
// force (manual /claude-quota) bypasses the staleness skip to fetch
|
|
908
|
+
// fresh numbers on demand. refreshAccountQuota still respects 429
|
|
909
|
+
// backoff via QuotaManager.refreshFallback.
|
|
910
|
+
if (!force && !quotaIsStale(next, storage, this.now())) {
|
|
806
911
|
if (next.lastQuotaRefreshError) {
|
|
807
912
|
next.lastQuotaRefreshError = undefined;
|
|
808
913
|
updateStoredAccount(storage, next);
|
|
@@ -948,12 +1053,18 @@ export class FallbackAccountManager {
|
|
|
948
1053
|
if (!target.access) {
|
|
949
1054
|
throw new Error(`Fallback account ${account.id} has no access token`);
|
|
950
1055
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1056
|
+
// Unify on the shared QuotaManager when present: it adds inflight
|
|
1057
|
+
// deduplication and 429 backoff gating around the same quota API. Fall back
|
|
1058
|
+
// to a direct fetch only when no QuotaManager is wired (e.g. in isolation).
|
|
1059
|
+
const fetchSnapshot = (accessToken) => this.quotaManager
|
|
1060
|
+
? this.quotaManager.refreshFallback(target.id, accessToken)
|
|
1061
|
+
: fetchOAuthQuotaSnapshot({
|
|
1062
|
+
accessToken,
|
|
954
1063
|
fetchImpl: this.fetchImpl,
|
|
955
1064
|
now: this.now,
|
|
956
1065
|
});
|
|
1066
|
+
try {
|
|
1067
|
+
target.quota = await fetchSnapshot(target.access);
|
|
957
1068
|
}
|
|
958
1069
|
catch (error) {
|
|
959
1070
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -964,14 +1075,22 @@ export class FallbackAccountManager {
|
|
|
964
1075
|
});
|
|
965
1076
|
if (!target.access)
|
|
966
1077
|
throw error;
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
fetchImpl: this.fetchImpl,
|
|
970
|
-
now: this.now,
|
|
971
|
-
});
|
|
1078
|
+
// 401 does not arm QuotaManager backoff, so this retry proceeds.
|
|
1079
|
+
target.quota = await fetchSnapshot(target.access);
|
|
972
1080
|
}
|
|
973
1081
|
target.lastQuotaRefreshError = undefined;
|
|
974
1082
|
updateStoredAccount(storage, target);
|
|
1083
|
+
// Sync to shared QuotaManager so all consumers see the same cache. The
|
|
1084
|
+
// refreshFallback path already cached the snapshot; re-set here so
|
|
1085
|
+
// refreshAfter reflects this storage's check interval consistently.
|
|
1086
|
+
if (this.quotaManager && target.quota) {
|
|
1087
|
+
const now = this.now();
|
|
1088
|
+
this.quotaManager.setFallback(target.id, {
|
|
1089
|
+
quota: target.quota,
|
|
1090
|
+
refreshAfter: now + getQuotaCheckIntervalMs(storage),
|
|
1091
|
+
checkedAt: now,
|
|
1092
|
+
}, target.access);
|
|
1093
|
+
}
|
|
975
1094
|
return target;
|
|
976
1095
|
}
|
|
977
1096
|
}
|
package/dist/cachekeep.d.ts
CHANGED
package/dist/cachekeep.js
CHANGED
|
@@ -205,6 +205,9 @@ export class CacheKeepManager {
|
|
|
205
205
|
: undefined;
|
|
206
206
|
return { trackedSessions: targets.length, nextPrewarmAt };
|
|
207
207
|
}
|
|
208
|
+
trackedCount() {
|
|
209
|
+
return this.targets.size;
|
|
210
|
+
}
|
|
208
211
|
track(input) {
|
|
209
212
|
if (!input.sessionId)
|
|
210
213
|
return { tracked: false, reason: 'missing session id' };
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified quota cache and API gateway.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for main + fallback quota state. All consumers
|
|
5
|
+
* share one QuotaManager instance so they see the same in-memory cache.
|
|
6
|
+
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
|
+
*/
|
|
8
|
+
import type { AccountOperationError, AccountStorage, OAuthAccount, OAuthQuotaSnapshot } from './accounts.ts';
|
|
9
|
+
/**
|
|
10
|
+
* Stable, non-reversible fingerprint of an access token. Used to detect a
|
|
11
|
+
* main-account switch so a different account's persisted/cached quota is never
|
|
12
|
+
* reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
|
|
13
|
+
*/
|
|
14
|
+
export declare function tokenFingerprint(token: string): string;
|
|
15
|
+
export type QuotaEntry = {
|
|
16
|
+
quota: OAuthQuotaSnapshot;
|
|
17
|
+
refreshAfter: number;
|
|
18
|
+
checkedAt: number;
|
|
19
|
+
};
|
|
20
|
+
export type QuotaManagerOptions = {
|
|
21
|
+
storage: AccountStorage | null;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
onMainQuotaFetched?: (quota: OAuthQuotaSnapshot, checkedAt: number, tokenFingerprint: string) => void;
|
|
25
|
+
onApiError?: (error: AccountOperationError) => void;
|
|
26
|
+
};
|
|
27
|
+
export declare class QuotaManager {
|
|
28
|
+
private main;
|
|
29
|
+
private mainTokenFp;
|
|
30
|
+
private fallbacks;
|
|
31
|
+
private fallbackTokenFps;
|
|
32
|
+
private inflightMain;
|
|
33
|
+
private inflightFallbacks;
|
|
34
|
+
private mainLastApiError;
|
|
35
|
+
private fallbackApiErrors;
|
|
36
|
+
private fallbackErrorTokenFps;
|
|
37
|
+
private apiGate;
|
|
38
|
+
private lastApiCallAt;
|
|
39
|
+
private storage;
|
|
40
|
+
private readonly fetchImpl;
|
|
41
|
+
private readonly now;
|
|
42
|
+
private readonly onMainQuotaFetched;
|
|
43
|
+
private readonly onApiError;
|
|
44
|
+
constructor(opts: QuotaManagerOptions);
|
|
45
|
+
/**
|
|
46
|
+
* Cached main quota entry. Pass the live access token to enforce token
|
|
47
|
+
* binding: if the cached entry was produced by a different token (main
|
|
48
|
+
* account switched), it is dropped and null is returned so the caller
|
|
49
|
+
* refetches for the current account. Called without a token (e.g. for
|
|
50
|
+
* display) it returns whatever is cached.
|
|
51
|
+
*/
|
|
52
|
+
getMain(accessToken?: string): QuotaEntry | null;
|
|
53
|
+
/**
|
|
54
|
+
* Cached fallback quota entry. Pass the live access token to enforce token
|
|
55
|
+
* binding: if the entry was produced by a different token (account re-login),
|
|
56
|
+
* it is dropped and null is returned so the caller refetches.
|
|
57
|
+
*/
|
|
58
|
+
getFallback(accountId: string, accessToken?: string): QuotaEntry | null;
|
|
59
|
+
getAllFallbacks(): Map<string, QuotaEntry>;
|
|
60
|
+
setMain(accessToken: string, entry: QuotaEntry): void;
|
|
61
|
+
setFallback(accountId: string, entry: QuotaEntry, accessToken?: string): void;
|
|
62
|
+
refreshMain(accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
63
|
+
refreshFallback(accountId: string, accessToken: string): Promise<OAuthQuotaSnapshot>;
|
|
64
|
+
refreshAllFallbacks(accounts: OAuthAccount[]): Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Fire-and-forget refresh. Does not await, swallows errors.
|
|
67
|
+
*/
|
|
68
|
+
refreshMainInBackground(accessToken: string): void;
|
|
69
|
+
isMainStale(): boolean;
|
|
70
|
+
isFallbackStale(accountId: string, accessToken?: string): boolean;
|
|
71
|
+
shouldRefreshOnRequestCount(requestCount: number): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Combined check: should a refresh happen right now?
|
|
74
|
+
* True if main is stale by time OR triggered by request count.
|
|
75
|
+
*/
|
|
76
|
+
needsRefresh(requestCount: number): boolean;
|
|
77
|
+
updateStorage(storage: AccountStorage | null): void;
|
|
78
|
+
/**
|
|
79
|
+
* Seed fallback cache entries from persisted account.quota data.
|
|
80
|
+
* Only seeds accounts that don't already have a cache entry.
|
|
81
|
+
* Prevents unnecessary API calls when persisted quota is still fresh.
|
|
82
|
+
*/
|
|
83
|
+
seedFallbacksFromAccounts(accounts: OAuthAccount[]): void;
|
|
84
|
+
/**
|
|
85
|
+
* Whether the MAIN quota API is currently in backoff. Scoped to the main
|
|
86
|
+
* account — a fallback account's 429 never reports here.
|
|
87
|
+
*/
|
|
88
|
+
isBackedOff(): boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Whether a specific fallback account's quota API is in backoff.
|
|
91
|
+
*/
|
|
92
|
+
isFallbackBackedOff(accountId: string, accessToken?: string): boolean;
|
|
93
|
+
getLastApiError(): AccountOperationError | undefined;
|
|
94
|
+
/** Minimum gap between consecutive quota API calls (ms). */
|
|
95
|
+
private static readonly API_CALL_GAP_MS;
|
|
96
|
+
private static fallbackInflightKey;
|
|
97
|
+
/**
|
|
98
|
+
* Serialize API calls through a shared gate so only one
|
|
99
|
+
* quota API request runs at a time, with a minimum gap
|
|
100
|
+
* between calls. Prevents concurrent and rapid-fire calls
|
|
101
|
+
* from triggering Anthropic's rate limits.
|
|
102
|
+
*/
|
|
103
|
+
private _enqueueApiFetch;
|
|
104
|
+
private _fetchMain;
|
|
105
|
+
private _fetchFallback;
|
|
106
|
+
private static isAuthError;
|
|
107
|
+
/** Main quota failure: arms main-only backoff and persists via onApiError. */
|
|
108
|
+
private _handleMainFetchError;
|
|
109
|
+
/**
|
|
110
|
+
* Fallback quota failure: arms backoff for THIS account only. Never touches
|
|
111
|
+
* main backoff state and never calls onApiError (which persists the main
|
|
112
|
+
* quota error) — the per-account error is recorded by the caller via the
|
|
113
|
+
* account's lastQuotaRefreshError.
|
|
114
|
+
*/
|
|
115
|
+
private _handleFallbackFetchError;
|
|
116
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified quota cache and API gateway.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for main + fallback quota state. All consumers
|
|
5
|
+
* share one QuotaManager instance so they see the same in-memory cache.
|
|
6
|
+
* Handles deduplication, rate-limiting (429 backoff), and staleness.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { acquireRefreshFileLock, buildQuotaOperationError, fetchOAuthQuotaSnapshot, getPersistedMainQuota, getQuotaCheckIntervalMs, getQuotaNextRefreshAt, getQuotaRefreshEveryNRequests, quotaBackoffActive, } from "./accounts.js";
|
|
10
|
+
// Capture real setTimeout before tests can mock globalThis.setTimeout
|
|
11
|
+
const nativeSetTimeout = globalThis.setTimeout;
|
|
12
|
+
/**
|
|
13
|
+
* Stable, non-reversible fingerprint of an access token. Used to detect a
|
|
14
|
+
* main-account switch so a different account's persisted/cached quota is never
|
|
15
|
+
* reused. Not a secret — a truncated SHA-256, safe to persist alongside quota.
|
|
16
|
+
*/
|
|
17
|
+
export function tokenFingerprint(token) {
|
|
18
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 16);
|
|
19
|
+
}
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Class
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
export class QuotaManager {
|
|
24
|
+
// --- State ---
|
|
25
|
+
main = null;
|
|
26
|
+
mainTokenFp = null;
|
|
27
|
+
fallbacks = new Map();
|
|
28
|
+
// Fingerprint of the access token that produced each fallback cache entry, so
|
|
29
|
+
// a re-login (credential change) for the same account id invalidates the
|
|
30
|
+
// stale entry instead of being treated as fresh.
|
|
31
|
+
fallbackTokenFps = new Map();
|
|
32
|
+
// --- Inflight deduplication ---
|
|
33
|
+
inflightMain = null;
|
|
34
|
+
inflightFallbacks = new Map();
|
|
35
|
+
// --- Rate-limiting (scoped per route so a fallback 429 never backs off the
|
|
36
|
+
// main account or vice versa) ---
|
|
37
|
+
mainLastApiError = undefined;
|
|
38
|
+
fallbackApiErrors = new Map();
|
|
39
|
+
fallbackErrorTokenFps = new Map();
|
|
40
|
+
// --- Serial API gate (prevents concurrent quota API calls) ---
|
|
41
|
+
apiGate = Promise.resolve();
|
|
42
|
+
lastApiCallAt = 0;
|
|
43
|
+
// --- Config ---
|
|
44
|
+
storage;
|
|
45
|
+
fetchImpl;
|
|
46
|
+
now;
|
|
47
|
+
onMainQuotaFetched;
|
|
48
|
+
onApiError;
|
|
49
|
+
constructor(opts) {
|
|
50
|
+
this.storage = opts.storage;
|
|
51
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
52
|
+
this.now = opts.now ?? Date.now;
|
|
53
|
+
this.onMainQuotaFetched = opts.onMainQuotaFetched;
|
|
54
|
+
this.onApiError = opts.onApiError;
|
|
55
|
+
// Seed main quota from persisted storage, bound to the token fingerprint
|
|
56
|
+
// that produced it. refreshMain() drops this seed if the live token's
|
|
57
|
+
// fingerprint differs (main-account switch), preventing stale wrong-account
|
|
58
|
+
// quota from being served during backoff.
|
|
59
|
+
const persisted = getPersistedMainQuota(opts.storage);
|
|
60
|
+
if (persisted) {
|
|
61
|
+
this.main = {
|
|
62
|
+
quota: persisted.quota,
|
|
63
|
+
refreshAfter: getQuotaNextRefreshAt(persisted.quota, opts.storage, persisted.checkedAt),
|
|
64
|
+
checkedAt: persisted.checkedAt,
|
|
65
|
+
};
|
|
66
|
+
this.mainTokenFp = persisted.tokenFingerprint ?? null;
|
|
67
|
+
}
|
|
68
|
+
// Seed backoff state from persisted storage
|
|
69
|
+
const persistedError = opts.storage?.quota?.mainLastQuotaApiError;
|
|
70
|
+
if (persistedError && quotaBackoffActive(persistedError, this.now())) {
|
|
71
|
+
this.mainLastApiError = persistedError;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// =========================================================================
|
|
75
|
+
// Get (synchronous, from cache)
|
|
76
|
+
// =========================================================================
|
|
77
|
+
/**
|
|
78
|
+
* Cached main quota entry. Pass the live access token to enforce token
|
|
79
|
+
* binding: if the cached entry was produced by a different token (main
|
|
80
|
+
* account switched), it is dropped and null is returned so the caller
|
|
81
|
+
* refetches for the current account. Called without a token (e.g. for
|
|
82
|
+
* display) it returns whatever is cached.
|
|
83
|
+
*/
|
|
84
|
+
getMain(accessToken) {
|
|
85
|
+
if (accessToken &&
|
|
86
|
+
this.main &&
|
|
87
|
+
this.mainTokenFp &&
|
|
88
|
+
this.mainTokenFp !== tokenFingerprint(accessToken)) {
|
|
89
|
+
this.main = null;
|
|
90
|
+
this.mainTokenFp = null;
|
|
91
|
+
}
|
|
92
|
+
return this.main;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Cached fallback quota entry. Pass the live access token to enforce token
|
|
96
|
+
* binding: if the entry was produced by a different token (account re-login),
|
|
97
|
+
* it is dropped and null is returned so the caller refetches.
|
|
98
|
+
*/
|
|
99
|
+
getFallback(accountId, accessToken) {
|
|
100
|
+
const entry = this.fallbacks.get(accountId) ?? null;
|
|
101
|
+
if (!accessToken || !entry)
|
|
102
|
+
return entry;
|
|
103
|
+
const fp = this.fallbackTokenFps.get(accountId);
|
|
104
|
+
if (fp !== tokenFingerprint(accessToken)) {
|
|
105
|
+
this.fallbacks.delete(accountId);
|
|
106
|
+
this.fallbackTokenFps.delete(accountId);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return entry;
|
|
110
|
+
}
|
|
111
|
+
getAllFallbacks() {
|
|
112
|
+
return this.fallbacks;
|
|
113
|
+
}
|
|
114
|
+
// =========================================================================
|
|
115
|
+
// Set (manual inject — seeding from persisted account.quota on boot)
|
|
116
|
+
// =========================================================================
|
|
117
|
+
setMain(accessToken, entry) {
|
|
118
|
+
this.mainTokenFp = tokenFingerprint(accessToken);
|
|
119
|
+
this.main = entry;
|
|
120
|
+
}
|
|
121
|
+
setFallback(accountId, entry, accessToken) {
|
|
122
|
+
this.fallbacks.set(accountId, entry);
|
|
123
|
+
if (accessToken) {
|
|
124
|
+
this.fallbackTokenFps.set(accountId, tokenFingerprint(accessToken));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
this.fallbackTokenFps.delete(accountId);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// =========================================================================
|
|
131
|
+
// Refresh (async, deduplicated, rate-limited)
|
|
132
|
+
// =========================================================================
|
|
133
|
+
async refreshMain(accessToken) {
|
|
134
|
+
// If the main account/token changed, invalidate the cache (including a
|
|
135
|
+
// persisted seed) BEFORE the backoff short-circuit so a different account's
|
|
136
|
+
// stale quota is never returned while the quota API is backed off.
|
|
137
|
+
const fp = tokenFingerprint(accessToken);
|
|
138
|
+
if (this.mainTokenFp && this.mainTokenFp !== fp) {
|
|
139
|
+
this.main = null;
|
|
140
|
+
this.mainTokenFp = null;
|
|
141
|
+
}
|
|
142
|
+
// Deduplicate — return in-flight promise if already fetching
|
|
143
|
+
if (this.inflightMain)
|
|
144
|
+
return this.inflightMain;
|
|
145
|
+
// Rate-limit — if API recently 429'd, return stale or throw
|
|
146
|
+
if (this.isBackedOff()) {
|
|
147
|
+
if (this.main)
|
|
148
|
+
return this.main.quota;
|
|
149
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
150
|
+
}
|
|
151
|
+
this.inflightMain = this._fetchMain(accessToken);
|
|
152
|
+
return this.inflightMain;
|
|
153
|
+
}
|
|
154
|
+
async refreshFallback(accountId, accessToken) {
|
|
155
|
+
// Deduplicate per account+token so a same-label re-login never joins a
|
|
156
|
+
// quota probe that was started with the previous credentials.
|
|
157
|
+
const inflightKey = QuotaManager.fallbackInflightKey(accountId, accessToken);
|
|
158
|
+
const inflight = this.inflightFallbacks.get(inflightKey);
|
|
159
|
+
if (inflight)
|
|
160
|
+
return inflight;
|
|
161
|
+
// Rate-limit — scoped to THIS fallback account only
|
|
162
|
+
if (this.isFallbackBackedOff(accountId, accessToken)) {
|
|
163
|
+
const cached = this.getFallback(accountId, accessToken);
|
|
164
|
+
if (cached)
|
|
165
|
+
return cached.quota;
|
|
166
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
167
|
+
}
|
|
168
|
+
const promise = this._fetchFallback(accountId, accessToken);
|
|
169
|
+
this.inflightFallbacks.set(inflightKey, promise);
|
|
170
|
+
return promise;
|
|
171
|
+
}
|
|
172
|
+
async refreshAllFallbacks(accounts) {
|
|
173
|
+
const now = this.now();
|
|
174
|
+
for (const account of accounts) {
|
|
175
|
+
if (account.enabled === false)
|
|
176
|
+
continue;
|
|
177
|
+
if (!account.access)
|
|
178
|
+
continue;
|
|
179
|
+
const cached = this.getFallback(account.id, account.access);
|
|
180
|
+
if (cached && now < cached.refreshAfter)
|
|
181
|
+
continue;
|
|
182
|
+
try {
|
|
183
|
+
await this.refreshFallback(account.id, account.access);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Best-effort — keep stale cache entry if fetch fails
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Fire-and-forget refresh. Does not await, swallows errors.
|
|
192
|
+
*/
|
|
193
|
+
refreshMainInBackground(accessToken) {
|
|
194
|
+
if (this.inflightMain)
|
|
195
|
+
return;
|
|
196
|
+
if (this.isBackedOff())
|
|
197
|
+
return;
|
|
198
|
+
void this.refreshMain(accessToken).catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
// =========================================================================
|
|
201
|
+
// Staleness queries
|
|
202
|
+
// =========================================================================
|
|
203
|
+
isMainStale() {
|
|
204
|
+
if (!this.main)
|
|
205
|
+
return true;
|
|
206
|
+
return this.now() >= this.main.refreshAfter;
|
|
207
|
+
}
|
|
208
|
+
isFallbackStale(accountId, accessToken) {
|
|
209
|
+
// Token-aware: a credential change invalidates the entry (treated as stale).
|
|
210
|
+
const entry = this.getFallback(accountId, accessToken);
|
|
211
|
+
if (!entry)
|
|
212
|
+
return true;
|
|
213
|
+
return this.now() >= entry.refreshAfter;
|
|
214
|
+
}
|
|
215
|
+
shouldRefreshOnRequestCount(requestCount) {
|
|
216
|
+
const everyN = getQuotaRefreshEveryNRequests(this.storage);
|
|
217
|
+
if (everyN <= 0)
|
|
218
|
+
return false;
|
|
219
|
+
return requestCount > 0 && requestCount % everyN === 0;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Combined check: should a refresh happen right now?
|
|
223
|
+
* True if main is stale by time OR triggered by request count.
|
|
224
|
+
*/
|
|
225
|
+
needsRefresh(requestCount) {
|
|
226
|
+
return this.isMainStale() || this.shouldRefreshOnRequestCount(requestCount);
|
|
227
|
+
}
|
|
228
|
+
// =========================================================================
|
|
229
|
+
// Config
|
|
230
|
+
// =========================================================================
|
|
231
|
+
updateStorage(storage) {
|
|
232
|
+
this.storage = storage;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Seed fallback cache entries from persisted account.quota data.
|
|
236
|
+
* Only seeds accounts that don't already have a cache entry.
|
|
237
|
+
* Prevents unnecessary API calls when persisted quota is still fresh.
|
|
238
|
+
*/
|
|
239
|
+
seedFallbacksFromAccounts(accounts) {
|
|
240
|
+
const checkInterval = getQuotaCheckIntervalMs(this.storage);
|
|
241
|
+
for (const account of accounts) {
|
|
242
|
+
if (account.enabled === false)
|
|
243
|
+
continue;
|
|
244
|
+
if (this.getFallback(account.id, account.access))
|
|
245
|
+
continue;
|
|
246
|
+
if (!account.quota)
|
|
247
|
+
continue;
|
|
248
|
+
const checkedAt = Math.max(account.quota.five_hour?.checkedAt ?? 0, account.quota.seven_day?.checkedAt ?? 0);
|
|
249
|
+
if (checkedAt <= 0)
|
|
250
|
+
continue;
|
|
251
|
+
this.setFallback(account.id, {
|
|
252
|
+
quota: account.quota,
|
|
253
|
+
refreshAfter: checkedAt + checkInterval,
|
|
254
|
+
checkedAt,
|
|
255
|
+
}, account.access);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Whether the MAIN quota API is currently in backoff. Scoped to the main
|
|
260
|
+
* account — a fallback account's 429 never reports here.
|
|
261
|
+
*/
|
|
262
|
+
isBackedOff() {
|
|
263
|
+
return quotaBackoffActive(this.mainLastApiError, this.now());
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Whether a specific fallback account's quota API is in backoff.
|
|
267
|
+
*/
|
|
268
|
+
isFallbackBackedOff(accountId, accessToken) {
|
|
269
|
+
if (accessToken) {
|
|
270
|
+
const errorFp = this.fallbackErrorTokenFps.get(accountId);
|
|
271
|
+
if (errorFp !== tokenFingerprint(accessToken))
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
return quotaBackoffActive(this.fallbackApiErrors.get(accountId), this.now());
|
|
275
|
+
}
|
|
276
|
+
getLastApiError() {
|
|
277
|
+
return this.mainLastApiError;
|
|
278
|
+
}
|
|
279
|
+
// =========================================================================
|
|
280
|
+
// Private
|
|
281
|
+
// =========================================================================
|
|
282
|
+
/** Minimum gap between consecutive quota API calls (ms). */
|
|
283
|
+
static API_CALL_GAP_MS = 1_000;
|
|
284
|
+
static fallbackInflightKey(accountId, accessToken) {
|
|
285
|
+
return JSON.stringify([accountId, tokenFingerprint(accessToken)]);
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Serialize API calls through a shared gate so only one
|
|
289
|
+
* quota API request runs at a time, with a minimum gap
|
|
290
|
+
* between calls. Prevents concurrent and rapid-fire calls
|
|
291
|
+
* from triggering Anthropic's rate limits.
|
|
292
|
+
*/
|
|
293
|
+
_enqueueApiFetch(fn) {
|
|
294
|
+
const gatedFn = async () => {
|
|
295
|
+
// Wait until minimum gap since last API call
|
|
296
|
+
const elapsed = this.now() - this.lastApiCallAt;
|
|
297
|
+
if (elapsed < QuotaManager.API_CALL_GAP_MS) {
|
|
298
|
+
await new Promise((r) => {
|
|
299
|
+
const id = nativeSetTimeout(r, QuotaManager.API_CALL_GAP_MS - elapsed);
|
|
300
|
+
if (typeof id === 'object' && 'unref' in id)
|
|
301
|
+
id.unref();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
this.lastApiCallAt = this.now();
|
|
305
|
+
return fn();
|
|
306
|
+
};
|
|
307
|
+
const queued = this.apiGate.then(gatedFn, gatedFn);
|
|
308
|
+
this.apiGate = queued.catch(() => { });
|
|
309
|
+
return queued;
|
|
310
|
+
}
|
|
311
|
+
async _fetchMain(accessToken) {
|
|
312
|
+
return this._enqueueApiFetch(async () => {
|
|
313
|
+
try {
|
|
314
|
+
// Re-check backoff inside gate — may have been set by
|
|
315
|
+
// a preceding queued call while we waited
|
|
316
|
+
if (this.isBackedOff()) {
|
|
317
|
+
if (this.main)
|
|
318
|
+
return this.main.quota;
|
|
319
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
320
|
+
}
|
|
321
|
+
const fileLock = await acquireRefreshFileLock({
|
|
322
|
+
name: 'opencode-main-quota-refresh',
|
|
323
|
+
ttlMs: 30_000,
|
|
324
|
+
});
|
|
325
|
+
if (!fileLock) {
|
|
326
|
+
if (this.main)
|
|
327
|
+
return this.main.quota;
|
|
328
|
+
throw new Error('Quota refresh is already in progress');
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const quota = await fetchOAuthQuotaSnapshot({
|
|
332
|
+
accessToken,
|
|
333
|
+
fetchImpl: this.fetchImpl,
|
|
334
|
+
now: this.now,
|
|
335
|
+
});
|
|
336
|
+
const now = this.now();
|
|
337
|
+
this.mainTokenFp = tokenFingerprint(accessToken);
|
|
338
|
+
this.main = {
|
|
339
|
+
quota,
|
|
340
|
+
refreshAfter: getQuotaNextRefreshAt(quota, this.storage, now),
|
|
341
|
+
checkedAt: now,
|
|
342
|
+
};
|
|
343
|
+
this.mainLastApiError = undefined;
|
|
344
|
+
this.onMainQuotaFetched?.(quota, now, this.mainTokenFp);
|
|
345
|
+
return quota;
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
this._handleMainFetchError(error);
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
await fileLock.release();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
this.inflightMain = null;
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
async _fetchFallback(accountId, accessToken) {
|
|
361
|
+
return this._enqueueApiFetch(async () => {
|
|
362
|
+
try {
|
|
363
|
+
// Re-check backoff inside gate — scoped to this fallback account
|
|
364
|
+
if (this.isFallbackBackedOff(accountId, accessToken)) {
|
|
365
|
+
const cached = this.getFallback(accountId, accessToken);
|
|
366
|
+
if (cached)
|
|
367
|
+
return cached.quota;
|
|
368
|
+
throw new Error('Quota API rate-limited — try again later');
|
|
369
|
+
}
|
|
370
|
+
const quota = await fetchOAuthQuotaSnapshot({
|
|
371
|
+
accessToken,
|
|
372
|
+
fetchImpl: this.fetchImpl,
|
|
373
|
+
now: this.now,
|
|
374
|
+
});
|
|
375
|
+
const now = this.now();
|
|
376
|
+
this.setFallback(accountId, {
|
|
377
|
+
quota,
|
|
378
|
+
refreshAfter: now + getQuotaCheckIntervalMs(this.storage),
|
|
379
|
+
checkedAt: now,
|
|
380
|
+
}, accessToken);
|
|
381
|
+
this.fallbackApiErrors.delete(accountId);
|
|
382
|
+
this.fallbackErrorTokenFps.delete(accountId);
|
|
383
|
+
return quota;
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
this._handleFallbackFetchError(accountId, accessToken, error);
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
this.inflightFallbacks.delete(QuotaManager.fallbackInflightKey(accountId, accessToken));
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
// A 401 is an auth/token problem, not a rate limit. The caller refreshes the
|
|
395
|
+
// token and retries; backing off the quota API here would block that retry,
|
|
396
|
+
// so surface 401s without recording backoff state.
|
|
397
|
+
static isAuthError(error) {
|
|
398
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
399
|
+
return /quota check failed: 401\b/.test(message);
|
|
400
|
+
}
|
|
401
|
+
/** Main quota failure: arms main-only backoff and persists via onApiError. */
|
|
402
|
+
_handleMainFetchError(error) {
|
|
403
|
+
if (QuotaManager.isAuthError(error))
|
|
404
|
+
return;
|
|
405
|
+
this.mainLastApiError = buildQuotaOperationError({
|
|
406
|
+
error,
|
|
407
|
+
now: this.now(),
|
|
408
|
+
previous: this.mainLastApiError,
|
|
409
|
+
});
|
|
410
|
+
this.onApiError?.(this.mainLastApiError);
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Fallback quota failure: arms backoff for THIS account only. Never touches
|
|
414
|
+
* main backoff state and never calls onApiError (which persists the main
|
|
415
|
+
* quota error) — the per-account error is recorded by the caller via the
|
|
416
|
+
* account's lastQuotaRefreshError.
|
|
417
|
+
*/
|
|
418
|
+
_handleFallbackFetchError(accountId, accessToken, error) {
|
|
419
|
+
if (QuotaManager.isAuthError(error))
|
|
420
|
+
return;
|
|
421
|
+
const tokenFp = tokenFingerprint(accessToken);
|
|
422
|
+
const previous = this.fallbackErrorTokenFps.get(accountId) === tokenFp
|
|
423
|
+
? this.fallbackApiErrors.get(accountId)
|
|
424
|
+
: undefined;
|
|
425
|
+
this.fallbackApiErrors.set(accountId, buildQuotaOperationError({
|
|
426
|
+
error,
|
|
427
|
+
now: this.now(),
|
|
428
|
+
previous,
|
|
429
|
+
}));
|
|
430
|
+
this.fallbackErrorTokenFps.set(accountId, tokenFp);
|
|
431
|
+
}
|
|
432
|
+
}
|
package/dist/relay.js
CHANGED
|
@@ -241,6 +241,12 @@ function toBinaryChunk(data) {
|
|
|
241
241
|
function parseRelayControlMessage(data) {
|
|
242
242
|
return JSON.parse(data);
|
|
243
243
|
}
|
|
244
|
+
function relayControlErrorMessage(message) {
|
|
245
|
+
const detail = message.message || 'relay websocket error';
|
|
246
|
+
return message.status
|
|
247
|
+
? `relay websocket error ${message.status}: ${detail}`
|
|
248
|
+
: detail;
|
|
249
|
+
}
|
|
244
250
|
class PersistentRelaySession {
|
|
245
251
|
config;
|
|
246
252
|
affinity;
|
|
@@ -561,7 +567,7 @@ class PersistentRelaySession {
|
|
|
561
567
|
}
|
|
562
568
|
const error = message.status === 409
|
|
563
569
|
? new RelayStateMismatchError(message.message || 'state mismatch')
|
|
564
|
-
: new Error(message
|
|
570
|
+
: new Error(relayControlErrorMessage(message));
|
|
565
571
|
this.failPending(error);
|
|
566
572
|
}
|
|
567
573
|
}
|