cc-viewer 1.7.3 → 1.7.4
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/cli.js +88 -17
- package/dist/assets/App-CKvCavmF.js +2 -0
- package/dist/assets/App-Dj37fluO.css +1 -0
- package/dist/assets/{MdxEditorPanel-D_dewIIc.js → MdxEditorPanel-DeSJp_ct.js} +1 -1
- package/dist/assets/{Mobile-BIzb7vpA.js → Mobile-CWou-jtF.js} +1 -1
- package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-BK7Nhp_x.js} +1 -1
- package/dist/assets/{arc-D4iFrlhb.js → arc-Cm4HMXz6.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-Co8QCowN.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-Cudecf7x.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-4JeFoCHX.js} +1 -1
- package/dist/assets/{channel-B3VthT0m.js → channel-K7eT7Hjr.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-BBjl6EVW.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-DBP9YnwO.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6--A_i9gwR.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-UBsL-3qr.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-DBO0asmI.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DOCcEaE6.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-B-j8QbQh.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-B0WO1U-U.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +1 -0
- package/dist/assets/clone-DjURvTeN.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DQgpss_a.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-CCJBKmKv.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-DgUcnfkU.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CRpVvexw.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-C3IhpsM-.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-BaVbsrwt.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-BxRoBUR8.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-Du2lIzoM.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-DR1pDqLK.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DHAmE20O.js} +1 -1
- package/dist/assets/{graph-BR2KmES9.js → graph-D8Yv0IU1.js} +1 -1
- package/dist/assets/{index-C-be29ey.js → index-AOmFG_I_.js} +1 -1
- package/dist/assets/{index-D5tSeMU7.js → index-B6ZWIxY0.js} +1 -1
- package/dist/assets/{index-D5YruOKD.js → index-Bq95HjYM.js} +1 -1
- package/dist/assets/{index--wgRW_OB.js → index-D0pvxpcl.js} +1 -1
- package/dist/assets/{index-DuZQ6FqR.js → index-D2c77__8.js} +1 -1
- package/dist/assets/{index-C16fiVhv.js → index-DgtUyP57.js} +1 -1
- package/dist/assets/{index-DidP9FCD.js → index-Dpu92rw_.js} +2 -2
- package/dist/assets/{index-OWzSX2T8.js → index-cCB-lhEO.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-BAqRIm2G.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-CzH4tezb.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Qs4eiyuI.js} +1 -1
- package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-C0OccDsk.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-BAjl0gpV.js} +1 -1
- package/dist/assets/{layout-DQl4aw09.js → layout-Cf6cd9xE.js} +1 -1
- package/dist/assets/{linear-CLD3aPlK.js → linear-PzdCkji6.js} +1 -1
- package/dist/assets/mermaid.core-DV9745Lq.js +7 -0
- package/dist/assets/{min-_D86henJ.js → min-CHgWxbzS.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-CHUjMdpi.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-D_r2QC0f.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-DPzGygdN.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-BA2ynnGb.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-XHVvXuqU.js} +1 -1
- package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
- package/dist/assets/seqResourceLoaders-kBEzcKLh.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-C47sA8YH.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-BOmer839.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-CTAKVZgs.js} +1 -1
- package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-BuJ6oz45.js} +2 -2
- package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-D2KAW4ty.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-BsFVVUcj.js} +2 -2
- package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-BpxSq04x.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-an4WvAvS.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-CuXzoroh.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CFCmxnck.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-gfhDB9SR.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-X-pj66qI.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/i18n.js +100 -55
- package/server/interceptor.js +27 -1
- package/server/lib/ccswitch-import.js +251 -0
- package/server/lib/proxy-retry.js +716 -0
- package/server/proxy.js +115 -11
- package/server/routes/preferences.js +184 -1
- package/server/server.js +12 -1
- package/src/utils/contentFilter.js +17 -1
- package/dist/assets/App-DRsuJXEw.js +0 -2
- package/dist/assets/App-dYPa5-df.css +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
- package/dist/assets/clone-BQtvy9Ae.js +0 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
- package/dist/assets/seqResourceLoaders-BtGpfGRW.js +0 -2
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
// Proxy Retry Engine — proxy retry core engine.
|
|
2
|
+
//
|
|
3
|
+
// Reimplements the proxy retry capability of llm-retry-proxy (Python) in Node.js within cc-viewer.
|
|
4
|
+
// Three modes: serial (serial retry) / race (request racing) / stagger (rolling race). off = no retry.
|
|
5
|
+
//
|
|
6
|
+
// Key integration constraints (see .omo/plans/proxy-retry-stats.md C1-C5):
|
|
7
|
+
// - The fetch called by the retry engine carries the `x-cc-viewer-trace` header and goes through the
|
|
8
|
+
// interceptor's recording branch (interceptor.js); each retry attempt is recorded to the session log
|
|
9
|
+
// (the data source of the network view). Model replacement is done by this engine via the pure
|
|
10
|
+
// function resolveProfileModel before the call; the interceptor runs resolveProfileModel again on the
|
|
11
|
+
// trace request — since the model in the body has already been replaced with the final value, the second
|
|
12
|
+
// resolve returns null (idempotent no-op), so no double replacement occurs. Writing one session log entry
|
|
13
|
+
// per retry attempt is expected: the network view can see every attempt.
|
|
14
|
+
// - Network proxy: fetchOptions still passes dispatcher = getProxyDispatcher(), ensuring the user's http_proxy is used.
|
|
15
|
+
// - Streaming responses: only read status + headers to decide whether to retry; never retry after the body has
|
|
16
|
+
// started being sent (retry-before-first-byte strategy).
|
|
17
|
+
// - race/stagger use AbortController; cancelled requests must be released correctly.
|
|
18
|
+
import { resolveProfileModel } from './interceptor-core.js';
|
|
19
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
// ── Configuration ─────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
// Runtime hot-swappable config file path. Injected by interceptor.js after _logDir initialization
|
|
24
|
+
// (setRetryConfigPath), mirroring the hot-swap pattern of PROFILE_PATH: UI changes config → writes this
|
|
25
|
+
// file → watchFile 1.5s triggers _loadRetryConfigState. Initially null: before the path is ready,
|
|
26
|
+
// resolveRetryConfig uses only env (backward compatible with the legacy ccv-retry.sh workflow).
|
|
27
|
+
let _retryConfigPath = null;
|
|
28
|
+
/** @param {string|null} p */
|
|
29
|
+
export function setRetryConfigPath(p) { _retryConfigPath = p; }
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default retry config. Aligned with the .env defaults of llm-retry-proxy.
|
|
33
|
+
* mode=off ensures backward compatibility (no retry, consistent with existing proxy.js behavior).
|
|
34
|
+
* Note: the "recommended" values from ccv-retry.sh are serial/maxConcurrent=4 (safest), which differ
|
|
35
|
+
* semantically from the code defaults here (off/10) — the UI "restore defaults" returns to the code
|
|
36
|
+
* defaults here (off = no retry), not to the script's recommended values.
|
|
37
|
+
*/
|
|
38
|
+
export const DEFAULT_RETRY_CONFIG = {
|
|
39
|
+
mode: 'off', // 'off' | 'serial' | 'race' | 'stagger'
|
|
40
|
+
retryStatusCodes: [502, 503, 504, 529, 429],
|
|
41
|
+
retryIntervalMs: 1000, // retry interval for 503/502/504/529
|
|
42
|
+
retryInterval429Ms: 5000, // interval specific to 429
|
|
43
|
+
maxRetries: 60, // 0 = retry indefinitely until success (capped by maxRetryDurationMs total duration)
|
|
44
|
+
maxConcurrent: 10, // max in-flight requests for race/stagger
|
|
45
|
+
connectTimeoutMs: 10000, // upstream connection timeout
|
|
46
|
+
streamIdleTimeoutMs: 300000, // max gap between two data chunks in streaming (reserved field, not consumed in current version)
|
|
47
|
+
maxRetryDurationMs: 4 * 60 * 60 * 1000, // total retry duration cap per request (4 hours): safety net when maxRetries=0
|
|
48
|
+
// (infinite), prevents unattended tasks from hanging forever under sustained upstream congestion;
|
|
49
|
+
// explicit finite maxRetries is unaffected
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const VALID_MODES = ['off', 'serial', 'race', 'stagger'];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Computes the adaptive backoff interval for consecutive 429s in stagger mode (exponential growth, capped at 64x).
|
|
56
|
+
* When 429s occur consecutively, progressively lengthen the resend interval to avoid continuously hammering an
|
|
57
|
+
* already rate-limited upstream; non-429 still uses retryIntervalMs. If the upstream returns Retry-After,
|
|
58
|
+
* parseRetryAfter already uses it preferentially in computeWaitMs; this function is only used when there is no Retry-After.
|
|
59
|
+
*/
|
|
60
|
+
function compute429BackoffMs(cfg, consecutive429) {
|
|
61
|
+
const base = cfg.retryInterval429Ms > 0 ? cfg.retryInterval429Ms : 5000;
|
|
62
|
+
const exp = Math.min(consecutive429, 6); // cap at 2^6 = 64x, avoid unbounded growth
|
|
63
|
+
return base * (2 ** exp);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validates and normalizes a single raw config field. Shared by resolveRetryConfig (reading env) and
|
|
68
|
+
* retryConfigPost (reading UI body), ensuring both entry points share identical validation logic (single source
|
|
69
|
+
* of truth) to avoid drift. Returns the normalized value; returns undefined when invalid (callers using spread
|
|
70
|
+
* ignore undefined fields, equivalent to falling back to the default).
|
|
71
|
+
*
|
|
72
|
+
* @param {string} key field name
|
|
73
|
+
* @param {any} raw raw value
|
|
74
|
+
* @returns {any} normalized value or undefined
|
|
75
|
+
*/
|
|
76
|
+
export function validateRetryField(key, raw) {
|
|
77
|
+
switch (key) {
|
|
78
|
+
case 'mode':
|
|
79
|
+
return typeof raw === 'string' && VALID_MODES.includes(raw.toLowerCase()) ? raw.toLowerCase() : undefined;
|
|
80
|
+
case 'retryStatusCodes': {
|
|
81
|
+
if (!Array.isArray(raw)) return undefined;
|
|
82
|
+
const codes = raw
|
|
83
|
+
.map(s => (typeof s === 'number' ? s : parseInt(String(s).trim(), 10)))
|
|
84
|
+
.filter(n => Number.isFinite(n) && n > 0);
|
|
85
|
+
return codes.length ? codes : undefined;
|
|
86
|
+
}
|
|
87
|
+
case 'retryIntervalMs':
|
|
88
|
+
case 'retryInterval429Ms':
|
|
89
|
+
case 'connectTimeoutMs':
|
|
90
|
+
case 'streamIdleTimeoutMs':
|
|
91
|
+
case 'maxRetryDurationMs': {
|
|
92
|
+
const n = typeof raw === 'number' ? raw : parseFloat(raw);
|
|
93
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
94
|
+
}
|
|
95
|
+
case 'maxRetries': {
|
|
96
|
+
const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
|
|
97
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
98
|
+
}
|
|
99
|
+
case 'maxConcurrent': {
|
|
100
|
+
const n = typeof raw === 'number' ? raw : parseInt(raw, 10);
|
|
101
|
+
return Number.isFinite(n) && n >= 1 ? n : undefined;
|
|
102
|
+
}
|
|
103
|
+
default:
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validates an entire raw config object, returning a normalized config containing only valid fields (can be
|
|
110
|
+
* merged directly into DEFAULT_RETRY_CONFIG).
|
|
111
|
+
* @param {object} raw
|
|
112
|
+
* @returns {object} normalized config (may be an empty object)
|
|
113
|
+
*/
|
|
114
|
+
export function validateRetryConfig(raw) {
|
|
115
|
+
const out = {};
|
|
116
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
117
|
+
for (const key of Object.keys(DEFAULT_RETRY_CONFIG)) {
|
|
118
|
+
if (!(key in raw)) continue;
|
|
119
|
+
const v = validateRetryField(key, raw[key]);
|
|
120
|
+
if (v !== undefined) out[key] = v;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parses retry config from environment variables. Called by server.js at startup.
|
|
127
|
+
* Environment variable prefix CCV_PROXY_RETRY_* (connect/streamIdle use CCV_PROXY_CONNECT_TIMEOUT_MS /
|
|
128
|
+
* CCV_PROXY_STREAM_IDLE_TIMEOUT_MS, which were previously gaps, now filled in).
|
|
129
|
+
*
|
|
130
|
+
* @param {object} env environment variable object (defaults to process.env)
|
|
131
|
+
* @param {{ fileOverride?: boolean }} [options] when fileOverride=true, also overrides env with retry-config.json
|
|
132
|
+
* @returns {object} merged retry config
|
|
133
|
+
*/
|
|
134
|
+
export function resolveRetryConfig(env = process.env, options = {}) {
|
|
135
|
+
const { fileOverride = false } = options;
|
|
136
|
+
const cfg = { ...DEFAULT_RETRY_CONFIG };
|
|
137
|
+
|
|
138
|
+
// env layer
|
|
139
|
+
const envRaw = {
|
|
140
|
+
mode: env.CCV_PROXY_RETRY_MODE,
|
|
141
|
+
retryStatusCodes: env.CCV_PROXY_RETRY_STATUS_CODES,
|
|
142
|
+
retryIntervalMs: env.CCV_PROXY_RETRY_INTERVAL_MS,
|
|
143
|
+
retryInterval429Ms: env.CCV_PROXY_RETRY_INTERVAL_429_MS,
|
|
144
|
+
maxRetries: env.CCV_PROXY_MAX_RETRIES,
|
|
145
|
+
maxConcurrent: env.CCV_PROXY_MAX_CONCURRENT,
|
|
146
|
+
connectTimeoutMs: env.CCV_PROXY_CONNECT_TIMEOUT_MS,
|
|
147
|
+
streamIdleTimeoutMs: env.CCV_PROXY_STREAM_IDLE_TIMEOUT_MS,
|
|
148
|
+
maxRetryDurationMs: env.CCV_PROXY_RETRY_DURATION_MS,
|
|
149
|
+
};
|
|
150
|
+
// retryStatusCodes is a comma-separated string in env; convert to array before passing to validateRetryField
|
|
151
|
+
if (typeof envRaw.retryStatusCodes === 'string' && envRaw.retryStatusCodes.trim()) {
|
|
152
|
+
envRaw.retryStatusCodes = envRaw.retryStatusCodes.split(',');
|
|
153
|
+
} else {
|
|
154
|
+
delete envRaw.retryStatusCodes;
|
|
155
|
+
}
|
|
156
|
+
Object.assign(cfg, validateRetryConfig(envRaw));
|
|
157
|
+
|
|
158
|
+
// File override layer (retry-config.json written by the UI takes precedence over env; env remains the startup default/fallback)
|
|
159
|
+
if (fileOverride && _retryConfigPath) {
|
|
160
|
+
try {
|
|
161
|
+
if (existsSync(_retryConfigPath)) {
|
|
162
|
+
const fileRaw = JSON.parse(readFileSync(_retryConfigPath, 'utf-8'));
|
|
163
|
+
Object.assign(cfg, validateRetryConfig(fileRaw));
|
|
164
|
+
}
|
|
165
|
+
} catch { /* file missing/corrupt → use env only, don't block */ }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return cfg;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Loads the currently effective retry config (env base + file override). Called by the watchFile callback
|
|
173
|
+
* and live binding consumers.
|
|
174
|
+
* @returns {object}
|
|
175
|
+
*/
|
|
176
|
+
export function loadRetryConfig() {
|
|
177
|
+
return resolveRetryConfig(process.env, { fileOverride: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Retry-After parsing ───────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parses the Retry-After header. Supports seconds ("120") and HTTP date ("Wed, 21 Oct 2026 07:28:00 GMT").
|
|
184
|
+
* @param {string|null|undefined} headerValue
|
|
185
|
+
* @returns {number|null} wait duration in milliseconds; null when unparseable
|
|
186
|
+
*/
|
|
187
|
+
export function parseRetryAfter(headerValue) {
|
|
188
|
+
if (!headerValue || typeof headerValue !== 'string') return null;
|
|
189
|
+
const s = headerValue.trim();
|
|
190
|
+
if (!s) return null;
|
|
191
|
+
// pure number = seconds
|
|
192
|
+
if (/^\d+$/.test(s)) {
|
|
193
|
+
const secs = parseInt(s, 10);
|
|
194
|
+
return secs >= 0 ? secs * 1000 : null;
|
|
195
|
+
}
|
|
196
|
+
// HTTP date
|
|
197
|
+
const date = Date.parse(s);
|
|
198
|
+
if (Number.isFinite(date)) {
|
|
199
|
+
const diff = date - Date.now();
|
|
200
|
+
return diff > 0 ? diff : 0; // expired also returns 0 (retry immediately), distinct from null (unrecognized)
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Utilities ────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Determines whether a status code should trigger a retry.
|
|
209
|
+
*/
|
|
210
|
+
export function shouldRetryStatus(status, retryStatusCodes) {
|
|
211
|
+
return retryStatusCodes.includes(status);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Determines whether a response is streaming (text/event-stream).
|
|
216
|
+
* Defensive: some mock/pseudo responses have headers that are not Headers instances (no .get method).
|
|
217
|
+
*/
|
|
218
|
+
export function isStreamResponse(response) {
|
|
219
|
+
try {
|
|
220
|
+
const ct = response?.headers?.get?.('content-type') || '';
|
|
221
|
+
return typeof ct === 'string' && ct.toLowerCase().includes('text/event-stream');
|
|
222
|
+
} catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Parses the model field from the request body (Buffer/string). Returns '' when unparseable.
|
|
229
|
+
*/
|
|
230
|
+
export function extractModel(body) {
|
|
231
|
+
if (!body) return '';
|
|
232
|
+
try {
|
|
233
|
+
const s = typeof body === 'string' ? body : body.toString('utf-8');
|
|
234
|
+
const obj = JSON.parse(s);
|
|
235
|
+
return typeof obj.model === 'string' ? obj.model : '';
|
|
236
|
+
} catch {
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Performs model replacement on the request body. Returns a new body (string) or the original body (nothing to replace).
|
|
243
|
+
* Reuses the pure function resolveProfileModel from interceptor-core.js.
|
|
244
|
+
*/
|
|
245
|
+
export function applyModelReplacement(body, profile) {
|
|
246
|
+
if (!body || !profile) return body;
|
|
247
|
+
try {
|
|
248
|
+
const s = typeof body === 'string' ? body : body.toString('utf-8');
|
|
249
|
+
const obj = JSON.parse(s);
|
|
250
|
+
const oldModel = typeof obj.model === 'string' ? obj.model : '';
|
|
251
|
+
if (!oldModel) return body;
|
|
252
|
+
const target = resolveProfileModel(oldModel, profile);
|
|
253
|
+
if (!target) return body;
|
|
254
|
+
obj.model = target;
|
|
255
|
+
return JSON.stringify(obj);
|
|
256
|
+
} catch {
|
|
257
|
+
return body;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Abortable sleep: resolves early (never rejects) when the signal fires, so a
|
|
263
|
+
* client disconnect interrupts retry waits instead of parking the loop for the
|
|
264
|
+
* full interval (a large upstream Retry-After would otherwise pin an abandoned
|
|
265
|
+
* request for its whole duration).
|
|
266
|
+
*/
|
|
267
|
+
function sleep(ms, signal) {
|
|
268
|
+
return new Promise(resolve => {
|
|
269
|
+
if (signal?.aborted) return resolve();
|
|
270
|
+
let timer = null;
|
|
271
|
+
const done = () => {
|
|
272
|
+
if (timer) clearTimeout(timer);
|
|
273
|
+
signal?.removeEventListener('abort', done);
|
|
274
|
+
resolve();
|
|
275
|
+
};
|
|
276
|
+
timer = setTimeout(done, ms);
|
|
277
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Never sleep past the total-duration deadline (0 = no deadline). */
|
|
282
|
+
function clampWaitToDeadline(wait, deadline) {
|
|
283
|
+
if (deadline > 0) return Math.max(0, Math.min(wait, deadline - Date.now()));
|
|
284
|
+
return wait;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Computes the wait in milliseconds until the next retry.
|
|
289
|
+
* 429 prefers Retry-After, then retryInterval429Ms; other status codes use retryIntervalMs.
|
|
290
|
+
*/
|
|
291
|
+
function computeWaitMs(status, retryAfterHeader, cfg) {
|
|
292
|
+
if (status === 429) {
|
|
293
|
+
const ra = parseRetryAfter(retryAfterHeader);
|
|
294
|
+
if (ra !== null) return ra;
|
|
295
|
+
return cfg.retryInterval429Ms;
|
|
296
|
+
}
|
|
297
|
+
return cfg.retryIntervalMs;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Single fetch wrapper ─────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Executes a single fetch request with the x-cc-viewer-trace header + network proxy dispatcher.
|
|
304
|
+
* Returns the raw Response. Does not throw (on network errors returns { __networkError: true, status: 0 }).
|
|
305
|
+
*
|
|
306
|
+
* @param {string} url full URL
|
|
307
|
+
* @param {object} fetchOptions method/headers/body
|
|
308
|
+
* @param {object} ctx { dispatcher, connectTimeoutMs, signal }
|
|
309
|
+
*/
|
|
310
|
+
async function singleFetch(url, fetchOptions, ctx) {
|
|
311
|
+
const opts = {
|
|
312
|
+
method: fetchOptions.method,
|
|
313
|
+
headers: { ...fetchOptions.headers },
|
|
314
|
+
};
|
|
315
|
+
// Add trace header so the interceptor records the request to the session log (network view data source).
|
|
316
|
+
// The interceptor performs model replacement (resolveProfileModel is idempotent, consistent with the replacement
|
|
317
|
+
// already done by the engine).
|
|
318
|
+
// Multiple retries → multiple records — this is expected: the network view can see each retry attempt.
|
|
319
|
+
opts.headers['x-cc-viewer-trace'] = 'true';
|
|
320
|
+
if (fetchOptions.body) opts.body = fetchOptions.body;
|
|
321
|
+
if (ctx.dispatcher) opts.dispatcher = ctx.dispatcher;
|
|
322
|
+
|
|
323
|
+
// Compose the fetch signal from the external signal (race/stagger loser
|
|
324
|
+
// cancellation + client disconnect) and the header-arrival timeout. The
|
|
325
|
+
// composition must stay live for the WHOLE response lifetime — the previous
|
|
326
|
+
// listener-bridge design detached the external signal the moment fetch
|
|
327
|
+
// resolved (headers in), which made a loser's already-streaming body
|
|
328
|
+
// un-cancellable: ctl.abort() no longer reached the signal fetch was holding.
|
|
329
|
+
// AbortSignal.any keeps the linkage for as long as the body exists; the
|
|
330
|
+
// finally below only clears the timeout timer, never the external linkage.
|
|
331
|
+
let timeoutTimer = null;
|
|
332
|
+
let timeoutCtl = null;
|
|
333
|
+
const signals = [];
|
|
334
|
+
if (ctx.signal) signals.push(ctx.signal);
|
|
335
|
+
if (ctx.connectTimeoutMs > 0) {
|
|
336
|
+
timeoutCtl = new AbortController();
|
|
337
|
+
timeoutTimer = setTimeout(() => timeoutCtl.abort(), ctx.connectTimeoutMs);
|
|
338
|
+
signals.push(timeoutCtl.signal);
|
|
339
|
+
}
|
|
340
|
+
if (signals.length === 1) opts.signal = signals[0];
|
|
341
|
+
else if (signals.length > 1) opts.signal = AbortSignal.any(signals);
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const response = await fetch(url, opts);
|
|
345
|
+
return response;
|
|
346
|
+
} catch (err) {
|
|
347
|
+
// Network error/timeout/cancellation → return a pseudo response; status=0 indicates an error
|
|
348
|
+
const aborted = ctx.signal?.aborted || timeoutCtl?.signal.aborted;
|
|
349
|
+
return {
|
|
350
|
+
__networkError: true,
|
|
351
|
+
__aborted: !!aborted,
|
|
352
|
+
status: 0,
|
|
353
|
+
statusText: aborted ? 'Aborted' : (err?.message || 'Network Error'),
|
|
354
|
+
headers: new Map(),
|
|
355
|
+
ok: false,
|
|
356
|
+
body: null,
|
|
357
|
+
text: async () => '',
|
|
358
|
+
json: async () => ({}),
|
|
359
|
+
};
|
|
360
|
+
} finally {
|
|
361
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Best-effort release of a response body that will never be piped to the
|
|
367
|
+
* client (failed attempts, losers of a settled race, replaced lastFailed).
|
|
368
|
+
* Leaving these undrained keeps the upstream socket out of undici's pool and
|
|
369
|
+
* pins memory for the life of the stream.
|
|
370
|
+
*/
|
|
371
|
+
function discardBody(response) {
|
|
372
|
+
try { response?.body?.cancel?.()?.catch?.(() => {}); } catch { /* best effort */ }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ── executeRequest ────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Executes a request with retries. Returns { response, attempts, retryCodes, durationMs, finalStatus, upstreamStatus, succeeded }.
|
|
379
|
+
*
|
|
380
|
+
* @param {object} params
|
|
381
|
+
* @param {string} params.url full upstream URL
|
|
382
|
+
* @param {object} params.fetchOptions { method, headers, body }
|
|
383
|
+
* @param {object} params.retryConfig retry config
|
|
384
|
+
* @param {object} params.ctx { dispatcher, profile } network proxy dispatcher + model replacement profile
|
|
385
|
+
* @returns {Promise<object>}
|
|
386
|
+
*/
|
|
387
|
+
export async function executeRequest({ url, fetchOptions, retryConfig, ctx }) {
|
|
388
|
+
const cfg = { ...DEFAULT_RETRY_CONFIG, ...(retryConfig || {}) };
|
|
389
|
+
const dispatcher = ctx?.dispatcher || null;
|
|
390
|
+
const profile = ctx?.profile || null;
|
|
391
|
+
// Client-disconnect signal from the proxy: when it fires, every in-flight
|
|
392
|
+
// attempt is aborted and no further attempts/waits are scheduled — a request
|
|
393
|
+
// whose client is gone must not keep billing the upstream for up to
|
|
394
|
+
// maxRetryDurationMs.
|
|
395
|
+
const clientSignal = ctx?.signal || null;
|
|
396
|
+
|
|
397
|
+
// Model replacement (one-time, done before retries; all retries use the same body)
|
|
398
|
+
let finalBody = fetchOptions.body;
|
|
399
|
+
if (finalBody && profile) {
|
|
400
|
+
finalBody = applyModelReplacement(finalBody, profile);
|
|
401
|
+
}
|
|
402
|
+
const finalFetchOptions = { ...fetchOptions, body: finalBody };
|
|
403
|
+
|
|
404
|
+
const startTime = Date.now();
|
|
405
|
+
const deadline = startTime + (cfg.maxRetryDurationMs > 0 ? cfg.maxRetryDurationMs : 0);
|
|
406
|
+
const retryCodes = [];
|
|
407
|
+
let attempts = 0;
|
|
408
|
+
let lastResponse = null;
|
|
409
|
+
// Real last upstream response status (distinct from finalStatus: in race/stagger full-failure fallback,
|
|
410
|
+
// the latter was once hard-coded to 503)
|
|
411
|
+
let upstreamStatus = 0;
|
|
412
|
+
|
|
413
|
+
// Backward-compat guarantee for mode 'off': the legacy proxy path awaited
|
|
414
|
+
// fetch with NO timeout at all (undici's own default only). connectTimeoutMs
|
|
415
|
+
// actually bounds time-to-HEADERS, and non-streaming completions can hold
|
|
416
|
+
// headers well past 10s — so with retry disabled we must not introduce a new
|
|
417
|
+
// failure mode. The timeout applies only when a retry mode is active.
|
|
418
|
+
const effectiveConnectTimeoutMs = cfg.mode === 'off' ? 0 : cfg.connectTimeoutMs;
|
|
419
|
+
const commonCtx = { dispatcher, connectTimeoutMs: effectiveConnectTimeoutMs };
|
|
420
|
+
|
|
421
|
+
if (cfg.mode === 'off' || cfg.mode === 'serial') {
|
|
422
|
+
// off / serial: serial retry. off = no retry (break on any status); serial = controlled by maxRetries (0=infinite, capped by deadline)
|
|
423
|
+
const isOff = cfg.mode === 'off';
|
|
424
|
+
const effectiveMax = isOff ? 0 : cfg.maxRetries;
|
|
425
|
+
while (true) {
|
|
426
|
+
// Total duration fallback: when maxRetries=0 (infinite), prevent permanent hang under sustained upstream congestion
|
|
427
|
+
if (deadline > 0 && Date.now() >= deadline) break;
|
|
428
|
+
if (clientSignal?.aborted) break;
|
|
429
|
+
|
|
430
|
+
attempts++;
|
|
431
|
+
lastResponse = await singleFetch(url, finalFetchOptions, { ...commonCtx, signal: clientSignal });
|
|
432
|
+
upstreamStatus = lastResponse.status;
|
|
433
|
+
|
|
434
|
+
// Streaming request special handling: after getting the response, first check the status
|
|
435
|
+
const status = lastResponse.status;
|
|
436
|
+
const isStream = lastResponse.headers && isStreamResponse(lastResponse);
|
|
437
|
+
|
|
438
|
+
// off mode: no retry on any status code, end immediately
|
|
439
|
+
if (isOff) break;
|
|
440
|
+
|
|
441
|
+
if (status === 0) {
|
|
442
|
+
// Network error
|
|
443
|
+
retryCodes.push(0);
|
|
444
|
+
} else if (!shouldRetryStatus(status, cfg.retryStatusCodes)) {
|
|
445
|
+
// No retry needed (success or non-retryable error code) → return
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Retry needed
|
|
450
|
+
if (status !== 0) retryCodes.push(status);
|
|
451
|
+
|
|
452
|
+
// Streaming response has already started returning 200 → never retry (body may have been sent already)
|
|
453
|
+
if (isStream && status < 400) break;
|
|
454
|
+
|
|
455
|
+
// Reached the limit (effectiveMax=0 in serial mode = infinite, no break; off already broke above)
|
|
456
|
+
if (effectiveMax > 0 && attempts > effectiveMax) break;
|
|
457
|
+
|
|
458
|
+
// Release the failed response's body BEFORE waiting — holding an
|
|
459
|
+
// undrained body across the sleep pins the upstream socket for the
|
|
460
|
+
// whole interval.
|
|
461
|
+
discardBody(lastResponse);
|
|
462
|
+
|
|
463
|
+
// Wait (abortable: a client disconnect ends it early; clamped so a huge
|
|
464
|
+
// Retry-After can never sleep past the total-duration deadline)
|
|
465
|
+
const retryAfter = lastResponse.headers?.get?.('retry-after') || lastResponse.headers?.get?.('Retry-After');
|
|
466
|
+
const wait = clampWaitToDeadline(computeWaitMs(status, retryAfter, cfg), deadline);
|
|
467
|
+
if (wait > 0) await sleep(wait, clientSignal);
|
|
468
|
+
}
|
|
469
|
+
} else if (cfg.mode === 'race') {
|
|
470
|
+
const result = await raceMode({ url, fetchOptions: finalFetchOptions, cfg, commonCtx, deadline, clientSignal });
|
|
471
|
+
attempts = result.attempts;
|
|
472
|
+
retryCodes.push(...result.retryCodes);
|
|
473
|
+
lastResponse = result.response;
|
|
474
|
+
if (result.upstreamStatus !== undefined) upstreamStatus = result.upstreamStatus;
|
|
475
|
+
} else if (cfg.mode === 'stagger') {
|
|
476
|
+
const result = await staggerMode({ url, fetchOptions: finalFetchOptions, cfg, commonCtx, deadline, clientSignal });
|
|
477
|
+
attempts = result.attempts;
|
|
478
|
+
retryCodes.push(...result.retryCodes);
|
|
479
|
+
lastResponse = result.response;
|
|
480
|
+
if (result.upstreamStatus !== undefined) upstreamStatus = result.upstreamStatus;
|
|
481
|
+
} else {
|
|
482
|
+
// Unknown mode → fallback to single attempt
|
|
483
|
+
attempts = 1;
|
|
484
|
+
lastResponse = await singleFetch(url, finalFetchOptions, { ...commonCtx });
|
|
485
|
+
upstreamStatus = lastResponse.status;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const durationMs = Date.now() - startTime;
|
|
489
|
+
const finalStatus = lastResponse?.status || 0;
|
|
490
|
+
// serial/fallback: upstreamStatus was assigned the real value in the loop; race/stagger: result brings back the real last upstream status.
|
|
491
|
+
// If not set (theoretically impossible), fall back to finalStatus.
|
|
492
|
+
if (!upstreamStatus) upstreamStatus = finalStatus;
|
|
493
|
+
const succeeded = finalStatus > 0 && finalStatus < 400;
|
|
494
|
+
const retries = Math.max(0, attempts - 1);
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
response: lastResponse,
|
|
498
|
+
attempts,
|
|
499
|
+
retries,
|
|
500
|
+
retryCodes,
|
|
501
|
+
durationMs,
|
|
502
|
+
finalStatus,
|
|
503
|
+
upstreamStatus,
|
|
504
|
+
succeeded,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── race mode: each round fans out N concurrent requests, first 200 wins ────
|
|
509
|
+
|
|
510
|
+
async function raceMode({ url, fetchOptions, cfg, commonCtx, deadline, clientSignal }) {
|
|
511
|
+
const retryCodes = [];
|
|
512
|
+
let attempts = 0;
|
|
513
|
+
let round = 0;
|
|
514
|
+
// Real last upstream response (returned on failure fallback, instead of hard-coded 503)
|
|
515
|
+
let lastFailed = null;
|
|
516
|
+
const maxRounds = cfg.maxRetries > 0 ? cfg.maxRetries : Infinity;
|
|
517
|
+
|
|
518
|
+
while (round < maxRounds) {
|
|
519
|
+
// Total duration fallback + client gone
|
|
520
|
+
if (deadline > 0 && Date.now() >= deadline) break;
|
|
521
|
+
if (clientSignal?.aborted) break;
|
|
522
|
+
|
|
523
|
+
round++;
|
|
524
|
+
// Hedged round: launch maxConcurrent attempts and resolve the round the
|
|
525
|
+
// moment ANY attempt succeeds — the winner must not wait for the slowest
|
|
526
|
+
// straggler (the old Promise.all design gated the round on the slowest
|
|
527
|
+
// header arrival and "aborted" losers only after they had already
|
|
528
|
+
// settled, i.e. never). Losers still pending are aborted immediately;
|
|
529
|
+
// late settlers after the win just have their bodies discarded.
|
|
530
|
+
const controllers = [];
|
|
531
|
+
const roundWinner = await new Promise((resolveRound) => {
|
|
532
|
+
let settled = 0;
|
|
533
|
+
let won = false;
|
|
534
|
+
for (let i = 0; i < cfg.maxConcurrent; i++) {
|
|
535
|
+
const ctl = new AbortController();
|
|
536
|
+
controllers.push(ctl);
|
|
537
|
+
const signal = clientSignal ? AbortSignal.any([clientSignal, ctl.signal]) : ctl.signal;
|
|
538
|
+
attempts++;
|
|
539
|
+
singleFetch(url, fetchOptions, { ...commonCtx, signal }).then((r) => {
|
|
540
|
+
settled++;
|
|
541
|
+
if (won) {
|
|
542
|
+
// A winner already streamed to the client — this attempt's body is unwanted.
|
|
543
|
+
discardBody(r);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (r.status > 0 && r.status < 400) {
|
|
547
|
+
won = true;
|
|
548
|
+
for (const c of controllers) {
|
|
549
|
+
if (c !== ctl) try { c.abort(); } catch { /* best effort */ }
|
|
550
|
+
}
|
|
551
|
+
resolveRound(r);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
// Failure: keep the latest REAL failed response for the final
|
|
555
|
+
// fallback (discarding the body of the one it replaces), count it.
|
|
556
|
+
if (r.status !== 0) {
|
|
557
|
+
retryCodes.push(r.status);
|
|
558
|
+
if (lastFailed) discardBody(lastFailed);
|
|
559
|
+
lastFailed = r;
|
|
560
|
+
} else {
|
|
561
|
+
retryCodes.push(0);
|
|
562
|
+
}
|
|
563
|
+
if (settled >= controllers.length) resolveRound(null);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
if (roundWinner) {
|
|
569
|
+
if (lastFailed) discardBody(lastFailed); // fallback candidate no longer needed
|
|
570
|
+
return { response: roundWinner, attempts, retryCodes, upstreamStatus: roundWinner.status };
|
|
571
|
+
}
|
|
572
|
+
if (clientSignal?.aborted) break;
|
|
573
|
+
|
|
574
|
+
// All failed → wait then next round. Base the wait on the last real
|
|
575
|
+
// failure (a network-errored attempt has empty headers and status 0 and
|
|
576
|
+
// would silently drop an upstream Retry-After carried by a sibling 429).
|
|
577
|
+
const waitSrc = lastFailed;
|
|
578
|
+
const retryAfter = waitSrc?.headers?.get?.('retry-after') || waitSrc?.headers?.get?.('Retry-After');
|
|
579
|
+
const wait = clampWaitToDeadline(computeWaitMs(waitSrc ? waitSrc.status : 0, retryAfter, cfg), deadline);
|
|
580
|
+
if (wait > 0) await sleep(wait, clientSignal);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Reached limit/timeout: return the last real failed response (preserving the real status code); fall back to 503 when there is no real failure
|
|
584
|
+
const response = lastFailed || lastFailedResponse();
|
|
585
|
+
return { response, attempts, retryCodes, upstreamStatus: response.status };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function lastFailedResponse() {
|
|
589
|
+
return { status: 503, statusText: 'Upstream Overloaded', headers: new Map(), ok: false, body: null, text: async () => 'Upstream Overloaded', json: async () => ({}) };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ── stagger mode: send interleaved, cancel in-flight on any 200 ────────
|
|
593
|
+
|
|
594
|
+
async function staggerMode({ url, fetchOptions, cfg, commonCtx, deadline, clientSignal }) {
|
|
595
|
+
const retryCodes = [];
|
|
596
|
+
let attempts = 0;
|
|
597
|
+
const inflight = []; // { ctl, promise }
|
|
598
|
+
let resolved = null;
|
|
599
|
+
let lastFailed = null; // real last failed response (returned on fallback instead of hard-coded 503)
|
|
600
|
+
let totalAttempts = 0;
|
|
601
|
+
const maxTotal = cfg.maxRetries > 0 ? cfg.maxRetries : Infinity;
|
|
602
|
+
let consecutive429 = 0;
|
|
603
|
+
|
|
604
|
+
// Timeout/limit/resolved/client-gone → stop dispatching
|
|
605
|
+
const canLaunch = () => !resolved
|
|
606
|
+
&& !clientSignal?.aborted
|
|
607
|
+
&& inflight.length < cfg.maxConcurrent
|
|
608
|
+
&& totalAttempts < maxTotal
|
|
609
|
+
&& !(deadline > 0 && Date.now() >= deadline);
|
|
610
|
+
|
|
611
|
+
function launchOne() {
|
|
612
|
+
if (!canLaunch()) return false;
|
|
613
|
+
const ctl = new AbortController();
|
|
614
|
+
const signal = clientSignal ? AbortSignal.any([clientSignal, ctl.signal]) : ctl.signal;
|
|
615
|
+
const p = singleFetch(url, fetchOptions, { ...commonCtx, signal }).then(r => {
|
|
616
|
+
// Remove self from inflight
|
|
617
|
+
const idx = inflight.findIndex(x => x.ctl === ctl);
|
|
618
|
+
if (idx >= 0) inflight.splice(idx, 1);
|
|
619
|
+
|
|
620
|
+
// Single-winner latch: two attempts can land a 200 in the same tick.
|
|
621
|
+
// Without this guard the second would overwrite `resolved` with a
|
|
622
|
+
// response whose body the first winner's cleanup just aborted — the
|
|
623
|
+
// client would receive a truncated 200.
|
|
624
|
+
if (resolved) {
|
|
625
|
+
discardBody(r);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (r.status > 0 && r.status < 400) {
|
|
629
|
+
// Success → set resolved FIRST (canLaunch relies on it), then cancel all in-flight
|
|
630
|
+
resolved = { response: r, attempts: totalAttempts, retryCodes: [...retryCodes], upstreamStatus: r.status };
|
|
631
|
+
for (const x of inflight) try { x.ctl.abort(); } catch { /* best effort */ }
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// Failure: record the real failed response (keep only the newest body
|
|
635
|
+
// for the final fallback; drain the one it replaces)
|
|
636
|
+
if (r.status !== 0) {
|
|
637
|
+
retryCodes.push(r.status);
|
|
638
|
+
if (lastFailed) discardBody(lastFailed);
|
|
639
|
+
lastFailed = r;
|
|
640
|
+
} else {
|
|
641
|
+
retryCodes.push(0);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (r.status === 429) {
|
|
645
|
+
// 429: after accumulating, the scheduler lengthens the next dispatch interval via adaptive backoff (no immediate refill)
|
|
646
|
+
consecutive429++;
|
|
647
|
+
} else {
|
|
648
|
+
consecutive429 = 0;
|
|
649
|
+
// Non-429 error → immediately dispatch a replacement (stagger semantics)
|
|
650
|
+
launchOne();
|
|
651
|
+
}
|
|
652
|
+
}).catch(() => {
|
|
653
|
+
const idx = inflight.findIndex(x => x.ctl === ctl);
|
|
654
|
+
if (idx >= 0) inflight.splice(idx, 1);
|
|
655
|
+
retryCodes.push(0);
|
|
656
|
+
});
|
|
657
|
+
inflight.push({ ctl, promise: p });
|
|
658
|
+
totalAttempts++;
|
|
659
|
+
attempts = totalAttempts;
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Current dispatch interval: adaptive backoff when 429s occur consecutively (exponential growth, avoids
|
|
664
|
+
// hammering a rate-limited upstream); otherwise uses retryIntervalMs
|
|
665
|
+
function currentStaggerInterval() {
|
|
666
|
+
if (consecutive429 > 0) return compute429BackoffMs(cfg, consecutive429);
|
|
667
|
+
return cfg.retryIntervalMs > 0 ? cfg.retryIntervalMs : 0;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Unified interleaved scheduler: the first wave is also staggered by interval (no longer synchronously
|
|
671
|
+
// dispatching a full batch of maxConcurrent at once); subsequent refills reuse the same scheduler. When there
|
|
672
|
+
// is an inflight slot and the limit/timeout has not been reached, dispatch one, then schedule the next by interval.
|
|
673
|
+
// Dispatch the first immediately (ensures no idle wait); the rest are staggered by interval.
|
|
674
|
+
let staggerTimer = null;
|
|
675
|
+
const launch = () => {
|
|
676
|
+
if (resolved) return;
|
|
677
|
+
if (clientSignal?.aborted) return;
|
|
678
|
+
if (deadline > 0 && Date.now() >= deadline) return;
|
|
679
|
+
if (canLaunch()) launchOne();
|
|
680
|
+
const interval = currentStaggerInterval();
|
|
681
|
+
if (interval > 0) {
|
|
682
|
+
staggerTimer = setTimeout(launch, interval);
|
|
683
|
+
} else if (inflight.length < cfg.maxConcurrent && totalAttempts < maxTotal && !resolved) {
|
|
684
|
+
// interval=0: still need to keep filling (use a microtask to avoid synchronous infinite recursion)
|
|
685
|
+
staggerTimer = setTimeout(launch, 0);
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
// Dispatch the first immediately, starting the staggered loop
|
|
689
|
+
launch();
|
|
690
|
+
|
|
691
|
+
// Wait for resolved, all complete, client disconnect, or total duration expiry
|
|
692
|
+
await new Promise((resolve) => {
|
|
693
|
+
const check = () => {
|
|
694
|
+
if (resolved) { resolve(); return; }
|
|
695
|
+
if (clientSignal?.aborted && inflight.length === 0) { resolve(); return; }
|
|
696
|
+
if (deadline > 0 && Date.now() >= deadline) { resolve(); return; }
|
|
697
|
+
if (inflight.length === 0 && (totalAttempts >= maxTotal || !canLaunch())) { resolve(); return; }
|
|
698
|
+
setTimeout(check, 50);
|
|
699
|
+
};
|
|
700
|
+
// A client disconnect aborts every in-flight attempt right away (their
|
|
701
|
+
// settle handlers then drain via the aborted pseudo-responses).
|
|
702
|
+
clientSignal?.addEventListener('abort', () => {
|
|
703
|
+
for (const x of inflight) try { x.ctl.abort(); } catch { /* best effort */ }
|
|
704
|
+
}, { once: true });
|
|
705
|
+
check();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
if (staggerTimer) clearTimeout(staggerTimer);
|
|
709
|
+
// Clean up any remaining in-flight
|
|
710
|
+
for (const x of inflight) try { x.ctl.abort(); } catch { /* best effort */ }
|
|
711
|
+
|
|
712
|
+
if (resolved) return resolved;
|
|
713
|
+
// Fallback: return the last real failed response (preserving the real status code); fall back to 503 when there is no real failure
|
|
714
|
+
const response = lastFailed || lastFailedResponse();
|
|
715
|
+
return { response, attempts, retryCodes, upstreamStatus: response.status };
|
|
716
|
+
}
|