@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 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 invocation path constants.
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
- "@dbx-tools/model": "0.3.33",
22
- "@dbx-tools/shared-core": "0.3.33",
23
- "@dbx-tools/shared-model": "0.3.33"
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.33",
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
- return createServer((req, res) => {
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(backend, body, requested, resolved.modelId, resolved.matched, res);
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 fetch(backend.invocationsUrl(resolved.modelId), {
260
- method: "POST",
445
+ const upstream = await upstreamFetchRetrying(
446
+ backend,
447
+ backend.invocationsUrl(resolved.modelId),
261
448
  headers,
262
- body: JSON.stringify(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 fetch(upstreamUrl, {
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 fetch(upstreamUrl, {
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
- /** Pump an upstream `fetch` Response body to the Node response, chunk by chunk. */
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
+ });