fix: parse complete retry durations and preserve local IDE configuration

This commit is contained in:
Evandro Fonseca Junior committed 2026-09-30 17:36:01 -03:00
1 parent e8eaa049d8
commit b193bee03f
5 files changed
+97 -21

No files matched your search

+9 -1
View File
@@ -199,7 +199,7 @@ scripts/ dev environment setup, Windows build help
## Verification
What exists and what was run on Linux x64 (Node 24.20.0, pnpm 10.11.0):
Previously recorded Linux x64 checks (Node 24.20.0, pnpm 10.11.0), before the review below:
| Command | Result |
|---|---|
@@ -248,3 +248,11 @@ The project is being migrated to MIT as the remaining original code is rewritten
## Credits
Originally forked from [AntigravityManager](https://github.com/Draculabo/AntigravityManager). Since the fork the codebase has been largely rewritten by [evandrodevbr](https://github.com/evandrodevbr): UI/UX, usage analytics, traffic monitor, environment isolation, CI/CD, test infrastructure and documentation.
## Review checks (2026-09-30)
Retry durations are parsed as complete values: `500ms` is milliseconds, and fractional components are rounded after summation. Invalid trailing text is rejected. Account and model lockouts honor the later expiration. IDE fallback configuration remains available at loopback when the OS denies network-interface enumeration.
Focused checks: `pnpm exec vitest run src/tests/unit/rate-limit-tracker.test.ts src/tests/unit/ipc/proxy-advanced-handler.test.ts`. Run `pnpm test`, `pnpm type-check`, `pnpm lint` and `pnpm format` for the full non-E2E gates. Native SQLite/keytar are mocked by the unit-test configuration; Electron packaging, real account switching and E2E still require separate validation.
This review passes all 508 tests across 54 files, type checking, lint (327 existing warnings, no errors) and formatting with Node 24.19.0 and pnpm 10.11.0. The original lockfile is retained: its audit still reports two critical findings, in `tar` and `seroval`, requiring a separately validated dependency update.
+12 -7
View File
@@ -453,16 +453,21 @@ export async function generateIdeConfig(
const apiKey = config?.api_key || 'YOUR_API_KEY';
let localIp = '127.0.0.1';
const interfaces = networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (iface) {
for (const entry of iface) {
if (entry.family === 'IPv4' && !entry.internal) {
localIp = entry.address;
break;
try {
const interfaces = networkInterfaces();
for (const iface of Object.values(interfaces)) {
if (iface) {
for (const entry of iface) {
if (entry.family === 'IPv4' && !entry.internal) {
localIp = entry.address;
break;
}
}
}
}
} catch (error) {
// Local proxy configuration remains usable when LAN enumeration is restricted.
logger.warn('Could not enumerate network interfaces; using loopback', error);
}
// Fallback generator mimicking the proxyIdeConfig.service
+9 -11
View File
@@ -44,7 +44,9 @@ function toLowerText(value: string | undefined): string {
}
function parseDurationToSeconds(text: string): number | null {
const match = text.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?/i);
const match = text
.trim()
.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?(?:(\d+(?:\.\d+)?)ms)?$/i);
if (!match) {
return null;
}
@@ -54,9 +56,8 @@ function parseDurationToSeconds(text: string): number | null {
const seconds = Number(match[3] ?? 0);
const milliseconds = Number(match[4] ?? 0);
const totalSeconds =
hours * 3600 + minutes * 60 + Math.ceil(seconds) + Math.ceil(milliseconds / 1000);
if (totalSeconds <= 0) {
const totalSeconds = Math.ceil(hours * 3600 + minutes * 60 + seconds + milliseconds / 1000);
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
return null;
}
return totalSeconds;
@@ -108,19 +109,16 @@ export class RateLimitTracker {
const now = Date.now();
const globalLock = this.lockoutByKey.get(accountId);
if (globalLock && globalLock.resetTimeMs > now) {
return Math.max(0, Math.ceil((globalLock.resetTimeMs - now) / 1000));
}
let resetTimeMs = globalLock?.resetTimeMs ?? now;
if (model) {
const modelKey = this.buildLockoutKey(accountId, model);
const modelLock = this.lockoutByKey.get(modelKey);
if (modelLock && modelLock.resetTimeMs > now) {
return Math.max(0, Math.ceil((modelLock.resetTimeMs - now) / 1000));
}
resetTimeMs = Math.max(resetTimeMs, modelLock?.resetTimeMs ?? now);
}
return 0;
// Both scopes apply; retrying before either lock expires still fails upstream.
return Math.max(0, Math.ceil((resetTimeMs - now) / 1000));
}
getRemainingWaitSec(accountId: string, model?: string): number {
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { networkInterfaces } from 'os';
import {
getRecentTrafficLogs,
getTrafficLogStats,
@@ -14,6 +15,11 @@ import {
import { TrafficLogsRepo } from '../../../ipc/database/proxyMetricsHandler';
import { proxyAdvancedRegistry } from '../../../ipc/proxy-advanced/service-registry';
vi.mock('os', async (importOriginal) => ({
...(await importOriginal<typeof import('os')>()),
networkInterfaces: vi.fn().mockReturnValue({}),
}));
vi.mock('../../../utils/logger', () => ({
logger: {
info: vi.fn(),
@@ -415,9 +421,18 @@ describe('Proxy Advanced Handler', () => {
});
describe('generateIdeConfig', () => {
it('uses loopback when network enumeration fails', async () => {
vi.mocked(networkInterfaces).mockImplementationOnce(() => {
throw new Error('network enumeration denied');
});
const result = await generateIdeConfig('vscode');
expect(result.success, result.error).toBe(true);
expect(result.data?.content).toMatchObject({ 'openai.url': 'http://127.0.0.1:8045/v1' });
});
it('should generate fallback config when proxyIdeConfig is null', async () => {
const result = await generateIdeConfig('vscode');
expect(result.success).toBe(true);
expect(result.success, result.error).toBe(true);
expect(result.data?.name).toBe('VS Code');
});
+51 -1
View File
@@ -1,7 +1,57 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { RateLimitReason, RateLimitTracker } from '../../server/modules/proxy/rate-limit-tracker';
describe('RateLimitTracker parity replay', () => {
it.each([
['500ms', 2],
['2500ms', 3],
['2.2s100ms', 3],
['1h2m3s', 3723],
[' 30s ', 30],
['30seconds', 5],
['invalid30s', 5],
])(
'parses complete retry duration %s without confusing minutes and milliseconds',
(duration, expected) => {
const tracker = new RateLimitTracker();
const info = tracker.parseAndMarkFromError({
accountId: 'acc-duration',
status: 503,
body: JSON.stringify({
error: { details: [{ reason: 'MODEL_CAPACITY_EXHAUSTED', retryDelay: duration }] },
}),
backoffSteps: [60],
});
expect(info?.retryAfterSec).toBe(expected);
},
);
it('waits for the longer of the account and model lockouts', () => {
const now = Date.parse('2026-01-01T00:00:00Z');
const clock = vi.spyOn(Date, 'now').mockReturnValue(now);
try {
const tracker = new RateLimitTracker();
tracker.setLockoutUntilIso(
'acc-overlap',
new Date(now + 5000).toISOString(),
RateLimitReason.RateLimitExceeded,
);
tracker.setLockoutUntilIso(
'acc-overlap',
new Date(now + 60000).toISOString(),
RateLimitReason.QuotaExhausted,
'model-a',
);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-a')).toBe(60);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-b')).toBe(5);
clock.mockReturnValue(now + 10000);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-a')).toBe(50);
expect(tracker.getRemainingWaitSeconds('acc-overlap', 'model-b')).toBe(0);
} finally {
clock.mockRestore();
}
});
it('uses Retry-After header before body/default', () => {
const tracker = new RateLimitTracker();
const info = tracker.parseAndMarkFromError({