@dbx-tools/cli-model-proxy 0.3.34 → 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
@@ -174,6 +174,43 @@ An upstream failure that happens _after_ the response headers are sent is
174
174
  logged (`stream ended early`) rather than thrown, since the status line is
175
175
  already committed and cannot be turned into an HTTP error.
176
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
+
177
214
  ## Unsupported Request Fields
178
215
 
179
216
  Databricks Model Serving validates the chat body strictly, so a single
@@ -205,7 +242,7 @@ PROXY_DROP_FIELDS=some_new_field,another dbx-tools-model-proxy
205
242
  - `backend` - `DatabricksBackend`, auth, model resolution, and upstream request
206
243
  forwarding.
207
244
  - `server` - Express proxy app and `startProxyServer()`.
208
- - `defaults` - bind host, port, and invocation path constants.
245
+ - `defaults` - bind host, port, and the 429-retry policy (`resolveRetryConfig`).
209
246
 
210
247
  Endpoint ranking and fuzzy matching come from
211
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
@@ -19,16 +19,16 @@
19
19
  "commander": "^15.0.0",
20
20
  "tsx": "^4.23.0",
21
21
  "undici": "^7.17.0",
22
- "@dbx-tools/model": "0.3.34",
23
- "@dbx-tools/shared-core": "0.3.34",
24
- "@dbx-tools/shared-model": "0.3.34"
22
+ "@dbx-tools/model": "0.3.35",
23
+ "@dbx-tools/shared-model": "0.3.35",
24
+ "@dbx-tools/shared-core": "0.3.35"
25
25
  },
26
26
  "main": "index.ts",
27
27
  "license": "UNLICENSED",
28
28
  "publishConfig": {
29
29
  "access": "public"
30
30
  },
31
- "version": "0.3.34",
31
+ "version": "0.3.35",
32
32
  "types": "index.ts",
33
33
  "type": "module",
34
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,12 +32,12 @@
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
37
  import { Agent } from "undici";
38
38
 
39
39
  import type { DatabricksBackend } from "./backend";
40
- import { DEFAULT_BIND_HOST, DEFAULT_PORT } from "./defaults";
40
+ import { DEFAULT_BIND_HOST, DEFAULT_PORT, DEFAULT_RETRY, type RetryConfig } from "./defaults";
41
41
 
42
42
  const { chatToResponsesRequest, responseToChatCompletion, sanitizeOpenResponsesRequest } =
43
43
  openaiResponses;
@@ -101,6 +101,11 @@ export interface ProxyServerOptions {
101
101
  * for a loopback bind but should be paired with a key on a wider one.
102
102
  */
103
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;
104
109
  }
105
110
 
106
111
  /** Options for {@link startProxyServer}, adding the listen address. */
@@ -155,7 +160,7 @@ function clientAbortSignal(res: ServerResponse): AbortSignal {
155
160
 
156
161
  /**
157
162
  * POST a JSON body to a serving endpoint on the no-timeout
158
- * {@link upstreamDispatcher}, cancelled by the client hanging up.
163
+ * {@link upstreamDispatcher}, cancelled via `signal`.
159
164
  *
160
165
  * Every upstream call goes through here so the timeout and cancellation policy
161
166
  * is stated once. `dispatcher` is an undici extension that the DOM
@@ -165,15 +170,10 @@ function clientAbortSignal(res: ServerResponse): AbortSignal {
165
170
  async function upstreamFetch(
166
171
  url: string,
167
172
  headers: Record<string, string>,
168
- body: unknown,
169
- res: ServerResponse,
173
+ body: string,
174
+ signal: AbortSignal,
170
175
  ): Promise<Response> {
171
- const init: RequestInit = {
172
- method: "POST",
173
- headers,
174
- body: JSON.stringify(body),
175
- signal: clientAbortSignal(res),
176
- };
176
+ const init: RequestInit = { method: "POST", headers, body, signal };
177
177
  // `dispatcher` is an undici extension the DOM `RequestInit` doesn't declare,
178
178
  // and it can't simply be typed on: `@types/node` pins its own `undici-types`
179
179
  // copy, so the `Agent` from our `undici` dependency is a structurally
@@ -184,6 +184,98 @@ async function upstreamFetch(
184
184
  return fetch(url, init);
185
185
  }
186
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();
277
+ }
278
+
187
279
  /**
188
280
  * Build and start the proxy, resolving once it is accepting connections.
189
281
  * Returns the server and its base URL (with the actually-bound port, so a
@@ -233,12 +325,13 @@ async function handleRequest(
233
325
  await handleModels(backend, res, wantsCodexShape);
234
326
  return;
235
327
  }
328
+ const retry = options.retry ?? DEFAULT_RETRY;
236
329
  if (req.method === "POST" && RESPONSES_PATHS.has(routePath)) {
237
- await handleResponses(backend, req, res);
330
+ await handleResponses(backend, req, res, retry);
238
331
  return;
239
332
  }
240
333
  if (req.method === "POST" && PROXY_PATHS.has(routePath)) {
241
- await handleProxy(backend, req, res);
334
+ await handleProxy(backend, req, res, retry);
242
335
  return;
243
336
  }
244
337
  sendJson(
@@ -305,6 +398,7 @@ async function handleProxy(
305
398
  backend: DatabricksBackend,
306
399
  req: IncomingMessage,
307
400
  res: ServerResponse,
401
+ retry: RetryConfig,
308
402
  ): Promise<void> {
309
403
  const body = await readJsonBody(req);
310
404
  const requested = typeof body.model === "string" ? body.model : undefined;
@@ -317,7 +411,15 @@ async function handleProxy(
317
411
  body.model = resolved.modelId;
318
412
 
319
413
  if (backend.isResponsesOnly(resolved.modelId)) {
320
- 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
+ );
321
423
  return;
322
424
  }
323
425
 
@@ -340,11 +442,13 @@ async function handleProxy(
340
442
  ...(dropped.length > 0 ? { dropped } : {}),
341
443
  });
342
444
 
343
- const upstream = await upstreamFetch(
445
+ const upstream = await upstreamFetchRetrying(
446
+ backend,
344
447
  backend.invocationsUrl(resolved.modelId),
345
448
  headers,
346
449
  body,
347
450
  res,
451
+ retry,
348
452
  );
349
453
 
350
454
  res.writeHead(upstream.status, {
@@ -366,6 +470,7 @@ async function proxyChatViaResponses(
366
470
  modelId: string,
367
471
  matched: boolean,
368
472
  res: ServerResponse,
473
+ retry: RetryConfig,
369
474
  ): Promise<void> {
370
475
  const { responses, stream } = chatToResponsesRequest(body);
371
476
  responses.model = modelId;
@@ -399,7 +504,7 @@ async function proxyChatViaResponses(
399
504
  stream: false,
400
505
  });
401
506
 
402
- const upstream = await upstreamFetch(upstreamUrl, headers, forward, res);
507
+ const upstream = await upstreamFetchRetrying(backend, upstreamUrl, headers, forward, res, retry);
403
508
 
404
509
  if (!upstream.ok) {
405
510
  const text = await upstream.text();
@@ -428,6 +533,7 @@ async function handleResponses(
428
533
  backend: DatabricksBackend,
429
534
  req: IncomingMessage,
430
535
  res: ServerResponse,
536
+ retry: RetryConfig,
431
537
  ): Promise<void> {
432
538
  const body = await readJsonBody(req);
433
539
  const requested = typeof body.model === "string" ? body.model : undefined;
@@ -464,7 +570,7 @@ async function handleResponses(
464
570
  ...(stripped ? { strippedNonFunctionTools: true } : {}),
465
571
  });
466
572
 
467
- const upstream = await upstreamFetch(upstreamUrl, headers, forward, res);
573
+ const upstream = await upstreamFetchRetrying(backend, upstreamUrl, headers, forward, res, retry);
468
574
 
469
575
  res.writeHead(upstream.status, {
470
576
  "content-type": upstream.headers.get("content-type") ?? "application/json",
@@ -3,7 +3,8 @@ import { createServer, type Server } from "node:http";
3
3
  import { describe, it } from "node:test";
4
4
 
5
5
  import type { DatabricksBackend } from "../src/backend";
6
- import { createProxyServer } from "../src/server";
6
+ import { DEFAULT_RETRY, resolveRetryConfig } from "../src/defaults";
7
+ import { backoffDelayMs, createProxyServer, parseRetryAfterMs } from "../src/server";
7
8
 
8
9
  /** Listen on an ephemeral port and resolve the bound port. */
9
10
  async function listen(server: Server): Promise<number> {
@@ -71,3 +72,102 @@ describe("upstream dispatcher", () => {
71
72
  assert.equal(await readThroughStall(0), "data: first\n\ndata: second\n\n");
72
73
  });
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
+ });