@dbx-tools/cli-model-proxy 0.3.33 → 0.3.35
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/README.md +67 -1
- package/index.ts +1 -0
- package/package.json +5 -4
- package/src/cli.ts +19 -2
- package/src/defaults.ts +62 -0
- package/src/server.ts +220 -21
- package/test/server.test.ts +173 -0
package/README.md
CHANGED
|
@@ -145,6 +145,72 @@ Use this when tests or local developer tools need a managed proxy lifecycle.
|
|
|
145
145
|
This keeps the package small: Databricks already speaks the OpenAI schema, so
|
|
146
146
|
the useful work is auth, endpoint resolution, and routing to the right surface.
|
|
147
147
|
|
|
148
|
+
## Timeouts And Cancellation
|
|
149
|
+
|
|
150
|
+
A proxied turn takes as long as the model behind it, so the proxy imposes **no
|
|
151
|
+
deadline of its own** in either direction. The stream ends when Databricks ends
|
|
152
|
+
it, or when the client hangs up.
|
|
153
|
+
|
|
154
|
+
That is a deliberate override of two sets of defaults that otherwise truncate
|
|
155
|
+
long turns:
|
|
156
|
+
|
|
157
|
+
- **Inbound** (client → proxy). Node's `requestTimeout` (300s) and
|
|
158
|
+
`headersTimeout` (60s) are sized for ordinary web traffic, not for holding a
|
|
159
|
+
streamed model response open. Both are set to `0`.
|
|
160
|
+
- **Upstream** (proxy → Databricks). Every upstream call goes out on an
|
|
161
|
+
`undici` `Agent` with `headersTimeout: 0` and `bodyTimeout: 0`. `bodyTimeout`
|
|
162
|
+
is the important one: it measures the gap **between** chunks, so its 300s
|
|
163
|
+
default fires on a model that pauses mid-stream - extended reasoning, a long
|
|
164
|
+
tool round trip, a slow Genie or SQL step - and tears the socket down with
|
|
165
|
+
`UND_ERR_BODY_TIMEOUT`. The client sees a stream that simply stops.
|
|
166
|
+
|
|
167
|
+
Because nothing times out, client disconnect is the backstop: each upstream
|
|
168
|
+
request carries an `AbortSignal` tied to the response, so a cancelled turn
|
|
169
|
+
(Ctrl-C, a closed tab, a killed CLI) releases the Databricks-side stream instead
|
|
170
|
+
of leaking it. If a turn should have a deadline, send one from the client - the
|
|
171
|
+
same as you would to OpenAI.
|
|
172
|
+
|
|
173
|
+
An upstream failure that happens _after_ the response headers are sent is
|
|
174
|
+
logged (`stream ended early`) rather than thrown, since the status line is
|
|
175
|
+
already committed and cannot be turned into an HTTP error.
|
|
176
|
+
|
|
177
|
+
## Rate Limits And 429 Backoff
|
|
178
|
+
|
|
179
|
+
Databricks Foundation Model endpoints are pay-per-token with an account-level
|
|
180
|
+
throughput ceiling. That ceiling is **not** exposed as a readable number, so the
|
|
181
|
+
proxy cannot pace against it in advance - it can only react. An agentic client
|
|
182
|
+
that bursts (Codex, for one) trips the limit and, left alone, retries a few
|
|
183
|
+
times and gives up with "exceeded retry limit".
|
|
184
|
+
|
|
185
|
+
So **by default the proxy absorbs the `429` itself**: instead of relaying it, it
|
|
186
|
+
retries the upstream call in-proxy with exponential backoff and up to +50%
|
|
187
|
+
jitter, honoring a server-sent `Retry-After` when present. Because the retry
|
|
188
|
+
happens before any status line is written, it is transparent to both streaming
|
|
189
|
+
and non-streaming callers - the client sees a slower success, not a 429. A fresh
|
|
190
|
+
auth header is minted per attempt so a long backoff can't outlive the token, and
|
|
191
|
+
a client disconnect during a backoff ends the wait immediately.
|
|
192
|
+
|
|
193
|
+
Retries are exhausted after `maxRetries` attempts, at which point the final 429
|
|
194
|
+
is relayed unchanged.
|
|
195
|
+
|
|
196
|
+
Disable it (relay 429s straight through) with the flag or the env var:
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
dbx-tools-model-proxy --no-retry-429
|
|
200
|
+
PROXY_RETRY_ON_429=false dbx-tools-model-proxy
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Tune the policy with environment variables (all optional):
|
|
204
|
+
|
|
205
|
+
| Variable | Default | Meaning |
|
|
206
|
+
| --- | --- | --- |
|
|
207
|
+
| `PROXY_RETRY_ON_429` | `true` | Master switch (loose boolean: `false`/`off`/`0`/`no`). `--no-retry-429` overrides it. |
|
|
208
|
+
| `PROXY_RETRY_MAX` | `5` | Max retry attempts after the initial try. |
|
|
209
|
+
| `PROXY_RETRY_BASE_MS` | `500` | First backoff, doubled each attempt. |
|
|
210
|
+
| `PROXY_RETRY_MAX_MS` | `30000` | Ceiling for any single backoff, including a `Retry-After`. |
|
|
211
|
+
|
|
212
|
+
Precedence is CLI flag → env → built-in default.
|
|
213
|
+
|
|
148
214
|
## Unsupported Request Fields
|
|
149
215
|
|
|
150
216
|
Databricks Model Serving validates the chat body strictly, so a single
|
|
@@ -176,7 +242,7 @@ PROXY_DROP_FIELDS=some_new_field,another dbx-tools-model-proxy
|
|
|
176
242
|
- `backend` - `DatabricksBackend`, auth, model resolution, and upstream request
|
|
177
243
|
forwarding.
|
|
178
244
|
- `server` - Express proxy app and `startProxyServer()`.
|
|
179
|
-
- `defaults` - bind host, port, and
|
|
245
|
+
- `defaults` - bind host, port, and the 429-retry policy (`resolveRetryConfig`).
|
|
180
246
|
|
|
181
247
|
Endpoint ranking and fuzzy matching come from
|
|
182
248
|
[`@dbx-tools/model`](../../node/model).
|
package/index.ts
CHANGED
|
@@ -7,4 +7,5 @@ export * as cli from "./src/cli";
|
|
|
7
7
|
export * as defaults from "./src/defaults";
|
|
8
8
|
export * as server from "./src/server";
|
|
9
9
|
export type { BackendOptions } from "./src/backend";
|
|
10
|
+
export type { RetryConfig } from "./src/defaults";
|
|
10
11
|
export type { ProxyServerOptions, StartProxyOptions } from "./src/server";
|
package/package.json
CHANGED
|
@@ -18,16 +18,17 @@
|
|
|
18
18
|
"@databricks/sdk-experimental": "^0.17.0",
|
|
19
19
|
"commander": "^15.0.0",
|
|
20
20
|
"tsx": "^4.23.0",
|
|
21
|
-
"
|
|
22
|
-
"@dbx-tools/
|
|
23
|
-
"@dbx-tools/shared-model": "0.3.
|
|
21
|
+
"undici": "^7.17.0",
|
|
22
|
+
"@dbx-tools/model": "0.3.35",
|
|
23
|
+
"@dbx-tools/shared-model": "0.3.35",
|
|
24
|
+
"@dbx-tools/shared-core": "0.3.35"
|
|
24
25
|
},
|
|
25
26
|
"main": "index.ts",
|
|
26
27
|
"license": "UNLICENSED",
|
|
27
28
|
"publishConfig": {
|
|
28
29
|
"access": "public"
|
|
29
30
|
},
|
|
30
|
-
"version": "0.3.
|
|
31
|
+
"version": "0.3.35",
|
|
31
32
|
"types": "index.ts",
|
|
32
33
|
"type": "module",
|
|
33
34
|
"exports": {
|
package/src/cli.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { classify, type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
|
24
24
|
import { Command, CommanderError } from "commander";
|
|
25
25
|
|
|
26
26
|
import { DatabricksBackend, type BackendOptions } from "./backend";
|
|
27
|
-
import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
|
|
27
|
+
import { DEFAULT_BIND_HOST, DEFAULT_PORT, resolveRetryConfig } from "./defaults";
|
|
28
28
|
import { startProxyServer } from "./server";
|
|
29
29
|
|
|
30
30
|
/**
|
|
@@ -47,6 +47,11 @@ interface ServeOpts extends CommonOpts {
|
|
|
47
47
|
port: string;
|
|
48
48
|
host: string;
|
|
49
49
|
apiKey?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Commander's negatable-flag value for `--no-retry-429`: `true` by default,
|
|
52
|
+
* `false` once the flag is passed. Off means relay upstream 429s unchanged.
|
|
53
|
+
*/
|
|
54
|
+
retry429: boolean;
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
/** Map shared CLI flags onto {@link BackendOptions}. */
|
|
@@ -73,9 +78,13 @@ async function startProxy(
|
|
|
73
78
|
): Promise<{ backend: DatabricksBackend; server: Server; url: string }> {
|
|
74
79
|
const backend = await DatabricksBackend.create(backendOptions(opts));
|
|
75
80
|
const apiKey = opts.apiKey ?? process.env.PROXY_API_KEY;
|
|
81
|
+
// `--no-retry-429` (opts.retry429 === false) is the only explicit override;
|
|
82
|
+
// otherwise resolveRetryConfig layers PROXY_RETRY_* env then the on-default.
|
|
83
|
+
const retry = resolveRetryConfig(opts.retry429 === false ? { enabled: false } : {});
|
|
76
84
|
const { server, url } = await startProxyServer(backend, {
|
|
77
85
|
host: opts.host,
|
|
78
86
|
port: Number(opts.port),
|
|
87
|
+
retry,
|
|
79
88
|
...(apiKey ? { apiKey } : {}),
|
|
80
89
|
});
|
|
81
90
|
return { backend, server, url };
|
|
@@ -99,7 +108,11 @@ export function buildProgram(): Command {
|
|
|
99
108
|
.description("Local OpenAI-compatible proxy to Databricks Model Serving.")
|
|
100
109
|
.option("-p, --port <port>", "port to listen on", String(DEFAULT_PORT))
|
|
101
110
|
.option("-H, --host <host>", "address to bind", DEFAULT_BIND_HOST)
|
|
102
|
-
.option("-k, --api-key <key>", "require this bearer token from local clients")
|
|
111
|
+
.option("-k, --api-key <key>", "require this bearer token from local clients")
|
|
112
|
+
.option(
|
|
113
|
+
"--no-retry-429",
|
|
114
|
+
"relay upstream 429s instead of retrying with backoff (default: retry)",
|
|
115
|
+
),
|
|
103
116
|
).action(async (opts: ServeOpts) => {
|
|
104
117
|
const { backend, url } = await startProxy(opts);
|
|
105
118
|
process.stderr.write(`model-proxy -> ${backend.host}\n`);
|
|
@@ -113,6 +126,10 @@ export function buildProgram(): Command {
|
|
|
113
126
|
.option("-p, --port <port>", "proxy port", String(DEFAULT_PORT))
|
|
114
127
|
.option("-H, --host <host>", "proxy bind host", DEFAULT_BIND_HOST)
|
|
115
128
|
.option("-m, --model <name>", "default model (fuzzy name ok)")
|
|
129
|
+
.option(
|
|
130
|
+
"--no-retry-429",
|
|
131
|
+
"relay upstream 429s instead of retrying with backoff (default: retry)",
|
|
132
|
+
)
|
|
116
133
|
.option(
|
|
117
134
|
"--client <cmd>",
|
|
118
135
|
"terminal chat CLI to launch (run via your shell)",
|
package/src/defaults.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Default values for the local Databricks model proxy.
|
|
2
2
|
|
|
3
|
+
import { object } from "@dbx-tools/shared-core";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Loopback address the proxy binds to by default. Keeps the OpenAI-compatible
|
|
5
7
|
* endpoint private to the machine unless the operator explicitly opts into a
|
|
@@ -9,3 +11,63 @@ export const DEFAULT_BIND_HOST = "127.0.0.1";
|
|
|
9
11
|
|
|
10
12
|
/** Default TCP port for the local proxy. */
|
|
11
13
|
export const DEFAULT_PORT = 4000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolved policy for absorbing upstream `429 Too Many Requests` responses.
|
|
17
|
+
*
|
|
18
|
+
* Databricks Foundation Model endpoints are pay-per-token with an account-level
|
|
19
|
+
* throughput ceiling that is not exposed as a readable number, so the proxy
|
|
20
|
+
* cannot pace against it proactively - it can only react. When `enabled`, a 429
|
|
21
|
+
* is retried in-proxy with exponential backoff (honoring any `Retry-After`)
|
|
22
|
+
* instead of being relayed to the client, so an agentic caller like Codex sees
|
|
23
|
+
* a slow success rather than "exceeded retry limit".
|
|
24
|
+
*/
|
|
25
|
+
export interface RetryConfig {
|
|
26
|
+
/** Retry 429s in-proxy rather than relaying them. */
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
/** Maximum retry attempts after the initial try (so N+1 total requests). */
|
|
29
|
+
maxRetries: number;
|
|
30
|
+
/** First backoff, doubled each attempt (before jitter and the `Retry-After` override). */
|
|
31
|
+
baseDelayMs: number;
|
|
32
|
+
/** Ceiling for any single backoff, including a server-sent `Retry-After`. */
|
|
33
|
+
maxDelayMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Built-in retry policy: on, with a bounded exponential backoff. */
|
|
37
|
+
export const DEFAULT_RETRY: RetryConfig = {
|
|
38
|
+
enabled: true,
|
|
39
|
+
maxRetries: 5,
|
|
40
|
+
baseDelayMs: 500,
|
|
41
|
+
maxDelayMs: 30_000,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Positive integer from an env var, or `undefined` when unset/unparseable. */
|
|
45
|
+
function envInt(name: string): number | undefined {
|
|
46
|
+
const raw = process.env[name];
|
|
47
|
+
if (raw === undefined || raw.trim() === "") return undefined;
|
|
48
|
+
const value = Number(raw);
|
|
49
|
+
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the 429-retry policy from, in decreasing precedence: an explicit
|
|
54
|
+
* override (the CLI flag), environment variables, then {@link DEFAULT_RETRY}.
|
|
55
|
+
*
|
|
56
|
+
* `enabled` layers as: a CLI `--no-retry-429` (`override.enabled === false`)
|
|
57
|
+
* always wins; otherwise `PROXY_RETRY_ON_429` (loose boolean via
|
|
58
|
+
* {@link object.toBoolean}) may switch it off; otherwise it stays on. There is
|
|
59
|
+
* deliberately no enable flag - the default is on, so the only meaningful
|
|
60
|
+
* action is disabling.
|
|
61
|
+
*
|
|
62
|
+
* Tunables read `PROXY_RETRY_MAX`, `PROXY_RETRY_BASE_MS`, `PROXY_RETRY_MAX_MS`.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveRetryConfig(override: Partial<RetryConfig> = {}): RetryConfig {
|
|
65
|
+
const envEnabled = object.toBoolean(process.env.PROXY_RETRY_ON_429);
|
|
66
|
+
const enabled = override.enabled ?? envEnabled ?? DEFAULT_RETRY.enabled;
|
|
67
|
+
return {
|
|
68
|
+
enabled,
|
|
69
|
+
maxRetries: override.maxRetries ?? envInt("PROXY_RETRY_MAX") ?? DEFAULT_RETRY.maxRetries,
|
|
70
|
+
baseDelayMs: override.baseDelayMs ?? envInt("PROXY_RETRY_BASE_MS") ?? DEFAULT_RETRY.baseDelayMs,
|
|
71
|
+
maxDelayMs: override.maxDelayMs ?? envInt("PROXY_RETRY_MAX_MS") ?? DEFAULT_RETRY.maxDelayMs,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -32,17 +32,36 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
35
|
-
import { error, json, log } from "@dbx-tools/shared-core";
|
|
35
|
+
import { async as asyncUtil, error, json, log } from "@dbx-tools/shared-core";
|
|
36
36
|
import { classify, openaiChat, openaiResponses } from "@dbx-tools/shared-model";
|
|
37
|
+
import { Agent } from "undici";
|
|
37
38
|
|
|
38
39
|
import type { DatabricksBackend } from "./backend";
|
|
39
|
-
import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
|
|
40
|
+
import { DEFAULT_BIND_HOST, DEFAULT_PORT, DEFAULT_RETRY, type RetryConfig } from "./defaults";
|
|
40
41
|
|
|
41
42
|
const { chatToResponsesRequest, responseToChatCompletion, sanitizeOpenResponsesRequest } =
|
|
42
43
|
openaiResponses;
|
|
43
44
|
|
|
44
45
|
const logger = log.logger("model-proxy/server");
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Dispatcher for every upstream call, with undici's inactivity timeouts
|
|
49
|
+
* DISABLED (`0`).
|
|
50
|
+
*
|
|
51
|
+
* A proxied turn is only as fast as the model behind it, and undici's
|
|
52
|
+
* defaults (300s `headersTimeout`, 300s `bodyTimeout`) are both wrong here.
|
|
53
|
+
* `bodyTimeout` is the killer: it measures the gap BETWEEN chunks, so a model
|
|
54
|
+
* that thinks for a while mid-stream - extended reasoning, a long tool round
|
|
55
|
+
* trip, a slow Genie/SQL step - trips it and undici tears the socket down
|
|
56
|
+
* with `UND_ERR_BODY_TIMEOUT`, which the client sees as a truncated stream.
|
|
57
|
+
*
|
|
58
|
+
* The proxy has no opinion on how long a model may take, so it imposes no
|
|
59
|
+
* deadline: the stream ends when the upstream ends it, or when the client
|
|
60
|
+
* hangs up (see {@link requestAbortSignal}). Callers that DO want a deadline
|
|
61
|
+
* should send one, the same as they would to OpenAI.
|
|
62
|
+
*/
|
|
63
|
+
const upstreamDispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
64
|
+
|
|
46
65
|
/** POST routes forwarded to a serving endpoint (chat or Responses-only). */
|
|
47
66
|
const PROXY_PATHS = new Set([
|
|
48
67
|
"/v1/chat/completions",
|
|
@@ -82,6 +101,11 @@ export interface ProxyServerOptions {
|
|
|
82
101
|
* for a loopback bind but should be paired with a key on a wider one.
|
|
83
102
|
*/
|
|
84
103
|
apiKey?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Policy for absorbing upstream 429s. Omitted defaults to {@link
|
|
106
|
+
* DEFAULT_RETRY} (retry on). See {@link RetryConfig}.
|
|
107
|
+
*/
|
|
108
|
+
retry?: RetryConfig;
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
/** Options for {@link startProxyServer}, adding the listen address. */
|
|
@@ -95,9 +119,161 @@ export function createProxyServer(
|
|
|
95
119
|
backend: DatabricksBackend,
|
|
96
120
|
options: ProxyServerOptions = {},
|
|
97
121
|
): Server {
|
|
98
|
-
|
|
122
|
+
const server = createServer((req, res) => {
|
|
99
123
|
void handleRequest(backend, options, req, res);
|
|
100
124
|
});
|
|
125
|
+
// Match the upstream policy on the INBOUND side: never cut a client off
|
|
126
|
+
// mid-turn. Node's defaults (300s `requestTimeout`, 60s `headersTimeout`)
|
|
127
|
+
// are sized for ordinary web traffic, not for holding a streamed model
|
|
128
|
+
// response open, and a client that hits one gets a dead socket rather than
|
|
129
|
+
// an error it can act on. `0` disables each; the turn now ends when the
|
|
130
|
+
// model is done or the client disconnects.
|
|
131
|
+
server.requestTimeout = 0;
|
|
132
|
+
server.headersTimeout = 0;
|
|
133
|
+
server.timeout = 0;
|
|
134
|
+
return server;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Signal that aborts when the client hangs up.
|
|
139
|
+
*
|
|
140
|
+
* Removing the timeouts means nothing else will ever end a forgotten request,
|
|
141
|
+
* so the client's own disconnect becomes the only backstop against an upstream
|
|
142
|
+
* call running on with no one to receive it. Passing this to `fetch` is what
|
|
143
|
+
* makes a cancelled turn (Ctrl-C, a closed tab, a killed CLI) release the
|
|
144
|
+
* Databricks-side stream instead of leaking it for the life of the process.
|
|
145
|
+
*
|
|
146
|
+
* Keyed off the RESPONSE, not the request: `IncomingMessage` emits `close` as
|
|
147
|
+
* soon as its body has been fully consumed, which on every POST here happens
|
|
148
|
+
* before the upstream call is even made - watching that would abort each turn
|
|
149
|
+
* instantly. `ServerResponse` emits `close` when the response finishes or the
|
|
150
|
+
* connection drops, so the `writableFinished` check separates "client left"
|
|
151
|
+
* from "we replied".
|
|
152
|
+
*/
|
|
153
|
+
function clientAbortSignal(res: ServerResponse): AbortSignal {
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
res.once("close", () => {
|
|
156
|
+
if (!res.writableFinished) controller.abort();
|
|
157
|
+
});
|
|
158
|
+
return controller.signal;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* POST a JSON body to a serving endpoint on the no-timeout
|
|
163
|
+
* {@link upstreamDispatcher}, cancelled via `signal`.
|
|
164
|
+
*
|
|
165
|
+
* Every upstream call goes through here so the timeout and cancellation policy
|
|
166
|
+
* is stated once. `dispatcher` is an undici extension that the DOM
|
|
167
|
+
* `RequestInit` Node compiles against does not declare, hence the local
|
|
168
|
+
* intersection type rather than a cast at each call site.
|
|
169
|
+
*/
|
|
170
|
+
async function upstreamFetch(
|
|
171
|
+
url: string,
|
|
172
|
+
headers: Record<string, string>,
|
|
173
|
+
body: string,
|
|
174
|
+
signal: AbortSignal,
|
|
175
|
+
): Promise<Response> {
|
|
176
|
+
const init: RequestInit = { method: "POST", headers, body, signal };
|
|
177
|
+
// `dispatcher` is an undici extension the DOM `RequestInit` doesn't declare,
|
|
178
|
+
// and it can't simply be typed on: `@types/node` pins its own `undici-types`
|
|
179
|
+
// copy, so the `Agent` from our `undici` dependency is a structurally
|
|
180
|
+
// identical but nominally different type than the one the global `fetch`
|
|
181
|
+
// signature expects. Attaching it structurally sidesteps a version skew that
|
|
182
|
+
// no cast expresses cleanly, and `fetch` reads it at runtime all the same.
|
|
183
|
+
Reflect.set(init, "dispatcher", upstreamDispatcher);
|
|
184
|
+
return fetch(url, init);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Delay (ms) a `429` response asks us to wait, from its `Retry-After` header,
|
|
189
|
+
* or `undefined` when absent/unparseable. Handles both header forms: an integer
|
|
190
|
+
* count of seconds, and an HTTP date to wait until (clamped to `>= 0`).
|
|
191
|
+
*/
|
|
192
|
+
export function parseRetryAfterMs(header: string | null, nowMs: number): number | undefined {
|
|
193
|
+
if (!header) return undefined;
|
|
194
|
+
const trimmed = header.trim();
|
|
195
|
+
if (/^\d+$/.test(trimmed)) return Number(trimmed) * 1000;
|
|
196
|
+
const when = Date.parse(trimmed);
|
|
197
|
+
if (Number.isNaN(when)) return undefined;
|
|
198
|
+
return Math.max(0, when - nowMs);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Backoff (ms) before retry `attempt` (0-based): exponential from
|
|
203
|
+
* `baseDelayMs`, capped at `maxDelayMs`, with up to +50% jitter so concurrent
|
|
204
|
+
* retries from one agent don't resynchronize into the next burst. A server-sent
|
|
205
|
+
* `Retry-After` wins outright (still capped), since the server knows better
|
|
206
|
+
* than our schedule when it will accept traffic again.
|
|
207
|
+
*/
|
|
208
|
+
export function backoffDelayMs(
|
|
209
|
+
attempt: number,
|
|
210
|
+
retry: RetryConfig,
|
|
211
|
+
retryAfterMs: number | undefined,
|
|
212
|
+
jitter: number,
|
|
213
|
+
): number {
|
|
214
|
+
if (retryAfterMs !== undefined) return Math.min(retryAfterMs, retry.maxDelayMs);
|
|
215
|
+
const exponential = retry.baseDelayMs * 2 ** attempt;
|
|
216
|
+
const capped = Math.min(exponential, retry.maxDelayMs);
|
|
217
|
+
return Math.round(capped * (1 + jitter * 0.5));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* {@link upstreamFetch} with in-proxy `429` handling. When `retry.enabled`, a
|
|
222
|
+
* 429 is retried up to `retry.maxRetries` times with {@link backoffDelayMs}
|
|
223
|
+
* backoff instead of being surfaced; the throttled response body is drained
|
|
224
|
+
* each time so the connection is released. The retry happens BEFORE the caller
|
|
225
|
+
* writes any status line, so it is transparent to both streaming and
|
|
226
|
+
* non-streaming paths - the client only ever sees the final response.
|
|
227
|
+
*
|
|
228
|
+
* Bails early (returning the 429) when retries are exhausted, when the client
|
|
229
|
+
* has hung up (`signal.aborted`), or when the response carries no `Retry-After`
|
|
230
|
+
* and we would otherwise loop blind past the cap.
|
|
231
|
+
*/
|
|
232
|
+
async function upstreamFetchRetrying(
|
|
233
|
+
backend: DatabricksBackend,
|
|
234
|
+
url: string,
|
|
235
|
+
headers: Record<string, string>,
|
|
236
|
+
body: unknown,
|
|
237
|
+
res: ServerResponse,
|
|
238
|
+
retry: RetryConfig,
|
|
239
|
+
): Promise<Response> {
|
|
240
|
+
const signal = clientAbortSignal(res);
|
|
241
|
+
const payload = JSON.stringify(body);
|
|
242
|
+
let response = await upstreamFetch(url, headers, payload, signal);
|
|
243
|
+
if (!retry.enabled) return response;
|
|
244
|
+
|
|
245
|
+
for (let attempt = 0; response.status === 429 && attempt < retry.maxRetries; attempt++) {
|
|
246
|
+
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"), Date.now());
|
|
247
|
+
const delayMs = backoffDelayMs(attempt, retry, retryAfterMs, jitterFraction());
|
|
248
|
+
// Release the throttled response's socket before waiting; its body is an
|
|
249
|
+
// error payload we are choosing not to relay.
|
|
250
|
+
await response.body?.cancel().catch(() => {});
|
|
251
|
+
logger.warn("upstream 429; backing off", {
|
|
252
|
+
attempt: attempt + 1,
|
|
253
|
+
maxRetries: retry.maxRetries,
|
|
254
|
+
delayMs,
|
|
255
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
await asyncUtil.sleep(delayMs, signal);
|
|
259
|
+
} catch {
|
|
260
|
+
// Client hung up mid-wait: return the last 429 rather than issue a
|
|
261
|
+
// request no one is listening for.
|
|
262
|
+
return response;
|
|
263
|
+
}
|
|
264
|
+
// Fresh auth header per attempt so a long backoff can't outlive the token.
|
|
265
|
+
const retryHeaders = { ...(await backend.authHeaders()), ...headers };
|
|
266
|
+
response = await upstreamFetch(url, retryHeaders, payload, signal);
|
|
267
|
+
}
|
|
268
|
+
return response;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Jitter fraction in `[0, 1)`. Split out so tests can stub determinism;
|
|
273
|
+
* `Math.random` is fine for spreading retry timing.
|
|
274
|
+
*/
|
|
275
|
+
function jitterFraction(): number {
|
|
276
|
+
return Math.random();
|
|
101
277
|
}
|
|
102
278
|
|
|
103
279
|
/**
|
|
@@ -149,12 +325,13 @@ async function handleRequest(
|
|
|
149
325
|
await handleModels(backend, res, wantsCodexShape);
|
|
150
326
|
return;
|
|
151
327
|
}
|
|
328
|
+
const retry = options.retry ?? DEFAULT_RETRY;
|
|
152
329
|
if (req.method === "POST" && RESPONSES_PATHS.has(routePath)) {
|
|
153
|
-
await handleResponses(backend, req, res);
|
|
330
|
+
await handleResponses(backend, req, res, retry);
|
|
154
331
|
return;
|
|
155
332
|
}
|
|
156
333
|
if (req.method === "POST" && PROXY_PATHS.has(routePath)) {
|
|
157
|
-
await handleProxy(backend, req, res);
|
|
334
|
+
await handleProxy(backend, req, res, retry);
|
|
158
335
|
return;
|
|
159
336
|
}
|
|
160
337
|
sendJson(
|
|
@@ -221,6 +398,7 @@ async function handleProxy(
|
|
|
221
398
|
backend: DatabricksBackend,
|
|
222
399
|
req: IncomingMessage,
|
|
223
400
|
res: ServerResponse,
|
|
401
|
+
retry: RetryConfig,
|
|
224
402
|
): Promise<void> {
|
|
225
403
|
const body = await readJsonBody(req);
|
|
226
404
|
const requested = typeof body.model === "string" ? body.model : undefined;
|
|
@@ -233,7 +411,15 @@ async function handleProxy(
|
|
|
233
411
|
body.model = resolved.modelId;
|
|
234
412
|
|
|
235
413
|
if (backend.isResponsesOnly(resolved.modelId)) {
|
|
236
|
-
await proxyChatViaResponses(
|
|
414
|
+
await proxyChatViaResponses(
|
|
415
|
+
backend,
|
|
416
|
+
body,
|
|
417
|
+
requested,
|
|
418
|
+
resolved.modelId,
|
|
419
|
+
resolved.matched,
|
|
420
|
+
res,
|
|
421
|
+
retry,
|
|
422
|
+
);
|
|
237
423
|
return;
|
|
238
424
|
}
|
|
239
425
|
|
|
@@ -256,11 +442,14 @@ async function handleProxy(
|
|
|
256
442
|
...(dropped.length > 0 ? { dropped } : {}),
|
|
257
443
|
});
|
|
258
444
|
|
|
259
|
-
const upstream = await
|
|
260
|
-
|
|
445
|
+
const upstream = await upstreamFetchRetrying(
|
|
446
|
+
backend,
|
|
447
|
+
backend.invocationsUrl(resolved.modelId),
|
|
261
448
|
headers,
|
|
262
|
-
body
|
|
263
|
-
|
|
449
|
+
body,
|
|
450
|
+
res,
|
|
451
|
+
retry,
|
|
452
|
+
);
|
|
264
453
|
|
|
265
454
|
res.writeHead(upstream.status, {
|
|
266
455
|
"content-type": upstream.headers.get("content-type") ?? "application/json",
|
|
@@ -281,6 +470,7 @@ async function proxyChatViaResponses(
|
|
|
281
470
|
modelId: string,
|
|
282
471
|
matched: boolean,
|
|
283
472
|
res: ServerResponse,
|
|
473
|
+
retry: RetryConfig,
|
|
284
474
|
): Promise<void> {
|
|
285
475
|
const { responses, stream } = chatToResponsesRequest(body);
|
|
286
476
|
responses.model = modelId;
|
|
@@ -314,11 +504,7 @@ async function proxyChatViaResponses(
|
|
|
314
504
|
stream: false,
|
|
315
505
|
});
|
|
316
506
|
|
|
317
|
-
const upstream = await
|
|
318
|
-
method: "POST",
|
|
319
|
-
headers,
|
|
320
|
-
body: JSON.stringify(forward),
|
|
321
|
-
});
|
|
507
|
+
const upstream = await upstreamFetchRetrying(backend, upstreamUrl, headers, forward, res, retry);
|
|
322
508
|
|
|
323
509
|
if (!upstream.ok) {
|
|
324
510
|
const text = await upstream.text();
|
|
@@ -347,6 +533,7 @@ async function handleResponses(
|
|
|
347
533
|
backend: DatabricksBackend,
|
|
348
534
|
req: IncomingMessage,
|
|
349
535
|
res: ServerResponse,
|
|
536
|
+
retry: RetryConfig,
|
|
350
537
|
): Promise<void> {
|
|
351
538
|
const body = await readJsonBody(req);
|
|
352
539
|
const requested = typeof body.model === "string" ? body.model : undefined;
|
|
@@ -383,11 +570,7 @@ async function handleResponses(
|
|
|
383
570
|
...(stripped ? { strippedNonFunctionTools: true } : {}),
|
|
384
571
|
});
|
|
385
572
|
|
|
386
|
-
const upstream = await
|
|
387
|
-
method: "POST",
|
|
388
|
-
headers,
|
|
389
|
-
body: JSON.stringify(forward),
|
|
390
|
-
});
|
|
573
|
+
const upstream = await upstreamFetchRetrying(backend, upstreamUrl, headers, forward, res, retry);
|
|
391
574
|
|
|
392
575
|
res.writeHead(upstream.status, {
|
|
393
576
|
"content-type": upstream.headers.get("content-type") ?? "application/json",
|
|
@@ -396,7 +579,16 @@ async function handleResponses(
|
|
|
396
579
|
await streamBody(upstream, res);
|
|
397
580
|
}
|
|
398
581
|
|
|
399
|
-
/**
|
|
582
|
+
/**
|
|
583
|
+
* Pump an upstream `fetch` Response body to the Node response, chunk by chunk.
|
|
584
|
+
*
|
|
585
|
+
* A mid-stream upstream failure is logged rather than thrown: the status line
|
|
586
|
+
* and some number of chunks have already gone out, so there is no way left to
|
|
587
|
+
* turn it into an HTTP error and the only honest move is to end the response
|
|
588
|
+
* and say why in the log. Without this the read loop rejected into the caller's
|
|
589
|
+
* catch, which found `headersSent` and ended the response silently - making an
|
|
590
|
+
* upstream teardown indistinguishable from a clean finish.
|
|
591
|
+
*/
|
|
400
592
|
async function streamBody(upstream: Response, res: ServerResponse): Promise<void> {
|
|
401
593
|
const body = upstream.body;
|
|
402
594
|
if (!body) {
|
|
@@ -411,6 +603,13 @@ async function streamBody(upstream: Response, res: ServerResponse): Promise<void
|
|
|
411
603
|
if (value && !res.writableEnded) res.write(Buffer.from(value));
|
|
412
604
|
if (res.writableEnded) break;
|
|
413
605
|
}
|
|
606
|
+
} catch (err) {
|
|
607
|
+
// An aborted request is the expected shape of a client hanging up, not a
|
|
608
|
+
// fault worth reporting at error level.
|
|
609
|
+
const aborted = res.writableEnded || !res.writable;
|
|
610
|
+
logger[aborted ? "info" : "error"]("stream ended early", {
|
|
611
|
+
error: error.errorMessage(err),
|
|
612
|
+
});
|
|
414
613
|
} finally {
|
|
415
614
|
await reader.cancel().catch(() => {});
|
|
416
615
|
res.end();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
|
|
5
|
+
import type { DatabricksBackend } from "../src/backend";
|
|
6
|
+
import { DEFAULT_RETRY, resolveRetryConfig } from "../src/defaults";
|
|
7
|
+
import { backoffDelayMs, createProxyServer, parseRetryAfterMs } from "../src/server";
|
|
8
|
+
|
|
9
|
+
/** Listen on an ephemeral port and resolve the bound port. */
|
|
10
|
+
async function listen(server: Server): Promise<number> {
|
|
11
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
|
12
|
+
const address = server.address();
|
|
13
|
+
assert.ok(typeof address === "object" && address);
|
|
14
|
+
return address.port;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("createProxyServer timeouts", () => {
|
|
18
|
+
it("disables every inbound timeout that could cut a stream short", () => {
|
|
19
|
+
const server = createProxyServer({} as DatabricksBackend);
|
|
20
|
+
// Node's defaults (300s request, 60s headers) are sized for ordinary web
|
|
21
|
+
// traffic and would kill a long model turn mid-stream.
|
|
22
|
+
assert.equal(server.requestTimeout, 0);
|
|
23
|
+
assert.equal(server.headersTimeout, 0);
|
|
24
|
+
assert.equal(server.timeout, 0);
|
|
25
|
+
server.close();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("differs from a stock node server, so the override is doing the work", () => {
|
|
29
|
+
const stock = createServer();
|
|
30
|
+
assert.notEqual(stock.requestTimeout, 0);
|
|
31
|
+
assert.notEqual(stock.headersTimeout, 0);
|
|
32
|
+
stock.close();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("upstream dispatcher", () => {
|
|
37
|
+
/**
|
|
38
|
+
* Serve one chunk, stall past the supplied body timeout, then finish. Proves
|
|
39
|
+
* `bodyTimeout` measures the gap BETWEEN chunks - the failure mode that made
|
|
40
|
+
* long-thinking turns look like dropped streams.
|
|
41
|
+
*/
|
|
42
|
+
async function readThroughStall(bodyTimeout: number): Promise<string> {
|
|
43
|
+
const upstream = createServer((_req, res) => {
|
|
44
|
+
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
45
|
+
res.write("data: first\n\n");
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
res.write("data: second\n\n");
|
|
48
|
+
res.end();
|
|
49
|
+
}, 1_000);
|
|
50
|
+
});
|
|
51
|
+
const port = await listen(upstream);
|
|
52
|
+
try {
|
|
53
|
+
const { Agent } = await import("undici");
|
|
54
|
+
const init: RequestInit = { method: "GET" };
|
|
55
|
+
Reflect.set(init, "dispatcher", new Agent({ bodyTimeout, headersTimeout: 0 }));
|
|
56
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, init);
|
|
57
|
+
let body = "";
|
|
58
|
+
for await (const chunk of response.body as AsyncIterable<Uint8Array>) {
|
|
59
|
+
body += Buffer.from(chunk).toString();
|
|
60
|
+
}
|
|
61
|
+
return body;
|
|
62
|
+
} finally {
|
|
63
|
+
upstream.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
it("aborts a stalled stream when bodyTimeout is set", async () => {
|
|
68
|
+
await assert.rejects(() => readThroughStall(100));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("survives the same stall with bodyTimeout disabled, as the proxy configures it", async () => {
|
|
72
|
+
assert.equal(await readThroughStall(0), "data: first\n\ndata: second\n\n");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("parseRetryAfterMs", () => {
|
|
77
|
+
const now = Date.UTC(2026, 0, 1, 0, 0, 0);
|
|
78
|
+
|
|
79
|
+
it("reads an integer count of seconds", () => {
|
|
80
|
+
assert.equal(parseRetryAfterMs("2", now), 2000);
|
|
81
|
+
assert.equal(parseRetryAfterMs(" 30 ", now), 30_000);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reads an HTTP date as ms-until, clamped at zero", () => {
|
|
85
|
+
const future = new Date(now + 5000).toUTCString();
|
|
86
|
+
assert.equal(parseRetryAfterMs(future, now), 5000);
|
|
87
|
+
const past = new Date(now - 5000).toUTCString();
|
|
88
|
+
assert.equal(parseRetryAfterMs(past, now), 0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns undefined for a missing or unparseable header", () => {
|
|
92
|
+
assert.equal(parseRetryAfterMs(null, now), undefined);
|
|
93
|
+
assert.equal(parseRetryAfterMs("soon", now), undefined);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("backoffDelayMs", () => {
|
|
98
|
+
const retry = { enabled: true, maxRetries: 5, baseDelayMs: 500, maxDelayMs: 30_000 };
|
|
99
|
+
|
|
100
|
+
it("grows exponentially from the base with jitter=0", () => {
|
|
101
|
+
assert.equal(backoffDelayMs(0, retry, undefined, 0), 500);
|
|
102
|
+
assert.equal(backoffDelayMs(1, retry, undefined, 0), 1000);
|
|
103
|
+
assert.equal(backoffDelayMs(2, retry, undefined, 0), 2000);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("caps the exponential at maxDelayMs", () => {
|
|
107
|
+
assert.equal(backoffDelayMs(20, retry, undefined, 0), retry.maxDelayMs);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("adds up to +50% jitter", () => {
|
|
111
|
+
assert.equal(backoffDelayMs(0, retry, undefined, 1), 750);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("lets a Retry-After win outright, still capped", () => {
|
|
115
|
+
assert.equal(backoffDelayMs(0, retry, 3000, 1), 3000);
|
|
116
|
+
assert.equal(backoffDelayMs(0, retry, 99_000, 0), retry.maxDelayMs);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("resolveRetryConfig", () => {
|
|
121
|
+
/** Run `fn` with `env` applied to `process.env`, restoring after. */
|
|
122
|
+
function withEnv(env: Record<string, string | undefined>, fn: () => void): void {
|
|
123
|
+
const saved = new Map<string, string | undefined>();
|
|
124
|
+
for (const key of Object.keys(env)) {
|
|
125
|
+
saved.set(key, process.env[key]);
|
|
126
|
+
if (env[key] === undefined) delete process.env[key];
|
|
127
|
+
else process.env[key] = env[key];
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
fn();
|
|
131
|
+
} finally {
|
|
132
|
+
for (const [key, value] of saved) {
|
|
133
|
+
if (value === undefined) delete process.env[key];
|
|
134
|
+
else process.env[key] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("defaults to on with the built-in policy", () => {
|
|
140
|
+
withEnv(
|
|
141
|
+
{
|
|
142
|
+
PROXY_RETRY_ON_429: undefined,
|
|
143
|
+
PROXY_RETRY_MAX: undefined,
|
|
144
|
+
PROXY_RETRY_BASE_MS: undefined,
|
|
145
|
+
PROXY_RETRY_MAX_MS: undefined,
|
|
146
|
+
},
|
|
147
|
+
() => assert.deepEqual(resolveRetryConfig(), DEFAULT_RETRY),
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("honors a loose PROXY_RETRY_ON_429 to switch it off", () => {
|
|
152
|
+
withEnv({ PROXY_RETRY_ON_429: "no" }, () => assert.equal(resolveRetryConfig().enabled, false));
|
|
153
|
+
withEnv({ PROXY_RETRY_ON_429: "off" }, () => assert.equal(resolveRetryConfig().enabled, false));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("reads numeric tunables from env", () => {
|
|
157
|
+
withEnv(
|
|
158
|
+
{ PROXY_RETRY_MAX: "9", PROXY_RETRY_BASE_MS: "250", PROXY_RETRY_MAX_MS: "60000" },
|
|
159
|
+
() => {
|
|
160
|
+
const config = resolveRetryConfig();
|
|
161
|
+
assert.equal(config.maxRetries, 9);
|
|
162
|
+
assert.equal(config.baseDelayMs, 250);
|
|
163
|
+
assert.equal(config.maxDelayMs, 60_000);
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("lets an explicit override beat env (the CLI --no-retry-429 path)", () => {
|
|
169
|
+
withEnv({ PROXY_RETRY_ON_429: "true" }, () =>
|
|
170
|
+
assert.equal(resolveRetryConfig({ enabled: false }).enabled, false),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|