@cortexkit/anthropic-auth-core 1.5.0 → 1.6.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 +29 -0
- package/dist/accounts.js +95 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/killswitch.d.ts +27 -0
- package/dist/killswitch.js +127 -0
- package/dist/relay.js +34 -10
- package/package.json +1 -1
package/dist/accounts.d.ts
CHANGED
|
@@ -31,6 +31,14 @@ export type AccountQuotaWindow = {
|
|
|
31
31
|
checkedAt: number;
|
|
32
32
|
};
|
|
33
33
|
export type RoutingMode = 'main-first' | 'fallback-first';
|
|
34
|
+
export type KillswitchThresholds = Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
|
|
35
|
+
export type KillswitchConfig = {
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
/** Thresholds for the main OAuth account (remaining % below which the account is killed). */
|
|
38
|
+
main?: KillswitchThresholds;
|
|
39
|
+
/** Per-account overrides keyed by account ID. Accounts without an entry use the `main` thresholds. */
|
|
40
|
+
accounts?: Record<string, KillswitchThresholds>;
|
|
41
|
+
};
|
|
34
42
|
export type AccountStorage = {
|
|
35
43
|
version: 1;
|
|
36
44
|
main?: {
|
|
@@ -56,6 +64,8 @@ export type AccountStorage = {
|
|
|
56
64
|
refreshEveryNRequests?: number;
|
|
57
65
|
minimumRemaining?: Partial<Record<QuotaWindowName | '5h' | '1w', number>>;
|
|
58
66
|
failClosedOnUnknownQuota?: boolean;
|
|
67
|
+
/** Opt-in OpenCode TUI toast after quota refresh. Default: false. */
|
|
68
|
+
showToasts?: boolean;
|
|
59
69
|
mainQuota?: OAuthQuotaSnapshot;
|
|
60
70
|
mainQuotaCheckedAt?: number;
|
|
61
71
|
mainQuotaToken?: string;
|
|
@@ -83,6 +93,7 @@ export type AccountStorage = {
|
|
|
83
93
|
fallbackToDirect?: boolean;
|
|
84
94
|
transport?: 'http' | 'websocket';
|
|
85
95
|
};
|
|
96
|
+
killswitch?: KillswitchConfig;
|
|
86
97
|
accounts: OAuthAccount[];
|
|
87
98
|
};
|
|
88
99
|
export type OAuthQuotaSnapshot = Partial<Record<QuotaWindowName, AccountQuotaWindow>>;
|
|
@@ -91,6 +102,7 @@ export type AccountManagerOptions = {
|
|
|
91
102
|
fetchImpl?: typeof fetch;
|
|
92
103
|
configPath?: string;
|
|
93
104
|
quotaManager?: import('./quota-manager.ts').QuotaManager;
|
|
105
|
+
onFallbackStorageChanged?: () => void;
|
|
94
106
|
};
|
|
95
107
|
export type AccountRefreshError = {
|
|
96
108
|
accountId: string;
|
|
@@ -149,6 +161,22 @@ export declare function getPersistedMainQuota(storage: AccountStorage | null): {
|
|
|
149
161
|
*/
|
|
150
162
|
export declare function getQuotaRefreshEveryNRequests(storage: AccountStorage | null): number;
|
|
151
163
|
export declare function quotaSnapshotPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null): boolean;
|
|
164
|
+
export declare const DEFAULT_KILLSWITCH_THRESHOLDS: Record<QuotaWindowName, number>;
|
|
165
|
+
export declare function isKillswitchEnabled(storage: AccountStorage | null): boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Returns true if the account's quota is above its killswitch threshold.
|
|
168
|
+
* When killswitch is disabled, always returns true.
|
|
169
|
+
*/
|
|
170
|
+
export declare function killswitchPassesPolicy(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, accountId?: string): boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Find the earliest reset time across all accounts' quota windows.
|
|
173
|
+
* Returns seconds from `now` until that reset, or 300 as a fallback.
|
|
174
|
+
*/
|
|
175
|
+
export declare function killswitchRetryAfterSeconds(mainQuota: OAuthQuotaSnapshot | undefined, fallbackAccounts: Array<{
|
|
176
|
+
quota?: OAuthQuotaSnapshot;
|
|
177
|
+
}>, now: number): number;
|
|
178
|
+
export declare function getKillswitchConfig(storage: AccountStorage | null): KillswitchConfig;
|
|
179
|
+
export declare function setKillswitchPersistent(config: KillswitchConfig, path?: string): Promise<AccountStorage>;
|
|
152
180
|
export declare function getQuotaNextRefreshAt(quota: OAuthQuotaSnapshot | undefined, storage: AccountStorage | null, now: number): number;
|
|
153
181
|
export declare function fetchOAuthQuotaSnapshot(input: {
|
|
154
182
|
accessToken: string;
|
|
@@ -166,6 +194,7 @@ export declare class FallbackAccountManager {
|
|
|
166
194
|
private refreshTimer;
|
|
167
195
|
private quotaTimer;
|
|
168
196
|
readonly quotaManager: import('./quota-manager.ts').QuotaManager | null;
|
|
197
|
+
private readonly onFallbackStorageChanged;
|
|
169
198
|
constructor(options?: AccountManagerOptions);
|
|
170
199
|
/**
|
|
171
200
|
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
package/dist/accounts.js
CHANGED
|
@@ -130,6 +130,7 @@ function normalizeStorage(value) {
|
|
|
130
130
|
claudeFast: isRecord(value.claudeFast) ? value.claudeFast : undefined,
|
|
131
131
|
cacheKeep: isRecord(value.cacheKeep) ? value.cacheKeep : undefined,
|
|
132
132
|
relay: isRecord(value.relay) ? value.relay : undefined,
|
|
133
|
+
killswitch: isRecord(value.killswitch) ? value.killswitch : undefined,
|
|
133
134
|
accounts: value.accounts
|
|
134
135
|
.map(normalizeAccount)
|
|
135
136
|
.filter((account) => account != null),
|
|
@@ -517,6 +518,95 @@ export function quotaSnapshotPassesPolicy(quota, storage) {
|
|
|
517
518
|
}
|
|
518
519
|
return true;
|
|
519
520
|
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Killswitch — hard-block requests when remaining quota drops below per-account
|
|
523
|
+
// thresholds, even if the API would still accept them.
|
|
524
|
+
// ---------------------------------------------------------------------------
|
|
525
|
+
export const DEFAULT_KILLSWITCH_THRESHOLDS = {
|
|
526
|
+
five_hour: 5,
|
|
527
|
+
seven_day: 10,
|
|
528
|
+
};
|
|
529
|
+
function normalizeKillswitchThresholds(thresholds) {
|
|
530
|
+
return {
|
|
531
|
+
five_hour: thresholds?.five_hour ??
|
|
532
|
+
thresholds?.['5h'] ??
|
|
533
|
+
DEFAULT_KILLSWITCH_THRESHOLDS.five_hour,
|
|
534
|
+
seven_day: thresholds?.seven_day ??
|
|
535
|
+
thresholds?.['1w'] ??
|
|
536
|
+
DEFAULT_KILLSWITCH_THRESHOLDS.seven_day,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
export function isKillswitchEnabled(storage) {
|
|
540
|
+
return storage?.killswitch?.enabled === true;
|
|
541
|
+
}
|
|
542
|
+
function getKillswitchThresholdsForAccount(storage, accountId) {
|
|
543
|
+
if (!storage?.killswitch)
|
|
544
|
+
return DEFAULT_KILLSWITCH_THRESHOLDS;
|
|
545
|
+
if (accountId && storage.killswitch.accounts?.[accountId]) {
|
|
546
|
+
return normalizeKillswitchThresholds(storage.killswitch.accounts[accountId]);
|
|
547
|
+
}
|
|
548
|
+
return normalizeKillswitchThresholds(storage.killswitch.main);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Returns true if the account's quota is above its killswitch threshold.
|
|
552
|
+
* When killswitch is disabled, always returns true.
|
|
553
|
+
*/
|
|
554
|
+
export function killswitchPassesPolicy(quota, storage, accountId) {
|
|
555
|
+
if (!isKillswitchEnabled(storage))
|
|
556
|
+
return true;
|
|
557
|
+
const thresholds = getKillswitchThresholdsForAccount(storage, accountId);
|
|
558
|
+
let sawUnknownWindow = false;
|
|
559
|
+
for (const key of ['five_hour', 'seven_day']) {
|
|
560
|
+
const window = quota?.[key];
|
|
561
|
+
// Defer the unknown-window decision: a quota snapshot can legally carry
|
|
562
|
+
// only one window, and a present window below its threshold must still
|
|
563
|
+
// block even if the other window is missing.
|
|
564
|
+
if (!window) {
|
|
565
|
+
sawUnknownWindow = true;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
if (window.remainingPercent < thresholds[key])
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
if (sawUnknownWindow)
|
|
572
|
+
return !failClosedOnUnknownQuota(storage);
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Find the earliest reset time across all accounts' quota windows.
|
|
577
|
+
* Returns seconds from `now` until that reset, or 300 as a fallback.
|
|
578
|
+
*/
|
|
579
|
+
export function killswitchRetryAfterSeconds(mainQuota, fallbackAccounts, now) {
|
|
580
|
+
const resetTimes = [];
|
|
581
|
+
const allQuotas = [mainQuota, ...fallbackAccounts.map((a) => a.quota)];
|
|
582
|
+
for (const quota of allQuotas) {
|
|
583
|
+
for (const key of ['five_hour', 'seven_day']) {
|
|
584
|
+
const resetStr = quota?.[key]?.resetsAt;
|
|
585
|
+
if (!resetStr)
|
|
586
|
+
continue;
|
|
587
|
+
const resetTime = Date.parse(resetStr);
|
|
588
|
+
if (Number.isFinite(resetTime) && resetTime > now) {
|
|
589
|
+
resetTimes.push(resetTime);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (!resetTimes.length)
|
|
594
|
+
return 300;
|
|
595
|
+
return Math.max(1, Math.ceil((Math.min(...resetTimes) - now) / 1000)) + 60;
|
|
596
|
+
}
|
|
597
|
+
export function getKillswitchConfig(storage) {
|
|
598
|
+
return storage?.killswitch ?? { enabled: false };
|
|
599
|
+
}
|
|
600
|
+
export async function setKillswitchPersistent(config, path = getAccountStoragePath()) {
|
|
601
|
+
const storage = (await loadAccounts(path)) ?? {
|
|
602
|
+
version: 1,
|
|
603
|
+
main: { type: 'opencode', provider: 'anthropic' },
|
|
604
|
+
accounts: [],
|
|
605
|
+
};
|
|
606
|
+
storage.killswitch = config;
|
|
607
|
+
await saveAccounts(storage, path);
|
|
608
|
+
return storage;
|
|
609
|
+
}
|
|
520
610
|
export function getQuotaNextRefreshAt(quota, storage, now) {
|
|
521
611
|
if (!quotaEnabled(storage))
|
|
522
612
|
return now + getQuotaCheckIntervalMs(storage);
|
|
@@ -664,11 +754,13 @@ export class FallbackAccountManager {
|
|
|
664
754
|
refreshTimer = null;
|
|
665
755
|
quotaTimer = null;
|
|
666
756
|
quotaManager;
|
|
757
|
+
onFallbackStorageChanged;
|
|
667
758
|
constructor(options = {}) {
|
|
668
759
|
this.now = options.now ?? Date.now;
|
|
669
760
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
670
761
|
this.configPath = options.configPath ?? getAccountStoragePath();
|
|
671
762
|
this.quotaManager = options.quotaManager ?? null;
|
|
763
|
+
this.onFallbackStorageChanged = options.onFallbackStorageChanged;
|
|
672
764
|
}
|
|
673
765
|
/**
|
|
674
766
|
* Seed QuotaManager from persisted account.quota if no cache entry exists
|
|
@@ -880,8 +972,10 @@ export class FallbackAccountManager {
|
|
|
880
972
|
// Quota probes are advisory; failed probes fail closed at selection time.
|
|
881
973
|
}
|
|
882
974
|
}
|
|
883
|
-
if (changed)
|
|
975
|
+
if (changed) {
|
|
884
976
|
await this.save(storage);
|
|
977
|
+
this.onFallbackStorageChanged?.();
|
|
978
|
+
}
|
|
885
979
|
}
|
|
886
980
|
async refreshQuotaForAllAccounts(options = {}) {
|
|
887
981
|
const storage = await this.load();
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { KillswitchConfig } from './accounts.ts';
|
|
2
|
+
export declare const KILLSWITCH_COMMAND_NAME = "claude-killswitch";
|
|
3
|
+
export type KillswitchCommandAction = {
|
|
4
|
+
type: 'status';
|
|
5
|
+
} | {
|
|
6
|
+
type: 'on';
|
|
7
|
+
} | {
|
|
8
|
+
type: 'off';
|
|
9
|
+
} | {
|
|
10
|
+
type: 'set';
|
|
11
|
+
entries: Array<{
|
|
12
|
+
account: string;
|
|
13
|
+
fh: number;
|
|
14
|
+
sd: number;
|
|
15
|
+
}>;
|
|
16
|
+
} | {
|
|
17
|
+
type: 'usage';
|
|
18
|
+
};
|
|
19
|
+
export declare function parseKillswitchCommandAction(argumentsText: string): KillswitchCommandAction;
|
|
20
|
+
export declare function executeKillswitchCommand(input: {
|
|
21
|
+
argumentsText: string;
|
|
22
|
+
config: KillswitchConfig;
|
|
23
|
+
accountIds: string[];
|
|
24
|
+
}): {
|
|
25
|
+
text: string;
|
|
26
|
+
updatedConfig?: KillswitchConfig;
|
|
27
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { DEFAULT_KILLSWITCH_THRESHOLDS as DEFAULT_THRESHOLDS } from "./accounts.js";
|
|
2
|
+
export const KILLSWITCH_COMMAND_NAME = 'claude-killswitch';
|
|
3
|
+
export function parseKillswitchCommandAction(argumentsText) {
|
|
4
|
+
const parts = argumentsText.trim().split(/\s+/).filter(Boolean);
|
|
5
|
+
if (parts.length === 0)
|
|
6
|
+
return { type: 'status' };
|
|
7
|
+
if (parts.length === 1 && parts[0] === 'on')
|
|
8
|
+
return { type: 'on' };
|
|
9
|
+
if (parts.length === 1 && parts[0] === 'off')
|
|
10
|
+
return { type: 'off' };
|
|
11
|
+
if (parts[0] === 'set') {
|
|
12
|
+
const entries = [];
|
|
13
|
+
for (let i = 1; i < parts.length; i++) {
|
|
14
|
+
const match = parts[i]?.match(/^([^:]+):(\d+),(\d+)$/);
|
|
15
|
+
if (!match)
|
|
16
|
+
return { type: 'usage' };
|
|
17
|
+
const [, account, fhStr, sdStr] = match;
|
|
18
|
+
entries.push({
|
|
19
|
+
account,
|
|
20
|
+
fh: Number.parseInt(fhStr, 10),
|
|
21
|
+
sd: Number.parseInt(sdStr, 10),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (entries.length === 0)
|
|
25
|
+
return { type: 'usage' };
|
|
26
|
+
return { type: 'set', entries };
|
|
27
|
+
}
|
|
28
|
+
return { type: 'usage' };
|
|
29
|
+
}
|
|
30
|
+
function buildStatusTable(config, accountIds) {
|
|
31
|
+
const enabled = config.enabled === true;
|
|
32
|
+
const lines = [
|
|
33
|
+
'## Killswitch',
|
|
34
|
+
'',
|
|
35
|
+
`Status: **${enabled ? 'ON' : 'OFF'}**`,
|
|
36
|
+
];
|
|
37
|
+
if (enabled) {
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push('| Account | 5h threshold | 1w threshold |');
|
|
40
|
+
lines.push('| ------- | ------------ | ------------ |');
|
|
41
|
+
const mainT = config.main ?? {};
|
|
42
|
+
const fh = mainT.five_hour ?? mainT['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
|
|
43
|
+
const sd = mainT.seven_day ?? mainT['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
|
|
44
|
+
lines.push(`| main | \u2265 ${fh}% | \u2265 ${sd}% |`);
|
|
45
|
+
for (const id of accountIds) {
|
|
46
|
+
const t = config.accounts?.[id] ?? config.main ?? {};
|
|
47
|
+
const afh = t.five_hour ?? t['5h'] ?? DEFAULT_THRESHOLDS.five_hour;
|
|
48
|
+
const asd = t.seven_day ?? t['1w'] ?? DEFAULT_THRESHOLDS.seven_day;
|
|
49
|
+
lines.push(`| ${id} | \u2265 ${afh}% | \u2265 ${asd}% |`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
const USAGE_TEXT = [
|
|
55
|
+
'## Killswitch Commands',
|
|
56
|
+
'',
|
|
57
|
+
'```',
|
|
58
|
+
`/${KILLSWITCH_COMMAND_NAME} — show status`,
|
|
59
|
+
`/${KILLSWITCH_COMMAND_NAME} on — enable with current or default thresholds`,
|
|
60
|
+
`/${KILLSWITCH_COMMAND_NAME} off — disable`,
|
|
61
|
+
`/${KILLSWITCH_COMMAND_NAME} set all:5,10 — set all accounts to 5h≥5%, 1w≥10%`,
|
|
62
|
+
`/${KILLSWITCH_COMMAND_NAME} set main:3,8 work-alt:5,10 — per-account`,
|
|
63
|
+
'```',
|
|
64
|
+
].join('\n');
|
|
65
|
+
export function executeKillswitchCommand(input) {
|
|
66
|
+
const action = parseKillswitchCommandAction(input.argumentsText);
|
|
67
|
+
if (action.type === 'status') {
|
|
68
|
+
const status = buildStatusTable(input.config, input.accountIds);
|
|
69
|
+
return { text: `${status}\n\n${USAGE_TEXT}` };
|
|
70
|
+
}
|
|
71
|
+
if (action.type === 'on') {
|
|
72
|
+
const updated = {
|
|
73
|
+
...input.config,
|
|
74
|
+
enabled: true,
|
|
75
|
+
main: input.config.main ?? {
|
|
76
|
+
five_hour: DEFAULT_THRESHOLDS.five_hour,
|
|
77
|
+
seven_day: DEFAULT_THRESHOLDS.seven_day,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
const status = buildStatusTable(updated, input.accountIds);
|
|
81
|
+
return {
|
|
82
|
+
text: `## Killswitch Enabled\n\n${status}`,
|
|
83
|
+
updatedConfig: updated,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (action.type === 'off') {
|
|
87
|
+
const updated = { ...input.config, enabled: false };
|
|
88
|
+
return {
|
|
89
|
+
text: '## Killswitch Disabled',
|
|
90
|
+
updatedConfig: updated,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (action.type === 'set') {
|
|
94
|
+
const accounts = { ...(input.config.accounts ?? {}) };
|
|
95
|
+
const updated = {
|
|
96
|
+
...input.config,
|
|
97
|
+
enabled: true,
|
|
98
|
+
accounts,
|
|
99
|
+
};
|
|
100
|
+
for (const entry of action.entries) {
|
|
101
|
+
const thresholds = {
|
|
102
|
+
five_hour: entry.fh,
|
|
103
|
+
seven_day: entry.sd,
|
|
104
|
+
};
|
|
105
|
+
if (entry.account === 'main') {
|
|
106
|
+
updated.main = thresholds;
|
|
107
|
+
}
|
|
108
|
+
else if (entry.account === 'all') {
|
|
109
|
+
updated.main = thresholds;
|
|
110
|
+
for (const id of input.accountIds) {
|
|
111
|
+
accounts[id] = thresholds;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
accounts[entry.account] = thresholds;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const status = buildStatusTable(updated, input.accountIds);
|
|
119
|
+
return {
|
|
120
|
+
text: `## Killswitch Updated\n\n${status}`,
|
|
121
|
+
updatedConfig: updated,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// usage
|
|
125
|
+
const status = buildStatusTable(input.config, input.accountIds);
|
|
126
|
+
return { text: `${status}\n\n${USAGE_TEXT}` };
|
|
127
|
+
}
|
package/dist/relay.js
CHANGED
|
@@ -241,6 +241,16 @@ function toBinaryChunk(data) {
|
|
|
241
241
|
function parseRelayControlMessage(data) {
|
|
242
242
|
return JSON.parse(data);
|
|
243
243
|
}
|
|
244
|
+
function formatCloseEvent(event) {
|
|
245
|
+
const close = event;
|
|
246
|
+
const parts = [
|
|
247
|
+
`code=${close.code ?? 'unknown'}`,
|
|
248
|
+
`wasClean=${close.wasClean ?? 'unknown'}`,
|
|
249
|
+
];
|
|
250
|
+
if (close.reason)
|
|
251
|
+
parts.push(`reason=${JSON.stringify(close.reason)}`);
|
|
252
|
+
return parts.join(' ');
|
|
253
|
+
}
|
|
244
254
|
function relayControlErrorMessage(message) {
|
|
245
255
|
const detail = message.message || 'relay websocket error';
|
|
246
256
|
return message.status
|
|
@@ -364,7 +374,7 @@ class PersistentRelaySession {
|
|
|
364
374
|
reject(error);
|
|
365
375
|
this.failPending(error);
|
|
366
376
|
});
|
|
367
|
-
socket.addEventListener('close', () => {
|
|
377
|
+
socket.addEventListener('close', (event) => {
|
|
368
378
|
clearTimeout(timeout);
|
|
369
379
|
if (this.socket === socket) {
|
|
370
380
|
this.socket = undefined;
|
|
@@ -374,9 +384,16 @@ class PersistentRelaySession {
|
|
|
374
384
|
if (this.pending) {
|
|
375
385
|
if (this.pending.retryingBeforeResponse)
|
|
376
386
|
return;
|
|
377
|
-
|
|
387
|
+
const closedAfterResponseStart = this.pending.responseStartedAt != null;
|
|
388
|
+
const reason = closedAfterResponseStart
|
|
389
|
+
? 'closed after response_start before stream bytes'
|
|
390
|
+
: 'closed before response';
|
|
391
|
+
if (this.retryPendingBeforeResponse(this.pending, reason, event))
|
|
378
392
|
return;
|
|
379
|
-
const error = new Error(
|
|
393
|
+
const error = new Error(closedAfterResponseStart
|
|
394
|
+
? 'relay websocket closed during response stream'
|
|
395
|
+
: 'relay websocket closed before response');
|
|
396
|
+
relayLog(`websocket ${closedAfterResponseStart ? 'closed during response stream' : 'closed before response without retry'} session=${shortAffinity(this.affinity)} request=${this.pending.payload.id} chunks=${this.pending.streamChunkCount} bytes=${this.pending.streamByteCount} ${formatCloseEvent(event)}`);
|
|
380
397
|
this.failPending(error);
|
|
381
398
|
}
|
|
382
399
|
});
|
|
@@ -410,6 +427,8 @@ class PersistentRelaySession {
|
|
|
410
427
|
rejectDone,
|
|
411
428
|
sentAt,
|
|
412
429
|
optimisticResponse,
|
|
430
|
+
streamChunkCount: 0,
|
|
431
|
+
streamByteCount: 0,
|
|
413
432
|
retryAttempts: 0,
|
|
414
433
|
retryingBeforeResponse: false,
|
|
415
434
|
};
|
|
@@ -436,8 +455,8 @@ class PersistentRelaySession {
|
|
|
436
455
|
this.socket?.close();
|
|
437
456
|
}, 45_000);
|
|
438
457
|
}
|
|
439
|
-
retryPendingBeforeResponse(pending, reason) {
|
|
440
|
-
if (pending.responseStartedAt != null)
|
|
458
|
+
retryPendingBeforeResponse(pending, reason, closeEvent) {
|
|
459
|
+
if (pending.responseStartedAt != null && pending.streamByteCount > 0)
|
|
441
460
|
return false;
|
|
442
461
|
if (pending.streamDone)
|
|
443
462
|
return false;
|
|
@@ -448,7 +467,7 @@ class PersistentRelaySession {
|
|
|
448
467
|
clearTimeout(pending.timeout);
|
|
449
468
|
const attempt = pending.retryAttempts;
|
|
450
469
|
const originalId = pending.payload.id;
|
|
451
|
-
relayLog(`websocket ${reason}; reconnecting and retrying full_sync session=${shortAffinity(this.affinity)} request=${originalId} retry=${attempt}`);
|
|
470
|
+
relayLog(`websocket ${reason}; reconnecting and retrying full_sync session=${shortAffinity(this.affinity)} request=${originalId} retry=${attempt} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}${closeEvent ? ` ${formatCloseEvent(closeEvent)}` : ''}`);
|
|
452
471
|
this.socket?.close();
|
|
453
472
|
this.socket = undefined;
|
|
454
473
|
this.serverState = undefined;
|
|
@@ -484,11 +503,16 @@ class PersistentRelaySession {
|
|
|
484
503
|
})();
|
|
485
504
|
return true;
|
|
486
505
|
}
|
|
506
|
+
enqueueStreamChunk(pending, chunk) {
|
|
507
|
+
pending.streamChunkCount += 1;
|
|
508
|
+
pending.streamByteCount += chunk.byteLength;
|
|
509
|
+
pending.streamController?.enqueue(chunk);
|
|
510
|
+
}
|
|
487
511
|
handleBinaryChunk(data) {
|
|
488
512
|
const pending = this.pending;
|
|
489
513
|
if (!pending?.responseStarted)
|
|
490
514
|
return;
|
|
491
|
-
|
|
515
|
+
this.enqueueStreamChunk(pending, toBinaryChunk(data));
|
|
492
516
|
}
|
|
493
517
|
resolvePendingResponse(pending, status, statusText, headers) {
|
|
494
518
|
if (pending.responseStarted)
|
|
@@ -542,7 +566,7 @@ class PersistentRelaySession {
|
|
|
542
566
|
return;
|
|
543
567
|
}
|
|
544
568
|
if (message.type === 'chunk') {
|
|
545
|
-
|
|
569
|
+
this.enqueueStreamChunk(pending, decodeRelayChunk(message.base64));
|
|
546
570
|
return;
|
|
547
571
|
}
|
|
548
572
|
if (message.type === 'done') {
|
|
@@ -555,7 +579,7 @@ class PersistentRelaySession {
|
|
|
555
579
|
// would reject a settled promise and leave the stream hanging. Instead,
|
|
556
580
|
// inject an SSE error event and close cleanly.
|
|
557
581
|
if (pending.optimisticResponse && pending.responseStartedAt != null) {
|
|
558
|
-
relayLog(`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'}`);
|
|
582
|
+
relayLog(`websocket relay error during optimistic response session=${shortAffinity(this.affinity)} request=${pending.payload.id} status=${message.status} message=${message.message || 'unknown'} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}`);
|
|
559
583
|
pending.streamController?.enqueue(new TextEncoder().encode(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'relay_error', message: message.message || 'relay error' } })}\n\n`));
|
|
560
584
|
this.finishPending();
|
|
561
585
|
this.socket?.close();
|
|
@@ -576,7 +600,7 @@ class PersistentRelaySession {
|
|
|
576
600
|
if (!pending)
|
|
577
601
|
return;
|
|
578
602
|
const finishedAt = perfNowMs();
|
|
579
|
-
relayLog(`perf websocket done session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(finishedAt - pending.sentAt)} streamMs=${pending.responseStartedAt == null ? 'unknown' : formatMs(finishedAt - pending.responseStartedAt)}`);
|
|
603
|
+
relayLog(`perf websocket done session=${shortAffinity(this.affinity)} request=${pending.payload.id} sentMs=${formatMs(finishedAt - pending.sentAt)} streamMs=${pending.responseStartedAt == null ? 'unknown' : formatMs(finishedAt - pending.responseStartedAt)} chunks=${pending.streamChunkCount} bytes=${pending.streamByteCount}`);
|
|
580
604
|
clearTimeout(pending.timeout);
|
|
581
605
|
if (!pending.streamDone) {
|
|
582
606
|
pending.streamDone = true;
|