@cortexkit/anthropic-auth-core 1.5.0 → 1.6.1
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 +49 -11
- 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
|
@@ -4,6 +4,20 @@ import { dumpRelayRequest } from "./dump.js";
|
|
|
4
4
|
import { relayLog } from "./logger.js";
|
|
5
5
|
class RelayStateMismatchError extends Error {
|
|
6
6
|
}
|
|
7
|
+
class RelayWebSocketConnectionResetError extends Error {
|
|
8
|
+
code = 'ECONNRESET';
|
|
9
|
+
syscall = 'websocket';
|
|
10
|
+
closeCode;
|
|
11
|
+
closeReason;
|
|
12
|
+
wasClean;
|
|
13
|
+
constructor(message, event) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'RelayWebSocketConnectionResetError';
|
|
16
|
+
this.closeCode = event?.code;
|
|
17
|
+
this.closeReason = event?.reason;
|
|
18
|
+
this.wasClean = event?.wasClean;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
7
21
|
const sessionState = new Map();
|
|
8
22
|
const websocketSessions = new Map();
|
|
9
23
|
const loggedRelayConfigMessages = new Set();
|
|
@@ -241,6 +255,16 @@ function toBinaryChunk(data) {
|
|
|
241
255
|
function parseRelayControlMessage(data) {
|
|
242
256
|
return JSON.parse(data);
|
|
243
257
|
}
|
|
258
|
+
function formatCloseEvent(event) {
|
|
259
|
+
const close = event;
|
|
260
|
+
const parts = [
|
|
261
|
+
`code=${close.code ?? 'unknown'}`,
|
|
262
|
+
`wasClean=${close.wasClean ?? 'unknown'}`,
|
|
263
|
+
];
|
|
264
|
+
if (close.reason)
|
|
265
|
+
parts.push(`reason=${JSON.stringify(close.reason)}`);
|
|
266
|
+
return parts.join(' ');
|
|
267
|
+
}
|
|
244
268
|
function relayControlErrorMessage(message) {
|
|
245
269
|
const detail = message.message || 'relay websocket error';
|
|
246
270
|
return message.status
|
|
@@ -360,11 +384,11 @@ class PersistentRelaySession {
|
|
|
360
384
|
});
|
|
361
385
|
socket.addEventListener('error', () => {
|
|
362
386
|
clearTimeout(timeout);
|
|
363
|
-
const error = new
|
|
387
|
+
const error = new RelayWebSocketConnectionResetError('relay websocket error');
|
|
364
388
|
reject(error);
|
|
365
389
|
this.failPending(error);
|
|
366
390
|
});
|
|
367
|
-
socket.addEventListener('close', () => {
|
|
391
|
+
socket.addEventListener('close', (event) => {
|
|
368
392
|
clearTimeout(timeout);
|
|
369
393
|
if (this.socket === socket) {
|
|
370
394
|
this.socket = undefined;
|
|
@@ -374,9 +398,16 @@ class PersistentRelaySession {
|
|
|
374
398
|
if (this.pending) {
|
|
375
399
|
if (this.pending.retryingBeforeResponse)
|
|
376
400
|
return;
|
|
377
|
-
|
|
401
|
+
const closedAfterResponseStart = this.pending.responseStartedAt != null;
|
|
402
|
+
const reason = closedAfterResponseStart
|
|
403
|
+
? 'closed after response_start before stream bytes'
|
|
404
|
+
: 'closed before response';
|
|
405
|
+
if (this.retryPendingBeforeResponse(this.pending, reason, event))
|
|
378
406
|
return;
|
|
379
|
-
const error =
|
|
407
|
+
const error = closedAfterResponseStart
|
|
408
|
+
? new RelayWebSocketConnectionResetError('relay websocket closed during response stream', event)
|
|
409
|
+
: new Error('relay websocket closed before response');
|
|
410
|
+
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
411
|
this.failPending(error);
|
|
381
412
|
}
|
|
382
413
|
});
|
|
@@ -410,6 +441,8 @@ class PersistentRelaySession {
|
|
|
410
441
|
rejectDone,
|
|
411
442
|
sentAt,
|
|
412
443
|
optimisticResponse,
|
|
444
|
+
streamChunkCount: 0,
|
|
445
|
+
streamByteCount: 0,
|
|
413
446
|
retryAttempts: 0,
|
|
414
447
|
retryingBeforeResponse: false,
|
|
415
448
|
};
|
|
@@ -436,8 +469,8 @@ class PersistentRelaySession {
|
|
|
436
469
|
this.socket?.close();
|
|
437
470
|
}, 45_000);
|
|
438
471
|
}
|
|
439
|
-
retryPendingBeforeResponse(pending, reason) {
|
|
440
|
-
if (pending.responseStartedAt != null)
|
|
472
|
+
retryPendingBeforeResponse(pending, reason, closeEvent) {
|
|
473
|
+
if (pending.responseStartedAt != null && pending.streamByteCount > 0)
|
|
441
474
|
return false;
|
|
442
475
|
if (pending.streamDone)
|
|
443
476
|
return false;
|
|
@@ -448,7 +481,7 @@ class PersistentRelaySession {
|
|
|
448
481
|
clearTimeout(pending.timeout);
|
|
449
482
|
const attempt = pending.retryAttempts;
|
|
450
483
|
const originalId = pending.payload.id;
|
|
451
|
-
relayLog(`websocket ${reason}; reconnecting and retrying full_sync session=${shortAffinity(this.affinity)} request=${originalId} retry=${attempt}`);
|
|
484
|
+
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
485
|
this.socket?.close();
|
|
453
486
|
this.socket = undefined;
|
|
454
487
|
this.serverState = undefined;
|
|
@@ -484,11 +517,16 @@ class PersistentRelaySession {
|
|
|
484
517
|
})();
|
|
485
518
|
return true;
|
|
486
519
|
}
|
|
520
|
+
enqueueStreamChunk(pending, chunk) {
|
|
521
|
+
pending.streamChunkCount += 1;
|
|
522
|
+
pending.streamByteCount += chunk.byteLength;
|
|
523
|
+
pending.streamController?.enqueue(chunk);
|
|
524
|
+
}
|
|
487
525
|
handleBinaryChunk(data) {
|
|
488
526
|
const pending = this.pending;
|
|
489
527
|
if (!pending?.responseStarted)
|
|
490
528
|
return;
|
|
491
|
-
|
|
529
|
+
this.enqueueStreamChunk(pending, toBinaryChunk(data));
|
|
492
530
|
}
|
|
493
531
|
resolvePendingResponse(pending, status, statusText, headers) {
|
|
494
532
|
if (pending.responseStarted)
|
|
@@ -542,7 +580,7 @@ class PersistentRelaySession {
|
|
|
542
580
|
return;
|
|
543
581
|
}
|
|
544
582
|
if (message.type === 'chunk') {
|
|
545
|
-
|
|
583
|
+
this.enqueueStreamChunk(pending, decodeRelayChunk(message.base64));
|
|
546
584
|
return;
|
|
547
585
|
}
|
|
548
586
|
if (message.type === 'done') {
|
|
@@ -555,7 +593,7 @@ class PersistentRelaySession {
|
|
|
555
593
|
// would reject a settled promise and leave the stream hanging. Instead,
|
|
556
594
|
// inject an SSE error event and close cleanly.
|
|
557
595
|
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'}`);
|
|
596
|
+
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
597
|
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
598
|
this.finishPending();
|
|
561
599
|
this.socket?.close();
|
|
@@ -576,7 +614,7 @@ class PersistentRelaySession {
|
|
|
576
614
|
if (!pending)
|
|
577
615
|
return;
|
|
578
616
|
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)}`);
|
|
617
|
+
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
618
|
clearTimeout(pending.timeout);
|
|
581
619
|
if (!pending.streamDone) {
|
|
582
620
|
pending.streamDone = true;
|