fix: parse complete retry durations and preserve local IDE configuration
This commit is contained in:
1 parent
e8eaa049d8
commit
b193bee03f
5 files changed
+97
-21
No files matched your search
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user