@timo972/cc-router 0.10.0 → 0.11.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { stats, boundModelId, createLocalRoutingErrorLog } from "./stats.js";
6
6
  import { logError } from "./logger.js";
7
7
  import { EmptyPoolError, NoEligibleAccountError } from "./account-pool.js";
8
8
  import { acquireRequestRoute, routeReasonDetails, routeFailureDetails } from "./lease-lifecycle.js";
9
+ import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
9
10
  /**
10
11
  * Mirrors `anthropic-routing.ts`'s `requestTerminated` check. This ingress
11
12
  * path never threads the raw `Request` through (only `Response`), so it
@@ -158,46 +159,64 @@ export async function runOpenAIIngress(opts) {
158
159
  res.status(500).json(envelope.wrap("proxy_error", "Unexpected routing error"));
159
160
  return;
160
161
  }
161
- const account = selected.route.account;
162
162
  const startedAt = now();
163
- const needed = needsOpenAIRefresh(account);
164
- let ready;
165
- try {
166
- ready = await prepareOpenAIAccount(account);
167
- }
168
- catch (error) {
169
- // A throwing refresh must behave exactly like a `false` return, never
170
- // crash the request (or the daemon).
171
- const message = error instanceof Error ? error.message : String(error);
172
- logError(account.id, 401, `openai token refresh threw: ${message}`);
173
- ready = false;
174
- }
175
- if (!ready) {
176
- selected.release();
177
- account.errorCount++;
178
- stats.totalErrors++;
179
- // Intentionally does not touch `account.healthy`: a single failed
180
- // refresh fails only this request. Disabling the account here would
181
- // hard-block it from every future request until a manual recovery, even
182
- // though the very next request naturally retries the refresh.
183
- //
184
- // It must, however, break session affinity and cool the account down.
185
- // A sticky binding survives this failure, so without both the session
186
- // would re-acquire the same broken account on every retry and never fail
187
- // over — 401ing forever while healthy accounts sit idle.
188
- if (selected.route.sessionId !== undefined && selected.route.bindingGeneration !== undefined) {
189
- openAIRouter.invalidate(selected.route.sessionId, account.id, selected.route.bindingGeneration);
163
+ const maxAttempts = Math.max(1, opts.maxAttempts ?? MAX_UPSTREAM_ATTEMPTS);
164
+ const sameAccountDelayMs = opts.sameAccountRetryDelayMs ?? SAME_ACCOUNT_RETRY_DELAY_MS;
165
+ const retryRefreshTimeoutMs = opts.retryRefreshTimeoutMs ?? RETRY_REFRESH_TIMEOUT_MS;
166
+ /**
167
+ * Refresh a routed account's token if needed. On failure this applies the
168
+ * shared refresh-failure bookkeeping and returns false; what the caller
169
+ * sends instead is its own decision — the first attempt answers a local
170
+ * 401, a retry attempt relays the upstream failure it already holds.
171
+ */
172
+ const prepareRoute = async (routed) => {
173
+ const account = routed.route.account;
174
+ const needed = needsOpenAIRefresh(account);
175
+ let ready;
176
+ try {
177
+ ready = await prepareOpenAIAccount(account);
190
178
  }
191
- openAIPool.setGlobalCooldownForAccount(account, REFRESH_FAILURE_COOLDOWN_MS, "unavailable");
192
- recordActivity({
193
- ts: now(),
194
- accountId: account.id,
195
- model: requestedModel,
196
- type: "error",
197
- statusCode: 401,
198
- path,
199
- details: "openai token refresh failed",
200
- });
179
+ catch (error) {
180
+ // A throwing refresh must behave exactly like a `false` return, never
181
+ // crash the request (or the daemon).
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ logError(account.id, 401, `openai token refresh threw: ${message}`);
184
+ ready = false;
185
+ }
186
+ if (!ready) {
187
+ routed.release();
188
+ account.errorCount++;
189
+ stats.totalErrors++;
190
+ // Intentionally does not touch `account.healthy`: a single failed
191
+ // refresh fails only this request. Disabling the account here would
192
+ // hard-block it from every future request until a manual recovery, even
193
+ // though the very next request naturally retries the refresh.
194
+ //
195
+ // It must, however, break session affinity and cool the account down.
196
+ // A sticky binding survives this failure, so without both the session
197
+ // would re-acquire the same broken account on every retry and never fail
198
+ // over — 401ing forever while healthy accounts sit idle.
199
+ if (routed.route.sessionId !== undefined && routed.route.bindingGeneration !== undefined) {
200
+ openAIRouter.invalidate(routed.route.sessionId, account.id, routed.route.bindingGeneration);
201
+ }
202
+ openAIPool.setGlobalCooldownForAccount(account, REFRESH_FAILURE_COOLDOWN_MS, "unavailable");
203
+ recordActivity({
204
+ ts: now(),
205
+ accountId: account.id,
206
+ model: requestedModel,
207
+ type: "error",
208
+ statusCode: 401,
209
+ path,
210
+ details: "openai token refresh failed",
211
+ });
212
+ return false;
213
+ }
214
+ account.healthy = true;
215
+ if (needed)
216
+ account.lastRefresh = now();
217
+ return true;
218
+ };
219
+ if (!(await prepareRoute(selected))) {
201
220
  res.status(401).json(envelope.wrap("authentication_error", "OpenAI subscription token refresh failed"));
202
221
  return;
203
222
  }
@@ -210,88 +229,168 @@ export async function runOpenAIIngress(opts) {
210
229
  selected.release();
211
230
  return;
212
231
  }
213
- account.healthy = true;
214
- if (needed)
215
- account.lastRefresh = now();
216
232
  let upstream;
217
- try {
218
- upstream = await forwardOpenAI({
219
- account,
220
- body: forwardBody,
221
- stream: forwardBody.stream === true,
222
- signal: clientGone.signal,
223
- });
224
- }
225
- catch (error) {
226
- // A client that hung up mid-forward rejects this call through the abort
227
- // above. That is a cancellation, not an upstream failure: the account did
228
- // nothing wrong, so counting it would push a healthy account toward the
229
- // unhealthy threshold and a cooldown for nothing more than a user pressing
230
- // Ctrl-C, and there is no client left to receive a 502 or to whom an
231
- // "upstream_error:network" entry would mean anything. Mirrors the
232
- // pre-forward disconnect branch above, which also just releases and stops.
233
- if (clientGone.signal.aborted || responseTerminated(res)) {
234
- selected.release();
233
+ let upstreamFailed;
234
+ let details;
235
+ let accountFailureCounted;
236
+ for (let attempt = 1;; attempt++) {
237
+ const account = selected.route.account;
238
+ const attemptStartedAt = now();
239
+ try {
240
+ upstream = await forwardOpenAI({
241
+ account,
242
+ body: forwardBody,
243
+ stream: forwardBody.stream === true,
244
+ signal: clientGone.signal,
245
+ });
246
+ }
247
+ catch (error) {
248
+ // A client that hung up mid-forward rejects this call through the abort
249
+ // above. That is a cancellation, not an upstream failure: the account did
250
+ // nothing wrong, so counting it would push a healthy account toward the
251
+ // unhealthy threshold and a cooldown for nothing more than a user pressing
252
+ // Ctrl-C, and there is no client left to receive a 502 or to whom an
253
+ // "upstream_error:network" entry would mean anything. Mirrors the
254
+ // pre-forward disconnect branch above, which also just releases and stops.
255
+ if (clientGone.signal.aborted || responseTerminated(res)) {
256
+ selected.release();
257
+ return;
258
+ }
259
+ // A rejected forward call (network failure) must produce a local 502,
260
+ // never an unhandled rejection. The lease releases via the response's
261
+ // own finish/close lifecycle once this response is sent.
262
+ account.errorCount++;
263
+ stats.totalErrors++;
264
+ const message = error instanceof Error ? error.message : String(error);
265
+ logError(account.id, 502, `openai request failed: ${message}`);
266
+ recordActivity({
267
+ ts: startedAt,
268
+ accountId: account.id,
269
+ model: requestedModel,
270
+ type: "error",
271
+ statusCode: 502,
272
+ path,
273
+ details: "upstream_error:network",
274
+ durationMs: now() - startedAt,
275
+ });
276
+ res.status(502).json(envelope.wrap("upstream_error", `OpenAI request failed: ${message}`));
235
277
  return;
236
278
  }
237
- // A rejected forward call (network failure) must produce a local 502,
238
- // never an unhandled rejection. The lease releases via the response's
239
- // own finish/close lifecycle once this response is sent.
240
- account.errorCount++;
279
+ // Cooldown/eligibility react to the raw upstream signal — this must not
280
+ // change based on how the relay later renders the response to the client.
281
+ upstreamFailed = upstream.status === 401 || upstream.status === 429 || upstream.status >= 500;
282
+ details = routeReasonDetails(selected.route);
283
+ // Tracks whether `account.errorCount`/`consecutiveErrors` were already
284
+ // incremented for this request by the upstream-classification branch below,
285
+ // so a relay-synthesized failure (e.g. a byte-transparent stream that
286
+ // observed an upstream `response.failed`/`error` event on an otherwise-200
287
+ // response) can still increment them once further down without double
288
+ // counting an upstream 401/429/5xx that already did.
289
+ accountFailureCounted = false;
290
+ try {
291
+ // Header/rate-limit parsing and cooldown bookkeeping run on live upstream
292
+ // data between the two request-level try/catches above — a throw here
293
+ // (e.g. an unreadable header) must degrade to "skip this bookkeeping",
294
+ // never crash the daemon or leave the relay below un-reached.
295
+ const headerRecord = headersToRecord(upstream.headers);
296
+ applyCodexRateLimits(account, parseCodexRateLimits(headerRecord, now()), now());
297
+ if (upstreamFailed) {
298
+ account.errorCount++;
299
+ account.consecutiveErrors++;
300
+ accountFailureCounted = true;
301
+ const applied = applyCodexFailureRouting(upstream.status, headerRecord, selected.route, requestedModel, openAIRouter, openAIPool, now);
302
+ details = routeFailureDetails(selected.route, upstream.status === 401 ? "token-invalid"
303
+ : upstream.status === 429 ? "rate-limited"
304
+ // Only 503/529 are treated as upstream overload for cooldown
305
+ // purposes; labelling an isolated 500/502/504 "service-overloaded"
306
+ // would contradict the routing decision actually taken.
307
+ : upstream.status === 503 || upstream.status === 529 ? "service-overloaded"
308
+ : "upstream-error", applied.limitingScope);
309
+ if (upstream.status === 401)
310
+ onUpstreamAuthFailure?.(account);
311
+ }
312
+ }
313
+ catch (error) {
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ logError(account.id, upstream.status, `openai response classification failed: ${message}`);
316
+ }
317
+ // Retryable statuses (429 || >= 500) are a strict subset of
318
+ // `upstreamFailed`, so this predicate alone decides the loop.
319
+ if (!isRetryableUpstreamStatus(upstream.status)
320
+ || attempt >= maxAttempts || clientGone.signal.aborted) {
321
+ break;
322
+ }
323
+ // Router-side failover/retry. The failure bookkeeping above already ran
324
+ // (cooldown, affinity break, error counters), and not a single response
325
+ // byte has been relayed, so the request can move to whichever account the
326
+ // pool would hand a brand-new request: a different one after a 429 or
327
+ // 503/529 cooldown, the same one after a plain 5xx. Everything is decided
328
+ // BEFORE the held failure response is abandoned — any dead end below
329
+ // still relays the original upstream failure unchanged.
330
+ let next;
331
+ try {
332
+ next = acquireRequestRoute(sessionKey, res, openAIRouter, { requestedModel });
333
+ }
334
+ catch (error) {
335
+ // Nothing eligible to fail over to — pass the failure through. Only
336
+ // routing-level rejections are expected here; anything else is a bug
337
+ // worth a log line, though pass-through stays the safe outcome.
338
+ if (!(error instanceof NoEligibleAccountError) && !(error instanceof EmptyPoolError)) {
339
+ const message = error instanceof Error ? error.message : String(error);
340
+ logError("proxy", 500, `unexpected routing failure during retry: ${message}`);
341
+ }
342
+ break;
343
+ }
344
+ if (upstream.status === 429 && next.route.account.id === account.id) {
345
+ // Re-sending a 429 to the account that produced it would only
346
+ // reproduce the rate limit. The cooldown normally guarantees a
347
+ // different account here; if it ever does not, pass through instead.
348
+ next.release();
349
+ break;
350
+ }
351
+ // Same bound as the Anthropic route: the held failure is ready to relay
352
+ // and the refresh fetch has no deadline of its own, so an unbounded wait
353
+ // here could withhold it for minutes. The refresh is not cancelled — a
354
+ // late outcome still runs prepareRoute's own bookkeeping in the
355
+ // background and readies (or cools down) the account for later requests.
356
+ const prepared = await boundedWait(prepareRoute(next), retryRefreshTimeoutMs, "still-pending", clientGone.signal);
357
+ if (prepared === "still-pending") {
358
+ if (!clientGone.signal.aborted) {
359
+ logError(next.route.account.id, 0, `failover token refresh still pending after ${retryRefreshTimeoutMs}ms — relaying held upstream failure`);
360
+ }
361
+ next.release();
362
+ break;
363
+ }
364
+ if (!prepared)
365
+ break;
366
+ // Committed: record the failed attempt and abandon its response.
241
367
  stats.totalErrors++;
242
- const message = error instanceof Error ? error.message : String(error);
243
- logError(account.id, 502, `openai request failed: ${message}`);
244
368
  recordActivity({
245
- ts: startedAt,
369
+ ts: attemptStartedAt,
246
370
  accountId: account.id,
247
371
  model: requestedModel,
248
372
  type: "error",
249
- statusCode: 502,
373
+ statusCode: upstream.status,
250
374
  path,
251
- details: "upstream_error:network",
252
- durationMs: now() - startedAt,
375
+ ...(opts.method !== undefined ? { method: opts.method } : {}),
376
+ ...(opts.source !== undefined ? { source: opts.source } : {}),
377
+ details: `${details}:will-retry`,
378
+ durationMs: now() - attemptStartedAt,
253
379
  });
254
- res.status(502).json(envelope.wrap("upstream_error", `OpenAI request failed: ${message}`));
255
- return;
256
- }
257
- // Cooldown/eligibility react to the raw upstream signal — this must not
258
- // change based on how the relay later renders the response to the client.
259
- const upstreamFailed = upstream.status === 401 || upstream.status === 429 || upstream.status >= 500;
260
- let details = routeReasonDetails(selected.route);
261
- // Tracks whether `account.errorCount`/`consecutiveErrors` were already
262
- // incremented for this request by the upstream-classification branch below,
263
- // so a relay-synthesized failure (e.g. a byte-transparent stream that
264
- // observed an upstream `response.failed`/`error` event on an otherwise-200
265
- // response) can still increment them once further down without double
266
- // counting an upstream 401/429/5xx that already did.
267
- let accountFailureCounted = false;
268
- try {
269
- // Header/rate-limit parsing and cooldown bookkeeping run on live upstream
270
- // data between the two request-level try/catches above — a throw here
271
- // (e.g. an unreadable header) must degrade to "skip this bookkeeping",
272
- // never crash the daemon or leave the relay below un-reached.
273
- const headerRecord = headersToRecord(upstream.headers);
274
- applyCodexRateLimits(account, parseCodexRateLimits(headerRecord, now()), now());
275
- if (upstreamFailed) {
276
- account.errorCount++;
277
- account.consecutiveErrors++;
278
- accountFailureCounted = true;
279
- const applied = applyCodexFailureRouting(upstream.status, headerRecord, selected.route, requestedModel, openAIRouter, openAIPool, now);
280
- details = routeFailureDetails(selected.route, upstream.status === 401 ? "token-invalid"
281
- : upstream.status === 429 ? "rate-limited"
282
- // Only 503/529 are treated as upstream overload for cooldown
283
- // purposes; labelling an isolated 500/502/504 "service-overloaded"
284
- // would contradict the routing decision actually taken.
285
- : upstream.status === 503 || upstream.status === 529 ? "service-overloaded"
286
- : "upstream-error", applied.limitingScope);
287
- if (upstream.status === 401)
288
- onUpstreamAuthFailure?.(account);
380
+ void upstream.body?.cancel().catch(() => { });
381
+ selected.release();
382
+ const sameAccount = next.route.account.id === account.id;
383
+ selected = next;
384
+ // An immediate same-account replay would hit whatever transient condition
385
+ // produced the 5xx still in progress; a failover needs no pause.
386
+ if (sameAccount)
387
+ await retryDelay(sameAccountDelayMs, clientGone.signal);
388
+ if (clientGone.signal.aborted || responseTerminated(res)) {
389
+ selected.release();
390
+ return;
289
391
  }
290
392
  }
291
- catch (error) {
292
- const message = error instanceof Error ? error.message : String(error);
293
- logError(account.id, upstream.status, `openai response classification failed: ${message}`);
294
- }
393
+ const account = selected.route.account;
295
394
  const entry = {
296
395
  ts: startedAt,
297
396
  accountId: account.id,
@@ -304,6 +403,7 @@ export async function runOpenAIIngress(opts) {
304
403
  };
305
404
  let finalStatus = upstream.status;
306
405
  let relayFailed = false;
406
+ let relayFailureMessage = "";
307
407
  const relayReport = { upstreamReportedFailure: false };
308
408
  try {
309
409
  const result = await relay(upstream, res, entry, relayReport);
@@ -316,7 +416,7 @@ export async function runOpenAIIngress(opts) {
316
416
  // can do is tear the connection down.
317
417
  relayFailed = true;
318
418
  const message = error instanceof Error ? error.message : String(error);
319
- logError(account.id, 502, `openai response relay failed: ${message}`);
419
+ relayFailureMessage = message;
320
420
  // The recorded status is what this request *became*, which is a failure
321
421
  // whether or not another HTTP response can still be sent. Leaving it at
322
422
  // the upstream's 200 in the headers-already-sent case produced an
@@ -346,6 +446,15 @@ export async function runOpenAIIngress(opts) {
346
446
  // upstream announce a failure. Only the truncation is the disconnect's to
347
447
  // explain away.
348
448
  const clientCancelled = clientGone.signal.aborted && !relayReport.upstreamReportedFailure;
449
+ // Logged only now, after the cancellation classification: the Codex CLI
450
+ // aborts streams routinely (a superseded turn, Ctrl-C, a pane closing),
451
+ // and each abort rejects the relay's body read with "This operation was
452
+ // aborted". Logging that from the catch block printed an [ERROR] line for
453
+ // every one — eight hours of them in one unattended overnight session —
454
+ // while the stats correctly ignored them. The log now matches the stats.
455
+ if (relayFailed && !clientCancelled) {
456
+ logError(account.id, 502, `openai response relay failed: ${relayFailureMessage}`);
457
+ }
349
458
  // Activity/stats must reflect what the client actually received, not just
350
459
  // the raw upstream signal: the non-streaming collector can synthesize a
351
460
  // local 502 from an upstream 200 whose SSE stream ended in
@@ -136,6 +136,13 @@ export function mountResponsesRoutes(app, opts) {
136
136
  now,
137
137
  envelope: RESPONSES_ENVELOPE,
138
138
  onUpstreamAuthFailure: opts.onUpstreamAuthFailure,
139
+ ...(opts.maxAttempts !== undefined ? { maxAttempts: opts.maxAttempts } : {}),
140
+ ...(opts.sameAccountRetryDelayMs !== undefined
141
+ ? { sameAccountRetryDelayMs: opts.sameAccountRetryDelayMs }
142
+ : {}),
143
+ ...(opts.retryRefreshTimeoutMs !== undefined
144
+ ? { retryRefreshTimeoutMs: opts.retryRefreshTimeoutMs }
145
+ : {}),
139
146
  relay: async (upstream, res, entry, report) => {
140
147
  if (body.stream === true) {
141
148
  const observer = createCodexUsageObserver();