icebear0828 Claude Opus 4.6 commited on
Commit
8cde2e9
·
1 Parent(s): 49640f7

fix: use real reset duration from 429 response + auto-fallback to next account (#65)

Browse files

- Parse resets_in_seconds / resets_at from 429 error body instead of
using fixed 60s backoff, so rate-limited accounts stay inactive for
their actual cooldown period (e.g. 5.5 days for free plans)
- Auto-retry with the next available account on 429 before returning
error to client (same pattern as model-not-supported retry)
- Add window_reset_at as second tiebreaker in least_used scheduling
(prefer accounts whose quota resets sooner)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/auth/account-pool.ts CHANGED
@@ -132,10 +132,14 @@ export class AccountPool {
132
  this.roundRobinIndex++;
133
  return selected;
134
  }
135
- // least_used: sort by request_count asc, then by last_used asc (LRU)
136
  candidates.sort((a, b) => {
137
  const diff = a.usage.request_count - b.usage.request_count;
138
  if (diff !== 0) return diff;
 
 
 
 
139
  const aTime = a.usage.last_used ? new Date(a.usage.last_used).getTime() : 0;
140
  const bTime = b.usage.last_used ? new Date(b.usage.last_used).getTime() : 0;
141
  return aTime - bTime;
 
132
  this.roundRobinIndex++;
133
  return selected;
134
  }
135
+ // least_used: sort by request_count asc window_reset_at asc → last_used asc (LRU)
136
  candidates.sort((a, b) => {
137
  const diff = a.usage.request_count - b.usage.request_count;
138
  if (diff !== 0) return diff;
139
+ // Prefer accounts whose quota window resets sooner (more fresh capacity)
140
+ const aReset = a.usage.window_reset_at ?? Infinity;
141
+ const bReset = b.usage.window_reset_at ?? Infinity;
142
+ if (aReset !== bReset) return aReset - bReset;
143
  const aTime = a.usage.last_used ? new Date(a.usage.last_used).getTime() : 0;
144
  const bTime = b.usage.last_used ? new Date(b.usage.last_used).getTime() : 0;
145
  return aTime - bTime;
src/routes/shared/proxy-handler.ts CHANGED
@@ -59,6 +59,23 @@ function toErrorStatus(status: number): StatusCode {
59
  return (status >= 400 && status < 600 ? status : 502) as StatusCode;
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  /** Check if a CodexApiError indicates the model is not supported on the account's plan. */
63
  function isModelNotSupportedError(err: CodexApiError): boolean {
64
  // Only 4xx client errors (exclude 429 rate-limit)
@@ -256,8 +273,29 @@ export async function handleProxyRequest(
256
  err.message,
257
  );
258
  if (err.status === 429) {
259
- // P1-6: Count 429s as requests via encapsulated API (no direct entry mutation)
260
- accountPool.markRateLimited(activeEntryId, { countRequest: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  c.status(429);
262
  return c.json(fmt.format429(err.message));
263
  }
 
59
  return (status >= 400 && status < 600 ? status : 502) as StatusCode;
60
  }
61
 
62
+ /** Extract the rate-limit reset duration from a 429 error body, if available. */
63
+ function extractRetryAfterSec(body: string): number | undefined {
64
+ try {
65
+ const parsed = JSON.parse(body) as Record<string, unknown>;
66
+ const error = parsed.error as Record<string, unknown> | undefined;
67
+ if (!error) return undefined;
68
+ if (typeof error.resets_in_seconds === "number" && error.resets_in_seconds > 0) {
69
+ return error.resets_in_seconds;
70
+ }
71
+ if (typeof error.resets_at === "number" && error.resets_at > 0) {
72
+ const diff = error.resets_at - Date.now() / 1000;
73
+ return diff > 0 ? diff : undefined;
74
+ }
75
+ } catch { /* use default backoff */ }
76
+ return undefined;
77
+ }
78
+
79
  /** Check if a CodexApiError indicates the model is not supported on the account's plan. */
80
  function isModelNotSupportedError(err: CodexApiError): boolean {
81
  // Only 4xx client errors (exclude 429 rate-limit)
 
273
  err.message,
274
  );
275
  if (err.status === 429) {
276
+ const retryAfterSec = extractRetryAfterSec(err.body);
277
+ accountPool.markRateLimited(activeEntryId, { retryAfterSec, countRequest: true });
278
+
279
+ const failedEmail = accountPool.getEntry(activeEntryId)?.email ?? "?";
280
+ console.warn(
281
+ `[${fmt.tag}] Account ${activeEntryId} (${failedEmail}) | 429 rate limited` +
282
+ (retryAfterSec != null ? ` (resets in ${Math.round(retryAfterSec)}s)` : "") +
283
+ `, trying different account...`,
284
+ );
285
+
286
+ const retry = accountPool.acquire({
287
+ model: req.codexRequest.model,
288
+ excludeIds: triedEntryIds,
289
+ });
290
+ if (retry) {
291
+ activeEntryId = retry.entryId;
292
+ triedEntryIds.push(retry.entryId);
293
+ const retryProxyUrl = proxyPool?.resolveProxyUrl(retry.entryId);
294
+ codexApi = new CodexApi(retry.token, retry.accountId, cookieJar, retry.entryId, retryProxyUrl);
295
+ console.log(`[${fmt.tag}] 429 fallback → account ${retry.entryId}`);
296
+ continue;
297
+ }
298
+
299
  c.status(429);
300
  return c.json(fmt.format429(err.message));
301
  }