Spaces:
Paused
Paused
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
|
| 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 |
-
|
| 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 |
}
|