swayrouter 1.0.7 → 1.0.8
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/.env.example +6 -0
- package/package.json +1 -1
- package/src/executors/antigravity.js +36 -26
- package/src/executors/base.js +32 -29
- package/src/executors/qoder.js +14 -7
- package/src/handlers/chatCore/streamingHandler.js +5 -4
- package/src/handlers/chatCore.js +45 -16
- package/src/lib/env.ts +18 -0
- package/src/providers/registry/codebuddy-intl.js +1 -1
- package/src/services/thoughtSignatureStore.js +49 -0
- package/src/sse/handlers/chat.js +48 -28
- package/src/translator/response/gemini-to-openai.js +20 -4
- package/src/utils/stream.js +11 -9
- package/src/utils/upstreamTimeout.js +67 -0
package/.env.example
CHANGED
|
@@ -41,3 +41,9 @@ PORT=14045
|
|
|
41
41
|
# Advanced performance overrides intentionally live in README.md so the
|
|
42
42
|
# normal setup stays small. Automatic resource profiles detect memory/CPU and
|
|
43
43
|
# bound concurrency, queues, request bodies, retries, and usage retention.
|
|
44
|
+
# The upstream routing guardrails below are also optional. Defaults are 3 account
|
|
45
|
+
# attempts per request, 15 seconds per upstream attempt, and a 45-second Sway
|
|
46
|
+
# deadline before a fast gateway timeout.
|
|
47
|
+
#SWAY_MAX_ACCOUNT_ATTEMPTS=3
|
|
48
|
+
#SWAY_UPSTREAM_ATTEMPT_TIMEOUT_MS=15000
|
|
49
|
+
#SWAY_UPSTREAM_DEADLINE_MS=45000
|
package/package.json
CHANGED
|
@@ -4,9 +4,10 @@ import { PROVIDERS } from "../config/providers.js";
|
|
|
4
4
|
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS } from "../config/appConstants.js";
|
|
5
5
|
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
|
6
6
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
|
7
|
-
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
|
8
|
-
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
|
|
9
|
-
import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
|
|
7
|
+
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
|
8
|
+
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
|
|
9
|
+
import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
|
|
10
|
+
import { getGeminiThoughtSignatureSync } from "../services/thoughtSignatureStore.js";
|
|
10
11
|
|
|
11
12
|
function sanitizeFunctionName(name) {
|
|
12
13
|
if (!name) return "_unknown";
|
|
@@ -186,17 +187,26 @@ export class AntigravityExecutor extends BaseExecutor {
|
|
|
186
187
|
return true;
|
|
187
188
|
});
|
|
188
189
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
190
|
+
let firstFunctionCallSeen = false;
|
|
191
|
+
const modifiedParts = parts?.map(p => {
|
|
192
|
+
if (!p.functionCall) return p;
|
|
193
|
+
const callId = p.functionCall.id;
|
|
194
|
+
const cachedSig = callId
|
|
195
|
+
? getGeminiThoughtSignatureSync(callId, sessionId, body.model || model)
|
|
196
|
+
: null;
|
|
197
|
+
const callSig = p.thoughtSignature
|
|
198
|
+
|| cachedSig
|
|
199
|
+
|| (!firstFunctionCallSeen ? DEFAULT_THINKING_AG_SIGNATURE : undefined);
|
|
200
|
+
firstFunctionCallSeen = true;
|
|
201
|
+
return callSig ? { ...p, thoughtSignature: callSig } : p;
|
|
202
|
+
});
|
|
203
|
+
const needsBackfill = modifiedParts?.some((p, index) => p !== parts[index]) ?? false;
|
|
204
|
+
if (role !== c.role || parts?.length !== c.parts?.length || needsBackfill) {
|
|
205
|
+
return {
|
|
206
|
+
...c, role,
|
|
207
|
+
parts: modifiedParts || parts,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
200
210
|
return c;
|
|
201
211
|
});
|
|
202
212
|
|
|
@@ -240,18 +250,18 @@ export class AntigravityExecutor extends BaseExecutor {
|
|
|
240
250
|
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
|
|
241
251
|
};
|
|
242
252
|
|
|
243
|
-
stripBlacklisted(body);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
|
|
254
|
-
request: transformedRequest
|
|
253
|
+
stripBlacklisted(body);
|
|
254
|
+
delete body.requestType;
|
|
255
|
+
|
|
256
|
+
this._lastSessionId = transformedRequest.sessionId;
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
...body,
|
|
260
|
+
project: projectId,
|
|
261
|
+
model: body.model || model,
|
|
262
|
+
userAgent: "antigravity",
|
|
263
|
+
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
|
|
264
|
+
request: transformedRequest
|
|
255
265
|
};
|
|
256
266
|
}
|
|
257
267
|
|
package/src/executors/base.js
CHANGED
|
@@ -3,8 +3,9 @@ import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"
|
|
|
3
3
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
|
4
4
|
import { dbg } from "../utils/debugLog.js";
|
|
5
5
|
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
|
|
6
|
-
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
|
|
7
|
-
import { env } from "@/lib/env";
|
|
6
|
+
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
|
|
7
|
+
import { env } from "@/lib/env";
|
|
8
|
+
import { createUpstreamAttemptSignal } from "../utils/upstreamTimeout.js";
|
|
8
9
|
|
|
9
10
|
export function waitForRetry(delayMs, signal) {
|
|
10
11
|
if (!delayMs || delayMs <= 0) {
|
|
@@ -124,7 +125,7 @@ export class BaseExecutor {
|
|
|
124
125
|
return { status: response.status, message: bodyText || `HTTP ${response.status}` };
|
|
125
126
|
}
|
|
126
127
|
|
|
127
|
-
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, requestTag = "", attemptReason = "initial", maxTotalAttempts = env.upstreamMaxAttempts }) {
|
|
128
|
+
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, requestTag = "", attemptReason = "initial", maxTotalAttempts = env.upstreamMaxAttempts, attemptTimeoutMs = env.upstreamAttemptTimeoutMs, deadlineAt = null }) {
|
|
128
129
|
const fallbackCount = this.getFallbackCount();
|
|
129
130
|
const totalAttemptLimit = Math.max(1, Number(maxTotalAttempts) || env.upstreamMaxAttempts || 8);
|
|
130
131
|
let attempt = 0;
|
|
@@ -157,27 +158,28 @@ export class BaseExecutor {
|
|
|
157
158
|
|
|
158
159
|
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
|
159
160
|
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
161
|
+
const configuredTimeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
|
|
162
|
+
const attemptControl = createUpstreamAttemptSignal({
|
|
163
|
+
signal,
|
|
164
|
+
timeoutMs: Math.min(configuredTimeoutMs, attemptTimeoutMs || env.upstreamAttemptTimeoutMs),
|
|
165
|
+
deadlineAt,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
attempt++;
|
|
167
170
|
const bodyStr = JSON.stringify(transformedBody);
|
|
168
171
|
const fetchT0 = Date.now();
|
|
169
172
|
const attemptMeta = `attempt=${attempt} · reason=${attemptReason}`;
|
|
170
173
|
let upstreamHost = url;
|
|
171
174
|
try { upstreamHost = new URL(url).host || url; } catch { }
|
|
172
175
|
log?.line?.(requestTag, "FETCH", `${this.provider.toUpperCase()} → ${upstreamHost} · ${attemptMeta}`);
|
|
173
|
-
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B |
|
|
174
|
-
const response = await proxyAwareFetch(url, {
|
|
175
|
-
method: "POST",
|
|
176
|
-
headers,
|
|
177
|
-
body: bodyStr,
|
|
178
|
-
signal:
|
|
179
|
-
}, proxyOptions);
|
|
180
|
-
clearTimeout(connectTimer);
|
|
176
|
+
dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | attemptTimeout=${attemptControl.timeoutMs}ms | ${attemptMeta}`);
|
|
177
|
+
const response = await proxyAwareFetch(url, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers,
|
|
180
|
+
body: bodyStr,
|
|
181
|
+
signal: attemptControl.signal
|
|
182
|
+
}, proxyOptions);
|
|
181
183
|
const ct = response.headers?.get?.("content-type") || "";
|
|
182
184
|
const cl = response.headers?.get?.("content-length") || "?";
|
|
183
185
|
const responseLabel = response.status >= 400 ? "ERROR" : "RESPONSE";
|
|
@@ -198,15 +200,14 @@ export class BaseExecutor {
|
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
return { response, url, headers, transformedBody };
|
|
201
|
-
} catch (error) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (signal?.aborted) throw signal.reason || error;
|
|
205
|
-
lastError = error;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (error.name === "AbortError" && !isConnectTimeout) throw error;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (attemptControl.wasTimeout()) throw attemptControl.timeoutError;
|
|
205
|
+
|
|
206
|
+
if (signal?.aborted) throw signal.reason || error;
|
|
207
|
+
lastError = error;
|
|
208
|
+
dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}`);
|
|
209
|
+
|
|
210
|
+
if (error.name === "AbortError") throw error;
|
|
210
211
|
|
|
211
212
|
if (await tryRetry(urlIndex, HTTP_STATUS.BAD_GATEWAY, `network "${error.message}"`)) { urlIndex--; continue; }
|
|
212
213
|
|
|
@@ -214,8 +215,10 @@ export class BaseExecutor {
|
|
|
214
215
|
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
|
|
215
216
|
continue;
|
|
216
217
|
}
|
|
217
|
-
throw error;
|
|
218
|
-
}
|
|
218
|
+
throw error;
|
|
219
|
+
} finally {
|
|
220
|
+
attemptControl.cleanup();
|
|
221
|
+
}
|
|
219
222
|
}
|
|
220
223
|
|
|
221
224
|
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`);
|
package/src/executors/qoder.js
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
QODER_MODEL_MAP,
|
|
16
16
|
} from "../shared/qoder/constants.js";
|
|
17
17
|
import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js";
|
|
18
|
+
import { createUpstreamAttemptSignal } from "../utils/upstreamTimeout.js";
|
|
19
|
+
import { env } from "@/lib/env";
|
|
18
20
|
|
|
19
21
|
function normalizeMessages(messages) {
|
|
20
22
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
@@ -305,7 +307,7 @@ export class QoderExecutor extends BaseExecutor {
|
|
|
305
307
|
return QODER_CHAT_URL_ENCODED;
|
|
306
308
|
}
|
|
307
309
|
|
|
308
|
-
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
|
310
|
+
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null, attemptTimeoutMs = env.upstreamAttemptTimeoutMs, deadlineAt = null }) {
|
|
309
311
|
|
|
310
312
|
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
|
311
313
|
if (isQoderPat(rawToken)) {
|
|
@@ -390,20 +392,25 @@ export class QoderExecutor extends BaseExecutor {
|
|
|
390
392
|
...cosyHeaders,
|
|
391
393
|
};
|
|
392
394
|
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
const configuredTimeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
|
|
396
|
+
const attemptControl = createUpstreamAttemptSignal({
|
|
397
|
+
signal,
|
|
398
|
+
timeoutMs: Math.min(configuredTimeoutMs, attemptTimeoutMs || env.upstreamAttemptTimeoutMs),
|
|
399
|
+
deadlineAt,
|
|
400
|
+
});
|
|
397
401
|
|
|
398
402
|
let response;
|
|
399
403
|
try {
|
|
400
404
|
response = await proxyAwareFetch(
|
|
401
405
|
url,
|
|
402
|
-
{ method: "POST", headers, body: encodedBodyBuf, signal:
|
|
406
|
+
{ method: "POST", headers, body: encodedBodyBuf, signal: attemptControl.signal },
|
|
403
407
|
proxyOptions,
|
|
404
408
|
);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (attemptControl.wasTimeout()) throw attemptControl.timeoutError;
|
|
411
|
+
throw error;
|
|
405
412
|
} finally {
|
|
406
|
-
|
|
413
|
+
attemptControl.cleanup();
|
|
407
414
|
}
|
|
408
415
|
|
|
409
416
|
if (!response.ok) {
|
|
@@ -89,7 +89,7 @@ export async function coerceOpenAIJsonToSSE(providerResponse, model, provider =
|
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey }) {
|
|
92
|
+
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, sessionId }) {
|
|
93
93
|
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
|
|
94
94
|
|
|
95
95
|
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
|
|
@@ -97,11 +97,11 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
|
|
|
97
97
|
|
|
98
98
|
if (needsCodexTranslation) {
|
|
99
99
|
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
|
|
100
|
-
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
|
|
100
|
+
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, sessionId);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
if (needsTranslation(targetFormat, sourceFormat)) {
|
|
104
|
-
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames);
|
|
104
|
+
return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey, customToolNames, sessionId);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey);
|
|
@@ -151,7 +151,8 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
|
|
|
151
151
|
};
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
const
|
|
154
|
+
const sessionId = finalBody?.request?.sessionId || translatedBody?.request?.sessionId || body?.request?.sessionId || null;
|
|
155
|
+
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, customToolNames, model, connectionId, body, onStreamComplete, apiKey, sessionId });
|
|
155
156
|
|
|
156
157
|
const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES;
|
|
157
158
|
const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null;
|
package/src/handlers/chatCore.js
CHANGED
|
@@ -33,6 +33,8 @@ import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
|
|
|
33
33
|
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
|
|
34
34
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
|
35
35
|
import { normalizeTranslationRequest, resolveTranslationSurface, unsupportedFeatures } from "../translator/canonical.js";
|
|
36
|
+
import { executeWithAttemptDeadline, isUpstreamTimeoutError } from "../utils/upstreamTimeout.js";
|
|
37
|
+
import { env } from "@/lib/env";
|
|
36
38
|
|
|
37
39
|
export function stripContinuityFields(body) {
|
|
38
40
|
if (!body || !Array.isArray(body.messages)) return body;
|
|
@@ -45,7 +47,7 @@ export function stripContinuityFields(body) {
|
|
|
45
47
|
return body;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking, requestSignal }) {
|
|
50
|
+
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking, requestSignal, deadlineAt = null }) {
|
|
49
51
|
const { provider, model, modelCapabilities } = modelInfo;
|
|
50
52
|
const requestStartTime = Date.now();
|
|
51
53
|
let failureRecorded = false;
|
|
@@ -315,6 +317,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
315
317
|
signal: streamController.signal, log, proxyOptions,
|
|
316
318
|
requestTag: reqTag,
|
|
317
319
|
attemptReason: "initial",
|
|
320
|
+
attemptTimeoutMs: env.upstreamAttemptTimeoutMs,
|
|
321
|
+
deadlineAt,
|
|
318
322
|
};
|
|
319
323
|
trackPendingRequest(model, provider, connectionId, true);
|
|
320
324
|
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });
|
|
@@ -352,7 +356,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
352
356
|
|
|
353
357
|
let providerResponseFormat = targetFormat;
|
|
354
358
|
try {
|
|
355
|
-
const result = await
|
|
359
|
+
const result = await executeWithAttemptDeadline(
|
|
360
|
+
(signal) => executor.execute({ ...executeOptions, signal }),
|
|
361
|
+
{ signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
|
|
362
|
+
);
|
|
356
363
|
providerResponse = result.response;
|
|
357
364
|
providerUrl = result.url;
|
|
358
365
|
providerHeaders = result.headers;
|
|
@@ -361,19 +368,24 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
361
368
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
362
369
|
} catch (error) {
|
|
363
370
|
trackPendingRequest(model, provider, connectionId, false, true);
|
|
364
|
-
const
|
|
365
|
-
|
|
371
|
+
const timeout = isUpstreamTimeoutError(error);
|
|
372
|
+
const failedStatus = recordFailure(error.message || String(error), timeout ? HTTP_STATUS.GATEWAY_TIMEOUT : error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY);
|
|
373
|
+
appendRequestLog({ model, provider, connectionId, status: `FAILED ${timeout ? HTTP_STATUS.GATEWAY_TIMEOUT : error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
|
366
374
|
saveRequestDetail(buildRequestDetail({
|
|
367
375
|
provider, model, connectionId,
|
|
368
376
|
latency: { ttft: 0, total: Date.now() - requestStartTime },
|
|
369
377
|
tokens: { prompt_tokens: 0, completion_tokens: 0 },
|
|
370
378
|
request: extractRequestConfig(body, stream),
|
|
371
379
|
providerRequest: translatedBody || null,
|
|
372
|
-
response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null },
|
|
380
|
+
response: { error: error.message || String(error), status: timeout ? HTTP_STATUS.GATEWAY_TIMEOUT : error.name === "AbortError" ? 499 : 502, thinking: null },
|
|
373
381
|
status: "error",
|
|
374
382
|
client: { id: clientMeta.id, confidence: clientMeta.confidence, signals: clientMeta.signals, conflicts: clientMeta.conflicts }
|
|
375
383
|
}), clientRawRequest).catch(() => { });
|
|
376
384
|
|
|
385
|
+
if (timeout) {
|
|
386
|
+
streamController.handleError(error);
|
|
387
|
+
return createErrorResult(HTTP_STATUS.GATEWAY_TIMEOUT, "Upstream request timed out");
|
|
388
|
+
}
|
|
377
389
|
if (error.name === "AbortError") {
|
|
378
390
|
streamController.handleError(error);
|
|
379
391
|
return createErrorResult(499, "Request aborted");
|
|
@@ -403,7 +415,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
403
415
|
try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); }
|
|
404
416
|
}
|
|
405
417
|
try {
|
|
406
|
-
const retryResult = await
|
|
418
|
+
const retryResult = await executeWithAttemptDeadline(
|
|
419
|
+
(signal) => executor.execute({ ...executeOptions, signal, attemptReason: "oauth-refresh" }),
|
|
420
|
+
{ signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
|
|
421
|
+
);
|
|
407
422
|
if (retryResult.response.ok) {
|
|
408
423
|
providerResponse = retryResult.response;
|
|
409
424
|
providerUrl = retryResult.url;
|
|
@@ -448,6 +463,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
448
463
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
|
449
464
|
}
|
|
450
465
|
} catch (e) {
|
|
466
|
+
if (isUpstreamTimeoutError(e)) throw e;
|
|
451
467
|
if (log?.warn) log.warn("A4.4", `retry after strip "${rejectedField}" threw: ${e?.message || e}`);
|
|
452
468
|
}
|
|
453
469
|
} else if (apply.retryable && !apply.removed) {
|
|
@@ -517,10 +533,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
517
533
|
}
|
|
518
534
|
log?.line?.(reqTag, "RETRY", `A4.3 pre-content socket close · ${provider}/${model}`);
|
|
519
535
|
try {
|
|
520
|
-
const retryResp = await
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
536
|
+
const retryResp = await executeWithAttemptDeadline(
|
|
537
|
+
(signal) => executor.execute({
|
|
538
|
+
...executeOptions,
|
|
539
|
+
signal,
|
|
540
|
+
attemptReason: "stream-preview-network",
|
|
541
|
+
}),
|
|
542
|
+
{ signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
|
|
543
|
+
);
|
|
524
544
|
if (retryResp?.response?.ok) {
|
|
525
545
|
providerResponse = retryResp.response;
|
|
526
546
|
providerUrl = retryResp.url;
|
|
@@ -541,6 +561,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
541
561
|
);
|
|
542
562
|
}
|
|
543
563
|
} catch (error) {
|
|
564
|
+
if (isUpstreamTimeoutError(error)) {
|
|
565
|
+
recordFailure(error?.message || String(error), HTTP_STATUS.GATEWAY_TIMEOUT);
|
|
566
|
+
return createErrorResult(HTTP_STATUS.GATEWAY_TIMEOUT, "Upstream request timed out");
|
|
567
|
+
}
|
|
544
568
|
if (requestSignal?.aborted || error?.name === "AbortError") {
|
|
545
569
|
recordFailure("Request aborted", 499);
|
|
546
570
|
return createErrorResult(499, "Request aborted");
|
|
@@ -578,10 +602,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
578
602
|
recordFailure("Request aborted", 499);
|
|
579
603
|
return createErrorResult(499, "Request aborted");
|
|
580
604
|
}
|
|
581
|
-
const retryResp = await
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
605
|
+
const retryResp = await executeWithAttemptDeadline(
|
|
606
|
+
(signal) => executor.execute({
|
|
607
|
+
...executeOptions,
|
|
608
|
+
signal,
|
|
609
|
+
attemptReason: "stream-preview",
|
|
610
|
+
}),
|
|
611
|
+
{ signal: executeOptions.signal, timeoutMs: env.upstreamAttemptTimeoutMs, deadlineAt },
|
|
612
|
+
);
|
|
585
613
|
if (retryResp?.response?.ok) {
|
|
586
614
|
providerResponse = retryResp.response;
|
|
587
615
|
providerUrl = retryResp.url;
|
|
@@ -604,8 +632,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|
|
604
632
|
} catch (e) {
|
|
605
633
|
log?.warn?.("A4.3", `retry threw: ${e?.message || e}`);
|
|
606
634
|
|
|
607
|
-
|
|
608
|
-
|
|
635
|
+
const status = isUpstreamTimeoutError(e) ? HTTP_STATUS.GATEWAY_TIMEOUT : HTTP_STATUS.BAD_GATEWAY;
|
|
636
|
+
recordFailure(e?.message || String(e), status);
|
|
637
|
+
return createErrorResult(status, isUpstreamTimeoutError(e) ? "Upstream request timed out" : formatProviderError(e, provider, model, status));
|
|
609
638
|
}
|
|
610
639
|
} else if (preview && (preview.kind === "committed" || preview.kind === "timeout" || preview.kind === "streamClosed")) {
|
|
611
640
|
|
package/src/lib/env.ts
CHANGED
|
@@ -39,6 +39,9 @@ export interface EnvConfig {
|
|
|
39
39
|
usageHistoryMaxRows: number;
|
|
40
40
|
|
|
41
41
|
upstreamMaxAttempts: number;
|
|
42
|
+
upstreamAttemptTimeoutMs: number;
|
|
43
|
+
upstreamDeadlineMs: number;
|
|
44
|
+
maxAccountAttempts: number;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const DEFAULT_PASSWORD = "123456";
|
|
@@ -161,6 +164,21 @@ export function loadEnv(source: Record<string, string | undefined> = process.env
|
|
|
161
164
|
nodeEnv === "production" ? resource.upstreamMaxAttempts : 8,
|
|
162
165
|
100,
|
|
163
166
|
),
|
|
167
|
+
upstreamAttemptTimeoutMs: parsePositiveInt(
|
|
168
|
+
source.SWAY_UPSTREAM_ATTEMPT_TIMEOUT_MS,
|
|
169
|
+
15_000,
|
|
170
|
+
10 * 60 * 1000,
|
|
171
|
+
),
|
|
172
|
+
upstreamDeadlineMs: parsePositiveInt(
|
|
173
|
+
source.SWAY_UPSTREAM_DEADLINE_MS,
|
|
174
|
+
45_000,
|
|
175
|
+
10 * 60 * 1000,
|
|
176
|
+
),
|
|
177
|
+
maxAccountAttempts: parsePositiveInt(
|
|
178
|
+
source.SWAY_MAX_ACCOUNT_ATTEMPTS,
|
|
179
|
+
3,
|
|
180
|
+
100,
|
|
181
|
+
),
|
|
164
182
|
});
|
|
165
183
|
}
|
|
166
184
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const MAX_ENTRIES = 4096;
|
|
2
|
+
const TTL_MS = 30 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
const signatures = new Map();
|
|
5
|
+
|
|
6
|
+
function keyFor(callId, sessionId, model) {
|
|
7
|
+
return `${String(sessionId)}\u0000${String(model)}\u0000${String(callId)}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isUsable(value) {
|
|
11
|
+
return typeof value === "string" && value.length > 0 && value.length <= 64 * 1024;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function prune(now = Date.now()) {
|
|
15
|
+
for (const [key, entry] of signatures) {
|
|
16
|
+
if (entry.expiresAt <= now) signatures.delete(key);
|
|
17
|
+
}
|
|
18
|
+
while (signatures.size > MAX_ENTRIES) {
|
|
19
|
+
const oldest = signatures.keys().next().value;
|
|
20
|
+
if (oldest === undefined) break;
|
|
21
|
+
signatures.delete(oldest);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function rememberGeminiThoughtSignature(callId, sessionId, model, signature) {
|
|
26
|
+
if (!isUsable(callId) || !isUsable(sessionId) || !isUsable(model) || !isUsable(signature)) return;
|
|
27
|
+
const key = keyFor(callId, sessionId, model);
|
|
28
|
+
signatures.delete(key);
|
|
29
|
+
signatures.set(key, { signature, expiresAt: Date.now() + TTL_MS });
|
|
30
|
+
prune();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getGeminiThoughtSignatureSync(callId, sessionId, model) {
|
|
34
|
+
if (!isUsable(callId) || !isUsable(sessionId) || !isUsable(model)) return null;
|
|
35
|
+
const key = keyFor(callId, sessionId, model);
|
|
36
|
+
const entry = signatures.get(key);
|
|
37
|
+
if (!entry) return null;
|
|
38
|
+
if (entry.expiresAt <= Date.now()) {
|
|
39
|
+
signatures.delete(key);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
signatures.delete(key);
|
|
43
|
+
signatures.set(key, entry);
|
|
44
|
+
return entry.signature;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function clearGeminiThoughtSignatures() {
|
|
48
|
+
signatures.clear();
|
|
49
|
+
}
|
package/src/sse/handlers/chat.js
CHANGED
|
@@ -240,10 +240,11 @@ export async function handleChat(request, clientRawRequest = null) {
|
|
|
240
240
|
return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey, preferredConnectionId, request?.signal);
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, preferredConnectionId = null, requestSignal = null) {
|
|
244
|
-
requestSignal ||= request?.signal || null;
|
|
245
|
-
if (requestSignal?.aborted) return errorResponse(499, "Request aborted");
|
|
246
|
-
const
|
|
243
|
+
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null, preferredConnectionId = null, requestSignal = null) {
|
|
244
|
+
requestSignal ||= request?.signal || null;
|
|
245
|
+
if (requestSignal?.aborted) return errorResponse(499, "Request aborted");
|
|
246
|
+
const deadlineAt = Date.now() + env.upstreamDeadlineMs;
|
|
247
|
+
const incomingClient = clientRawRequest?.headers
|
|
247
248
|
? Object.fromEntries(clientRawRequest.headers.entries?.() || [])
|
|
248
249
|
: {};
|
|
249
250
|
const settings = await getSettings();
|
|
@@ -311,12 +312,17 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|
|
311
312
|
|
|
312
313
|
const excludeConnectionIds = new Set();
|
|
313
314
|
let lastError = null;
|
|
314
|
-
let lastStatus = null;
|
|
315
|
-
|
|
316
|
-
const cacheKey = extractCacheKey(body);
|
|
317
|
-
let retryCount = 0;
|
|
318
|
-
|
|
319
|
-
|
|
315
|
+
let lastStatus = null;
|
|
316
|
+
|
|
317
|
+
const cacheKey = extractCacheKey(body);
|
|
318
|
+
let retryCount = 0;
|
|
319
|
+
let accountAttempts = 0;
|
|
320
|
+
|
|
321
|
+
while (accountAttempts < env.maxAccountAttempts) {
|
|
322
|
+
if (Date.now() >= deadlineAt) {
|
|
323
|
+
return errorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, "Sway Router upstream deadline exceeded");
|
|
324
|
+
}
|
|
325
|
+
|
|
320
326
|
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, {
|
|
321
327
|
cacheKey,
|
|
322
328
|
preferredConnectionId,
|
|
@@ -348,9 +354,10 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|
|
348
354
|
credentials.retryAfter,
|
|
349
355
|
credentials.retryAfterHuman || "retry shortly",
|
|
350
356
|
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
accountAttempts += 1;
|
|
360
|
+
const releaseAccountSlot = credentials.releaseAccountSlot;
|
|
354
361
|
let result;
|
|
355
362
|
try {
|
|
356
363
|
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
|
|
@@ -381,8 +388,9 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|
|
381
388
|
cavemanLevel: chatSettings.cavemanLevel || "full",
|
|
382
389
|
ponytailEnabled: !!chatSettings.ponytailEnabled,
|
|
383
390
|
ponytailLevel: chatSettings.ponytailLevel || "full",
|
|
384
|
-
providerThinking,
|
|
385
|
-
requestSignal,
|
|
391
|
+
providerThinking,
|
|
392
|
+
requestSignal,
|
|
393
|
+
deadlineAt,
|
|
386
394
|
|
|
387
395
|
sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null,
|
|
388
396
|
onCredentialsRefreshed: async (newCreds) => {
|
|
@@ -406,21 +414,33 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
|
|
|
406
414
|
|
|
407
415
|
releaseAccountSlot?.();
|
|
408
416
|
|
|
409
|
-
if (isCallerCancellation({ requestSignal, status: result.status })) {
|
|
410
|
-
return result.response;
|
|
411
|
-
}
|
|
417
|
+
if (isCallerCancellation({ requestSignal, status: result.status })) {
|
|
418
|
+
return result.response;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (Date.now() >= deadlineAt) {
|
|
422
|
+
return result.response.status === HTTP_STATUS.GATEWAY_TIMEOUT
|
|
423
|
+
? result.response
|
|
424
|
+
: errorResponse(HTTP_STATUS.GATEWAY_TIMEOUT, "Sway Router upstream deadline exceeded");
|
|
425
|
+
}
|
|
412
426
|
|
|
413
427
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs, { retryCount });
|
|
414
428
|
|
|
415
|
-
if (shouldFallback) {
|
|
416
|
-
log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
|
|
417
|
-
excludeConnectionIds.add(credentials.connectionId);
|
|
418
|
-
lastError = result.error;
|
|
419
|
-
lastStatus = result.status;
|
|
420
|
-
retryCount += 1;
|
|
421
|
-
|
|
429
|
+
if (shouldFallback) {
|
|
430
|
+
log.warn("FALLBACK", `⇄ ACC:${credentials.connectionName} UNAVAILABLE (${result.status}) → NEXT ACCOUNT`);
|
|
431
|
+
excludeConnectionIds.add(credentials.connectionId);
|
|
432
|
+
lastError = result.error;
|
|
433
|
+
lastStatus = result.status;
|
|
434
|
+
retryCount += 1;
|
|
435
|
+
if (accountAttempts >= env.maxAccountAttempts) {
|
|
436
|
+
log.warn("FALLBACK", `[${provider}/${model}] account attempt limit reached (${env.maxAccountAttempts})`);
|
|
437
|
+
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
422
440
|
}
|
|
423
441
|
|
|
424
|
-
return result.response;
|
|
425
|
-
}
|
|
426
|
-
|
|
442
|
+
return result.response;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return errorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, "Provider account fallback limit reached; retry shortly");
|
|
446
|
+
}
|
|
@@ -5,7 +5,8 @@ import { buildChunk } from "../concerns/chunk.js";
|
|
|
5
5
|
import { toOpenAIUsage } from "../concerns/usage.js";
|
|
6
6
|
import { reasoningDelta } from "../concerns/reasoning.js";
|
|
7
7
|
import { encodeDataUri } from "../concerns/image.js";
|
|
8
|
-
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
|
8
|
+
import { toOpenAIFinish } from "../concerns/finishReason.js";
|
|
9
|
+
import { rememberGeminiThoughtSignature } from "../../services/thoughtSignatureStore.js";
|
|
9
10
|
|
|
10
11
|
function chunkMeta(state) {
|
|
11
12
|
return { id: `chatcmpl-${state.messageId}`, created: Math.floor(Date.now() / 1000), model: state.model };
|
|
@@ -47,9 +48,24 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
if (content?.parts) {
|
|
50
|
-
for (const part of content.parts) {
|
|
51
|
-
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
|
52
|
-
const isThought = part.thought === true;
|
|
51
|
+
for (const part of content.parts) {
|
|
52
|
+
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
|
53
|
+
const isThought = part.thought === true;
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
state.provider === "antigravity"
|
|
57
|
+
&& state.sessionId
|
|
58
|
+
&& (state.signatureModel || state.model)
|
|
59
|
+
&& part.functionCall?.id
|
|
60
|
+
&& hasThoughtSig
|
|
61
|
+
) {
|
|
62
|
+
rememberGeminiThoughtSignature(
|
|
63
|
+
part.functionCall.id,
|
|
64
|
+
state.sessionId,
|
|
65
|
+
state.signatureModel || state.model,
|
|
66
|
+
hasThoughtSig,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
53
69
|
|
|
54
70
|
if (hasThoughtSig) {
|
|
55
71
|
const hasTextContent = part.text !== undefined && part.text !== "";
|
package/src/utils/stream.js
CHANGED
|
@@ -101,9 +101,10 @@ export function createSSEStream(options = {}) {
|
|
|
101
101
|
customToolNames = null,
|
|
102
102
|
model = null,
|
|
103
103
|
connectionId = null,
|
|
104
|
-
body = null,
|
|
105
|
-
onStreamComplete = null,
|
|
106
|
-
apiKey = null
|
|
104
|
+
body = null,
|
|
105
|
+
onStreamComplete = null,
|
|
106
|
+
apiKey = null,
|
|
107
|
+
sessionId = null
|
|
107
108
|
} = options;
|
|
108
109
|
|
|
109
110
|
let buffer = "";
|
|
@@ -111,8 +112,8 @@ export function createSSEStream(options = {}) {
|
|
|
111
112
|
|
|
112
113
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
|
113
114
|
|
|
114
|
-
const state = mode === STREAM_MODE.TRANSLATE
|
|
115
|
-
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
|
|
115
|
+
const state = mode === STREAM_MODE.TRANSLATE
|
|
116
|
+
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, signatureModel: model, sessionId }
|
|
116
117
|
: null;
|
|
117
118
|
|
|
118
119
|
let totalContentLength = 0;
|
|
@@ -480,7 +481,7 @@ export function createSSEStream(options = {}) {
|
|
|
480
481
|
});
|
|
481
482
|
}
|
|
482
483
|
|
|
483
|
-
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
|
|
484
|
+
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null, sessionId = null) {
|
|
484
485
|
return createSSEStream({
|
|
485
486
|
mode: STREAM_MODE.TRANSLATE,
|
|
486
487
|
targetFormat,
|
|
@@ -493,9 +494,10 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
|
|
|
493
494
|
connectionId,
|
|
494
495
|
body,
|
|
495
496
|
onStreamComplete,
|
|
496
|
-
apiKey
|
|
497
|
-
|
|
498
|
-
}
|
|
497
|
+
apiKey,
|
|
498
|
+
sessionId
|
|
499
|
+
});
|
|
500
|
+
}
|
|
499
501
|
|
|
500
502
|
export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) {
|
|
501
503
|
return createSSEStream({
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_ATTEMPT_TIMEOUT_MS = 15_000;
|
|
2
|
+
|
|
3
|
+
export class UpstreamTimeoutError extends Error {
|
|
4
|
+
constructor(message, { kind = "attempt", timeoutMs = 0 } = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "UpstreamTimeoutError";
|
|
7
|
+
this.code = "UPSTREAM_TIMEOUT";
|
|
8
|
+
this.kind = kind;
|
|
9
|
+
this.timeoutMs = timeoutMs;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function isUpstreamTimeoutError(error) {
|
|
13
|
+
return error?.name === "UpstreamTimeoutError" || error?.code === "UPSTREAM_TIMEOUT";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveTimeoutMs(value) {
|
|
17
|
+
const parsed = Number(value);
|
|
18
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ATTEMPT_TIMEOUT_MS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createUpstreamAttemptSignal({ signal = null, timeoutMs, deadlineAt = null } = {}) {
|
|
22
|
+
const configuredTimeoutMs = resolveTimeoutMs(timeoutMs);
|
|
23
|
+
const remainingMs = deadlineAt == null ? Infinity : Number(deadlineAt) - Date.now();
|
|
24
|
+
|
|
25
|
+
if (remainingMs <= 0) {
|
|
26
|
+
throw new UpstreamTimeoutError("Upstream request deadline exceeded", {
|
|
27
|
+
kind: "deadline",
|
|
28
|
+
timeoutMs: 0,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const budgetMs = Math.max(1, Math.min(configuredTimeoutMs, remainingMs));
|
|
33
|
+
const kind = Number.isFinite(remainingMs) && remainingMs <= configuredTimeoutMs
|
|
34
|
+
? "deadline"
|
|
35
|
+
: "attempt";
|
|
36
|
+
const timeoutError = new UpstreamTimeoutError(
|
|
37
|
+
kind === "deadline"
|
|
38
|
+
? "Upstream request deadline exceeded"
|
|
39
|
+
: `Upstream attempt timed out after ${Math.ceil(budgetMs)}ms`,
|
|
40
|
+
{ kind, timeoutMs: budgetMs },
|
|
41
|
+
);
|
|
42
|
+
const timeoutController = new AbortController();
|
|
43
|
+
const mergedSignal = signal
|
|
44
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
45
|
+
: timeoutController.signal;
|
|
46
|
+
const timer = setTimeout(() => timeoutController.abort(timeoutError), budgetMs);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
signal: mergedSignal,
|
|
50
|
+
timeoutError,
|
|
51
|
+
timeoutMs: budgetMs,
|
|
52
|
+
wasTimeout: () => timeoutController.signal.aborted && !signal?.aborted,
|
|
53
|
+
cleanup: () => clearTimeout(timer),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function executeWithAttemptDeadline(execute, options = {}) {
|
|
58
|
+
const attempt = createUpstreamAttemptSignal(options);
|
|
59
|
+
try {
|
|
60
|
+
return await execute(attempt.signal, attempt.timeoutMs);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (attempt.wasTimeout()) throw attempt.timeoutError;
|
|
63
|
+
throw error;
|
|
64
|
+
} finally {
|
|
65
|
+
attempt.cleanup();
|
|
66
|
+
}
|
|
67
|
+
}
|