babelgate 0.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +467 -1
- package/dist/app.js +40 -0
- package/dist/cli.js +203 -0
- package/dist/config/env-config.js +99 -0
- package/dist/index.js +60 -0
- package/dist/middleware/error-handler.js +158 -0
- package/dist/middleware/request-logger.js +66 -0
- package/dist/middleware/require-api-key.js +80 -0
- package/dist/middleware/resolve-source.js +28 -0
- package/dist/routes/proxy.js +74 -0
- package/dist/services/http-client.js +220 -0
- package/dist/services/proxy.service.js +329 -0
- package/dist/services/stream-pipeline.js +119 -0
- package/dist/services/upstream-errors.js +34 -0
- package/dist/transformers/adapters/anthropic.adapter.js +292 -0
- package/dist/transformers/adapters/anthropic.messages.js +261 -0
- package/dist/transformers/adapters/anthropic.stream.js +378 -0
- package/dist/transformers/adapters/gemini.adapter.js +418 -0
- package/dist/transformers/adapters/gemini.schema.js +61 -0
- package/dist/transformers/adapters/gemini.stream.js +341 -0
- package/dist/transformers/adapters/gemini.tools.js +246 -0
- package/dist/transformers/adapters/index.js +141 -0
- package/dist/transformers/adapters/ollama.adapter.js +288 -0
- package/dist/transformers/adapters/ollama.stream.js +294 -0
- package/dist/transformers/adapters/ollama.tools.js +107 -0
- package/dist/transformers/adapters/openai.adapter.js +460 -0
- package/dist/transformers/adapters/openai.stream.js +229 -0
- package/dist/transformers/index.js +110 -0
- package/dist/types/anthropic.types.js +2 -0
- package/dist/types/canonical.schema.js +160 -0
- package/dist/types/canonical.types.js +8 -0
- package/dist/types/gemini.types.js +2 -0
- package/dist/types/ollama.types.js +4 -0
- package/dist/types/openai.types.js +2 -0
- package/dist/utils/canonical-error.js +77 -0
- package/dist/utils/errors.js +95 -0
- package/dist/utils/ids.js +17 -0
- package/dist/utils/logger.js +21 -0
- package/dist/utils/model-map.js +39 -0
- package/dist/utils/sse.js +147 -0
- package/dist/utils/stream-reassemble.js +104 -0
- package/dist/utils/token-estimate.js +36 -0
- package/dist/utils/tool-args.js +26 -0
- package/package.json +54 -7
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractClientKeys = extractClientKeys;
|
|
4
|
+
exports.requireApiKey = requireApiKey;
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const errors_1 = require("../utils/errors");
|
|
7
|
+
/**
|
|
8
|
+
* Timing-safe string comparison — returns false for length mismatches
|
|
9
|
+
* without leaking length via early exit.
|
|
10
|
+
*/
|
|
11
|
+
function safeEqual(a, b) {
|
|
12
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
13
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
14
|
+
if (aBuf.length !== bBuf.length)
|
|
15
|
+
return false;
|
|
16
|
+
return (0, crypto_1.timingSafeEqual)(aBuf, bBuf);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Every credential a client may have presented (spec §3.4): the
|
|
20
|
+
* `Authorization: Bearer` token (scheme case-insensitive), `x-api-key`,
|
|
21
|
+
* `x-goog-api-key` and the `key` query parameter — covering the OpenAI,
|
|
22
|
+
* Anthropic and Gemini SDKs' native auth styles. Empty values are not
|
|
23
|
+
* candidates. Exported for tests.
|
|
24
|
+
*/
|
|
25
|
+
function extractClientKeys(req) {
|
|
26
|
+
const keys = [];
|
|
27
|
+
const collect = (value) => {
|
|
28
|
+
if (typeof value === "string" && value.length > 0) {
|
|
29
|
+
keys.push(value);
|
|
30
|
+
}
|
|
31
|
+
else if (Array.isArray(value)) {
|
|
32
|
+
value.forEach(collect);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const authorization = req.headers.authorization;
|
|
36
|
+
for (const value of Array.isArray(authorization)
|
|
37
|
+
? authorization
|
|
38
|
+
: [authorization]) {
|
|
39
|
+
if (typeof value !== "string")
|
|
40
|
+
continue;
|
|
41
|
+
const match = /^bearer\s+(.+)$/i.exec(value);
|
|
42
|
+
if (match)
|
|
43
|
+
collect(match[1].trim());
|
|
44
|
+
}
|
|
45
|
+
collect(req.headers["x-api-key"]);
|
|
46
|
+
collect(req.headers["x-goog-api-key"]);
|
|
47
|
+
collect(req.query?.key);
|
|
48
|
+
return keys;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Client-facing auth for proxied endpoints.
|
|
52
|
+
*
|
|
53
|
+
* Enabled only when PROXY_API_KEY is set — unset means auth disabled
|
|
54
|
+
* (localhost/dev stays frictionless). Accepts the key in any of the
|
|
55
|
+
* provider-native styles (see extractClientKeys) so client SDKs work
|
|
56
|
+
* unchanged. Every candidate is compared with safeEqual; any match
|
|
57
|
+
* wins. These credentials are never forwarded upstream — the upstream
|
|
58
|
+
* gets the proxy's own key.
|
|
59
|
+
*
|
|
60
|
+
* Applied after /health in app.ts so health probes stay open.
|
|
61
|
+
*/
|
|
62
|
+
function requireApiKey(apiKey) {
|
|
63
|
+
// Auth disabled — pass everything through
|
|
64
|
+
if (!apiKey) {
|
|
65
|
+
return (_req, _res, next) => next();
|
|
66
|
+
}
|
|
67
|
+
return (req, _res, next) => {
|
|
68
|
+
// Compare every candidate; never return early on a mismatch —
|
|
69
|
+
// any match authenticates.
|
|
70
|
+
let authenticated = false;
|
|
71
|
+
for (const candidate of extractClientKeys(req)) {
|
|
72
|
+
if (safeEqual(candidate, apiKey))
|
|
73
|
+
authenticated = true;
|
|
74
|
+
}
|
|
75
|
+
if (!authenticated) {
|
|
76
|
+
return next(new errors_1.AuthError("Invalid or missing API key", { requestId: req.id }));
|
|
77
|
+
}
|
|
78
|
+
next();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveSource = resolveSource;
|
|
4
|
+
const env_config_1 = require("../config/env-config");
|
|
5
|
+
const adapters_1 = require("../transformers/adapters");
|
|
6
|
+
/**
|
|
7
|
+
* Records the configured source provider on `res.locals.sourceApi` when
|
|
8
|
+
* the request matches one of its routes, so the error handler can render
|
|
9
|
+
* errors — including auth and body-parser errors — in that provider's
|
|
10
|
+
* dialect (spec §3.3). Mounted before the body parser and auth.
|
|
11
|
+
*
|
|
12
|
+
* Never throws: a broken config just leaves `sourceApi` unset and errors
|
|
13
|
+
* keep the generic shape.
|
|
14
|
+
*/
|
|
15
|
+
function resolveSource() {
|
|
16
|
+
return (req, res, next) => {
|
|
17
|
+
try {
|
|
18
|
+
const { sourceApi } = (0, env_config_1.getConfig)();
|
|
19
|
+
if ((0, adapters_1.findSourceRoute)(sourceApi, req.method, req.path)) {
|
|
20
|
+
res.locals.sourceApi = sourceApi;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// config unavailable — sourceApi stays unset
|
|
25
|
+
}
|
|
26
|
+
next();
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.proxyRouter = void 0;
|
|
4
|
+
const node_events_1 = require("node:events");
|
|
5
|
+
const express_1 = require("express");
|
|
6
|
+
const proxy_service_1 = require("../services/proxy.service");
|
|
7
|
+
const errors_1 = require("../utils/errors");
|
|
8
|
+
/**
|
|
9
|
+
* Thin HTTP adapter — all orchestration lives in proxy.service.
|
|
10
|
+
* Returns null from the service → next() → 404 handler.
|
|
11
|
+
*/
|
|
12
|
+
exports.proxyRouter = (0, express_1.Router)();
|
|
13
|
+
exports.proxyRouter.use(async (req, res, next) => {
|
|
14
|
+
// Client disconnect mid-request → abort the upstream call. On a
|
|
15
|
+
// completed response "close" still fires, so guard on completion.
|
|
16
|
+
const ac = new AbortController();
|
|
17
|
+
let completed = false;
|
|
18
|
+
res.on("close", () => {
|
|
19
|
+
if (!completed)
|
|
20
|
+
ac.abort();
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
const result = await (0, proxy_service_1.proxyRequest)(req.path, req.method, req.body, String(req.id), {
|
|
24
|
+
query: Object.fromEntries(Object.entries(req.query).map(([k, v]) => [k, String(v)])),
|
|
25
|
+
signal: ac.signal,
|
|
26
|
+
});
|
|
27
|
+
if (!result)
|
|
28
|
+
return next();
|
|
29
|
+
if (result.headers) {
|
|
30
|
+
for (const [name, value] of Object.entries(result.headers)) {
|
|
31
|
+
res.setHeader(name, value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (result.stream) {
|
|
35
|
+
// spec §3.2: headers once, before the first write; honour
|
|
36
|
+
// backpressure; never write after the client went away.
|
|
37
|
+
res.status(200);
|
|
38
|
+
res.setHeader("Content-Type", result.stream.contentType);
|
|
39
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
40
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
41
|
+
res.flushHeaders();
|
|
42
|
+
// One close promise for the whole loop — racing a fresh
|
|
43
|
+
// `once(res, "close")` per backpressured write leaks listeners.
|
|
44
|
+
const closed = new Promise((resolve) => {
|
|
45
|
+
res.once("close", resolve);
|
|
46
|
+
});
|
|
47
|
+
for await (const chunk of result.stream.chunks) {
|
|
48
|
+
if (res.writableEnded || res.destroyed)
|
|
49
|
+
break;
|
|
50
|
+
if (!res.write(chunk)) {
|
|
51
|
+
await Promise.race([(0, node_events_1.once)(res, "drain"), closed]);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
completed = true;
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.status(200).json(result.body);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
// The client went away mid-request — nothing left to answer.
|
|
62
|
+
if (ac.signal.aborted)
|
|
63
|
+
return;
|
|
64
|
+
// Headers already went out: a JSON error can't be rendered onto a
|
|
65
|
+
// streaming response — end it rather than calling next(err).
|
|
66
|
+
if (res.headersSent) {
|
|
67
|
+
res.destroy();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
next(err instanceof errors_1.ProxyError
|
|
71
|
+
? err
|
|
72
|
+
: new errors_1.TransformError(err instanceof Error ? err.message : "Unknown proxy error", { requestId: req.id }));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.requestJson = requestJson;
|
|
4
|
+
exports.postJson = postJson;
|
|
5
|
+
exports.getJson = getJson;
|
|
6
|
+
exports.postStream = postStream;
|
|
7
|
+
const errors_1 = require("../utils/errors");
|
|
8
|
+
const canonical_error_1 = require("../utils/canonical-error");
|
|
9
|
+
const logger_1 = require("../utils/logger");
|
|
10
|
+
/** Retry only these upstream statuses — upstream rejected before processing */
|
|
11
|
+
const RETRYABLE_STATUSES = new Set([429, 503]);
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const DEFAULT_RETRIES = 1;
|
|
14
|
+
const BASE_BACKOFF_MS = 250;
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
/** Exponential backoff with jitter: ~250ms, ~500ms, ... */
|
|
17
|
+
function backoffMs(attempt) {
|
|
18
|
+
const base = BASE_BACKOFF_MS * Math.pow(2, attempt);
|
|
19
|
+
return base + Math.floor(Math.random() * base * 0.5);
|
|
20
|
+
}
|
|
21
|
+
function isAbortError(err) {
|
|
22
|
+
return (err instanceof Error &&
|
|
23
|
+
(err.name === "AbortError" || err.name === "TimeoutError"));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Send a JSON request to an upstream provider with timeout + conservative
|
|
27
|
+
* retry. postJson/getJson are thin wrappers.
|
|
28
|
+
*
|
|
29
|
+
* Retry policy: LLM POSTs aren't idempotent — retrying a request the
|
|
30
|
+
* upstream may have already processed can double-bill tokens. So we only
|
|
31
|
+
* retry transport failures (connection refused, DNS, socket reset) and
|
|
32
|
+
* 429/503 where the upstream rejected before processing. Never retry other
|
|
33
|
+
* statuses or timeouts (a partial generation may have run). GETs are
|
|
34
|
+
* idempotent but follow the same policy for simplicity.
|
|
35
|
+
*/
|
|
36
|
+
async function requestJson(method, opts) {
|
|
37
|
+
const { url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS, retries = DEFAULT_RETRIES, requestId, } = opts;
|
|
38
|
+
const maxAttempts = retries + 1;
|
|
39
|
+
let lastError = null;
|
|
40
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url, {
|
|
44
|
+
method,
|
|
45
|
+
headers: {
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
...headers,
|
|
48
|
+
...(requestId ? { "x-request-id": requestId } : {}),
|
|
49
|
+
},
|
|
50
|
+
...(method === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
51
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
52
|
+
});
|
|
53
|
+
const durationMs = Date.now() - start;
|
|
54
|
+
const contentType = res.headers.get("content-type") ?? "unknown";
|
|
55
|
+
const retryAfterSeconds = (0, canonical_error_1.parseRetryAfter)(res.headers.get("retry-after"));
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
let parsed = null;
|
|
58
|
+
let parseFailed = false;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
parseFailed = true;
|
|
64
|
+
}
|
|
65
|
+
logger_1.logger.info({
|
|
66
|
+
requestId,
|
|
67
|
+
url,
|
|
68
|
+
attempt: attempt + 1,
|
|
69
|
+
durationMs,
|
|
70
|
+
status: res.status,
|
|
71
|
+
}, "Upstream request");
|
|
72
|
+
// Retryable rejection (429/503): upstream declined before processing
|
|
73
|
+
if (RETRYABLE_STATUSES.has(res.status)) {
|
|
74
|
+
if (attempt < maxAttempts - 1) {
|
|
75
|
+
await sleep(backoffMs(attempt));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const error = new errors_1.UpstreamError(`Upstream rejected request after ${maxAttempts} attempts`, res.status, parsed, { requestId, url, attempts: maxAttempts });
|
|
79
|
+
error.retryAfterSeconds = retryAfterSeconds;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
// A 2xx that isn't valid JSON is an upstream failure (e.g. Ollama
|
|
83
|
+
// answering with NDJSON), not a successful empty response.
|
|
84
|
+
if (res.ok && parseFailed) {
|
|
85
|
+
throw new errors_1.UpstreamError(`Upstream returned a non-JSON response (content-type: ${contentType})`, res.status, undefined, {
|
|
86
|
+
requestId,
|
|
87
|
+
url,
|
|
88
|
+
// the error-handler never logs err.context; the body preview
|
|
89
|
+
// (completion text for NDJSON) surfaces only in
|
|
90
|
+
// non-production response details
|
|
91
|
+
...(process.env.NODE_ENV !== "production"
|
|
92
|
+
? { bodyPreview: text.slice(0, 200) }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
status: res.status,
|
|
98
|
+
body: parseFailed ? null : parsed,
|
|
99
|
+
durationMs,
|
|
100
|
+
contentType,
|
|
101
|
+
...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
// Our own UpstreamErrors (non-JSON 2xx, retries exhausted) propagate.
|
|
106
|
+
if (err instanceof errors_1.UpstreamError)
|
|
107
|
+
throw err;
|
|
108
|
+
const durationMs = Date.now() - start;
|
|
109
|
+
// Timeout: never retry — upstream may have partially processed
|
|
110
|
+
if (isAbortError(err)) {
|
|
111
|
+
throw new errors_1.UpstreamError(`Upstream timed out after ${timeoutMs}ms`, undefined, undefined, { requestId, url, durationMs }, 504);
|
|
112
|
+
}
|
|
113
|
+
// Transport failure: safe to retry — upstream likely never saw it
|
|
114
|
+
lastError = new errors_1.UpstreamError(`Upstream unreachable: ${err instanceof Error ? err.message : "connection failed"}`, undefined, undefined, { requestId, url, attempt: attempt + 1, durationMs });
|
|
115
|
+
if (attempt < maxAttempts - 1) {
|
|
116
|
+
await sleep(backoffMs(attempt));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
throw lastError ?? new errors_1.UpstreamError("Upstream request failed");
|
|
122
|
+
}
|
|
123
|
+
function postJson(opts) {
|
|
124
|
+
return requestJson("POST", opts);
|
|
125
|
+
}
|
|
126
|
+
function getJson(opts) {
|
|
127
|
+
return requestJson("GET", opts);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* POST that resolves as soon as response headers arrive, leaving the body
|
|
131
|
+
* as a ReadableStream for the streaming pipeline (spec §3.2).
|
|
132
|
+
*
|
|
133
|
+
* Non-2xx: the body is read as JSON (null when it isn't) and the same
|
|
134
|
+
* UpstreamError the JSON path produces is thrown — proxy.service maps it
|
|
135
|
+
* to the client's dialect identically. `timeoutMs` covers connect +
|
|
136
|
+
* headers only; inter-chunk gaps are the pipeline's idle timeout.
|
|
137
|
+
* Retries match requestJson (transport failures, 429/503) and can only
|
|
138
|
+
* ever happen before headers — the stream body is never retried.
|
|
139
|
+
* `signal` aborts the upstream request (client disconnect).
|
|
140
|
+
*/
|
|
141
|
+
async function postStream(opts) {
|
|
142
|
+
const { url, headers, body, timeoutMs = DEFAULT_TIMEOUT_MS, retries = DEFAULT_RETRIES, requestId, signal, } = opts;
|
|
143
|
+
const maxAttempts = retries + 1;
|
|
144
|
+
let lastError = null;
|
|
145
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
146
|
+
const start = Date.now();
|
|
147
|
+
try {
|
|
148
|
+
// timeoutMs bounds connect + response headers only (spec §3.2):
|
|
149
|
+
// a header-phase AbortController whose timer is cleared the
|
|
150
|
+
// moment fetch resolves, so nothing bounds the body except the
|
|
151
|
+
// pipeline's idle timeout. The caller's signal stays armed — it
|
|
152
|
+
// is what aborts the body on client disconnect.
|
|
153
|
+
const headerPhase = new AbortController();
|
|
154
|
+
const headerTimer = setTimeout(() => headerPhase.abort(), timeoutMs);
|
|
155
|
+
const res = await fetch(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: {
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
...headers,
|
|
160
|
+
...(requestId ? { "x-request-id": requestId } : {}),
|
|
161
|
+
},
|
|
162
|
+
body: JSON.stringify(body),
|
|
163
|
+
signal: signal
|
|
164
|
+
? AbortSignal.any([signal, headerPhase.signal])
|
|
165
|
+
: headerPhase.signal,
|
|
166
|
+
}).finally(() => clearTimeout(headerTimer));
|
|
167
|
+
const durationMs = Date.now() - start;
|
|
168
|
+
const retryAfterSeconds = (0, canonical_error_1.parseRetryAfter)(res.headers.get("retry-after"));
|
|
169
|
+
logger_1.logger.info({
|
|
170
|
+
requestId,
|
|
171
|
+
url,
|
|
172
|
+
attempt: attempt + 1,
|
|
173
|
+
durationMs,
|
|
174
|
+
status: res.status,
|
|
175
|
+
}, "Upstream request");
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
// The client gets a normal JSON error in its own dialect — read
|
|
178
|
+
// the body like requestJson does, then throw the same error.
|
|
179
|
+
const text = await res.text();
|
|
180
|
+
let parsed = null;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(text);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// body stays null — adapters' toCanonicalError handles that
|
|
186
|
+
}
|
|
187
|
+
if (RETRYABLE_STATUSES.has(res.status) &&
|
|
188
|
+
attempt < maxAttempts - 1) {
|
|
189
|
+
await sleep(backoffMs(attempt));
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const error = new errors_1.UpstreamError(`Upstream returned ${res.status}`, res.status, parsed, { requestId, url });
|
|
193
|
+
error.retryAfterSeconds = retryAfterSeconds;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
if (!res.body) {
|
|
197
|
+
throw new errors_1.UpstreamError("Upstream returned no response body", res.status, undefined, { requestId, url });
|
|
198
|
+
}
|
|
199
|
+
return { status: res.status, body: res.body, durationMs };
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (err instanceof errors_1.UpstreamError)
|
|
203
|
+
throw err;
|
|
204
|
+
const durationMs = Date.now() - start;
|
|
205
|
+
// Aborted by the caller (client disconnect): not a timeout, not
|
|
206
|
+
// retryable — propagate as-is so the route can tell.
|
|
207
|
+
if (signal?.aborted)
|
|
208
|
+
throw err;
|
|
209
|
+
if (isAbortError(err)) {
|
|
210
|
+
throw new errors_1.UpstreamError(`Upstream timed out after ${timeoutMs}ms`, undefined, undefined, { requestId, url, durationMs }, 504);
|
|
211
|
+
}
|
|
212
|
+
lastError = new errors_1.UpstreamError(`Upstream unreachable: ${err instanceof Error ? err.message : "connection failed"}`, undefined, undefined, { requestId, url, attempt: attempt + 1, durationMs });
|
|
213
|
+
if (attempt < maxAttempts - 1) {
|
|
214
|
+
await sleep(backoffMs(attempt));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw lastError ?? new errors_1.UpstreamError("Upstream request failed");
|
|
220
|
+
}
|