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

+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({