diff --git a/server/src/index.ts b/server/src/index.ts index 3489c837..cdfcf5f1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -27,7 +27,16 @@ export function createApp(): express.Express { app.use(helmet()); app.use( cors({ - exposedHeaders: ['X-Log-Count', 'Mcp-Session-Id', 'mcp-session-id'], + exposedHeaders: [ + 'X-Log-Count', + 'Mcp-Session-Id', + 'mcp-session-id', + 'Retry-After', + 'retry-after-ms', + 'X-RateLimit-Remaining', + 'X-RateLimit-Reset', + 'x-ratelimit-remaining', + ], allowedHeaders: [ 'Content-Type', 'Authorization', diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index d0033f7c..4ab3454b 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -27,7 +27,43 @@ function getProxyConfig(): ProxyConfig | null { const router = Router(); -// Helper: build API URL handling baseUrl already ending in version prefix +// 透传上游与限流相关的响应头,供前端(含直连模式)统一识别 Retry-After 与剩余配额。 +const UPSTREAM_RATE_LIMIT_HEADERS = new Set([ + 'retry-after', + 'retry-after-ms', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens', + 'x-ratelimit-reset-requests', + 'x-ratelimit-reset-tokens', + 'x-ratelimit-limit-requests', + 'x-ratelimit-limit-tokens', + 'x-ratelimit-remaining', + 'x-ratelimit-reset', + 'x-ratelimit-limit', + 'ratelimit-remaining', + 'ratelimit-reset', + 'ratelimit-limit', + 'anthropic-ratelimit-requests-remaining', + 'anthropic-ratelimit-requests-reset', + 'anthropic-ratelimit-input-tokens-remaining', + 'anthropic-ratelimit-input-tokens-reset', + 'anthropic-ratelimit-output-tokens-remaining', + 'anthropic-ratelimit-output-tokens-reset', + 'x-should-retry', +]); + +function relayRateLimitHeaders(res: import('express').Response, headers: Record | undefined): void { + if (!headers) return; + for (const [key, value] of Object.entries(headers)) { + if (UPSTREAM_RATE_LIMIT_HEADERS.has(key.toLowerCase()) && typeof value === 'string' && value) { + try { + res.setHeader(key, value); + } catch { /* ignore invalid header name */ } + } + } +} + +// Helper: build API URL handling base already ending in version prefix function buildApiUrl(baseUrl: string, pathWithVersion: string): string { const baseUrlWithSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const versionPrefix = pathWithVersion.split('/')[0] || ''; @@ -104,6 +140,7 @@ router.post('/api/proxy/github/*', async (req, res) => { const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method, headers, body: body.body, proxyConfig }); + relayRateLimitHeaders(res, result.headers); res.status(result.status).json(result.data); } catch (err) { logger.errorFromError('proxy.github', 'GitHub proxy error', err); @@ -175,6 +212,7 @@ router.post('/api/proxy/github-raw', async (req, res) => { // Raw content is text/plain, forward as-is (not JSON-wrapped) const contentType = String(result.headers['content-type'] || 'text/plain'); + relayRateLimitHeaders(res, result.headers); res.status(result.status).type(contentType).send(typeof result.data === 'string' ? result.data : JSON.stringify(result.data)); } catch (err) { logger.errorFromError('proxy.github-raw', 'GitHub raw proxy error', err); @@ -302,6 +340,7 @@ router.post('/api/proxy/ai', async (req, res) => { allowPrivate, }); + relayRateLimitHeaders(res, result.headers); res.status(result.status).json(result.data); } catch (err) { logger.errorFromError('proxy.ai', 'AI proxy error', err); @@ -366,6 +405,7 @@ router.post('/api/proxy/webdav', async (req, res) => { allowPrivate: true, }); + relayRateLimitHeaders(res, result.headers); res.status(result.status).json(result.data); } catch (err) { logger.errorFromError('proxy.webdav', 'WebDAV proxy error', err); @@ -406,6 +446,7 @@ router.post('/api/proxy/github/search/repositories', async (req, res) => { const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); + relayRateLimitHeaders(res, result.headers); res.status(result.status).json(result.data); } catch (err) { logger.errorFromError('proxy.github.search', 'GitHub search repositories proxy error', err); @@ -446,6 +487,7 @@ router.post('/api/proxy/github/search/users', async (req, res) => { const proxyConfig = getProxyConfig(); const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); + relayRateLimitHeaders(res, result.headers); res.status(result.status).json(result.data); } catch (err) { logger.errorFromError('proxy.github.search', 'GitHub search users proxy error', err); diff --git a/server/tests/routes/githubProxyRoute.test.ts b/server/tests/routes/githubProxyRoute.test.ts index d77ba0ab..97c17307 100644 --- a/server/tests/routes/githubProxyRoute.test.ts +++ b/server/tests/routes/githubProxyRoute.test.ts @@ -139,4 +139,34 @@ describe('GitHub proxy routes', () => { expect(options.headers['Content-Length']).toBeUndefined(); expect(options.headers['X-Custom']).toBe('kept'); }); + + it('relays upstream rate-limit headers to the client and skips unrelated ones', async () => { + const app = createTestApp(); + proxyRequestMock.mockResolvedValueOnce({ + status: 429, + data: { error: { message: 'rate limited' } }, + headers: { + 'content-type': 'application/json', + 'retry-after-ms': '2500', + 'retry-after': '5', + 'x-ratelimit-remaining-requests': '12', + 'x-ratelimit-remaining-tokens': '900', + 'x-debug-id': 'secret-upstream-id', + 'set-cookie': 'sid=abc', + }, + }); + + const res = await request(app) + .post('/api/proxy/github/gists/abc123') + .send({ method: 'GET' }) + .expect(429); + + expect(res.headers['retry-after-ms']).toBe('2500'); + expect(res.headers['retry-after']).toBe('5'); + expect(res.headers['x-ratelimit-remaining-requests']).toBe('12'); + expect(res.headers['x-ratelimit-remaining-tokens']).toBe('900'); + // 非限流相关头不应透传 + expect(res.headers['x-debug-id']).toBeUndefined(); + expect(res.headers['set-cookie']).toBeUndefined(); + }); }); diff --git a/src/components/DiscoveryView.tsx b/src/components/DiscoveryView.tsx index 6a1086a8..a43c60c7 100644 --- a/src/components/DiscoveryView.tsx +++ b/src/components/DiscoveryView.tsx @@ -793,6 +793,10 @@ export const DiscoveryView: React.FC = React.memo(() => { const aiService = new AIService(activeConfig, language); const optimizer = new AIAnalysisOptimizer({ initialConcurrency: activeConfig.concurrency || 3, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: activeConfig.requestsPerMinute || 0, + }, }); setAnalysisOptimizer(optimizer); diff --git a/src/components/RepositoryList.tsx b/src/components/RepositoryList.tsx index ab65ba8c..3511a4b1 100644 --- a/src/components/RepositoryList.tsx +++ b/src/components/RepositoryList.tsx @@ -322,6 +322,10 @@ export const RepositoryList: React.FC = ({ maxRetries: 3, retryDelayBaseMs: 1000, enableAdaptiveConcurrency: true, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: activeConfig.requestsPerMinute || 0, + }, }); try { @@ -675,6 +679,10 @@ export const RepositoryList: React.FC = ({ maxRetries: 3, retryDelayBaseMs: 1000, enableAdaptiveConcurrency: true, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: activeConfig.requestsPerMinute || 0, + }, }); try { diff --git a/src/services/aiAnalysisOptimizer.test.ts b/src/services/aiAnalysisOptimizer.test.ts new file mode 100644 index 00000000..b2c3f383 --- /dev/null +++ b/src/services/aiAnalysisOptimizer.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AIAnalysisOptimizer, AnalysisTask } from './aiAnalysisOptimizer'; +import type { AIService } from './aiService'; +import type { GitHubApiService } from './githubApi'; +import { AIRequestError } from './aiService'; +import type { Repository } from '../types'; + +function makeRepo(id: number): Repository { + return { + id, + name: `repo-${id}`, + full_name: `acme/repo-${id}`, + description: null, + html_url: `https://github.com/acme/repo-${id}`, + stargazers_count: 0, + forks_count: 0, + forks: 0, + language: 'TypeScript', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-06-01T00:00:00Z', + pushed_at: '2024-06-01T00:00:00Z', + owner: { login: 'owner', avatar_url: '' }, + topics: [], + }; +} + +function makeTask(id: number): AnalysisTask { + return { repo: makeRepo(id), readmeContent: '# readme', retries: 0 }; +} + +describe('AIAnalysisOptimizer 与共享限流器集成', () => { + it('遭遇 429 时记录到限流器、等待后重试成功,并清零计数', async () => { + const optimizer = new AIAnalysisOptimizer({ + maxRetries: 2, + retryDelayBaseMs: 1, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: 0, + cooldownThreshold: 3, + backoffBaseMs: 10, + backoffCapMs: 120, + maxRetryAfterMs: 50, + }, + }); + + const analyze = vi.fn() + .mockRejectedValueOnce(new AIRequestError('rate limited', 429, 200000)) + .mockResolvedValueOnce({ summary: 'ok', tags: [], platforms: [] }); + const fakeAi = { analyzeRepository: analyze } as unknown as AIService; + + const result = await optimizer.analyzeWithRetry(makeTask(1), fakeAi, []); + + expect(result.success).toBe(true); + expect(analyze).toHaveBeenCalledTimes(2); + // 成功后将连续 429 计数复位 + expect(optimizer.limiter.getStatus().consecutiveRateLimits).toBe(0); + }); + + it('连续 429 超过阈值触发熔断,重试耗尽后返回失败', async () => { + const optimizer = new AIAnalysisOptimizer({ + maxRetries: 2, + retryDelayBaseMs: 1, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: 0, + cooldownThreshold: 2, + backoffBaseMs: 5, + backoffCapMs: 50, + maxRetryAfterMs: 30, + }, + }); + + const analyze = vi.fn().mockRejectedValue(new AIRequestError('too many requests', 429)); + const fakeAi = { analyzeRepository: analyze } as unknown as AIService; + + const result = await optimizer.analyzeWithRetry(makeTask(2), fakeAi, []); + + expect(result.success).toBe(false); + expect(result.error).toBeInstanceOf(AIRequestError); + // 3 次调用 >= 阈值 2 => 熔断打开 + expect(optimizer.limiter.getStatus().circuitOpen).toBe(true); + expect(optimizer.limiter.getStatus().consecutiveRateLimits).toBe(3); + }); + + it('非限流错误不受 RateLimiter 冷却影响', async () => { + const optimizer = new AIAnalysisOptimizer({ + maxRetries: 1, + rateLimiter: { maxConcurrency: 0, requestsPerMinute: 0, cooldownThreshold: 2 }, + }); + + const analyze = vi.fn() + .mockRejectedValueOnce(new Error('some server error')) + .mockResolvedValueOnce({ summary: 'ok', tags: [], platforms: [] }); + const fakeAi = { analyzeRepository: analyze } as unknown as AIService; + + const result = await optimizer.analyzeWithRetry(makeTask(3), fakeAi, []); + + expect(result.success).toBe(true); + expect(analyze).toHaveBeenCalledTimes(2); + expect(optimizer.limiter.getStatus().consecutiveRateLimits).toBe(0); + }); + + it('abort() 立即停止阻塞在限流等待与重试延迟中的 worker', async () => { + const optimizer = new AIAnalysisOptimizer({ + maxRetries: 3, + retryDelayBaseMs: 10000, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: 0, + cooldownThreshold: 1, + backoffBaseMs: 10000, + backoffCapMs: 10000, + maxRetryAfterMs: 10000, + }, + }); + + const analyze = vi.fn().mockRejectedValue(new AIRequestError('too many requests', 429)); + const fakeAi = { analyzeRepository: analyze } as unknown as AIService; + + // 第一个任务触发 429 后陷入长重试延迟;第二个任务阻塞在限流器 acquire + const first = optimizer.analyzeWithRetry(makeTask(4), fakeAi, []); + const second = optimizer.analyzeWithRetry(makeTask(5), fakeAi, []); + + // 给两个任务时间进入阻塞状态,然后中止整个批次 + await new Promise(r => setTimeout(r, 100)); + const start = Date.now(); + optimizer.abort(); + + const [r1, r2] = await Promise.all([first, second]); + const elapsed = Date.now() - start; + + // abort 应立刻穿过冷却等待 / 重试延迟(总时长被设为 10s) + expect(elapsed).toBeLessThan(500); + expect(r1.success).toBe(false); + expect(r1.error?.message).toBe('Analysis aborted'); + expect(r2.success).toBe(false); + expect(r2.error?.message).toBe('Analysis aborted'); + }); + + it('abort() 立即结束流水线,即使 README 请求仍在飞行', async () => { + const optimizer = new AIAnalysisOptimizer({ + maxRetries: 0, + rateLimiter: { maxConcurrency: 0, requestsPerMinute: 0 }, + }); + + const analyze = vi.fn().mockResolvedValue({ summary: 'ok', tags: [], platforms: [] }); + const fakeAi = { analyzeRepository: analyze } as unknown as AIService; + + // README 拉取在收到 batch 信号中止前一直挂起:验证信号确实被传到了请求 API + let resolveReadmeStarted = () => {}; + const readmeStarted = new Promise(resolve => { + resolveReadmeStarted = resolve; + }); + const readmeAborted = vi.fn(); + const githubApi = { + getRepositoryReadme: vi.fn((_owner: string, _repo: string, signal?: AbortSignal) => { + resolveReadmeStarted(); + return new Promise((_resolve, reject) => { + const onAbort = () => { + readmeAborted(); + reject(new Error('Aborted')); + }; + if (!signal) { + reject(new Error('Missing AbortSignal')); + } else if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + } + }); + }), + } as unknown as GitHubApiService; + + const pending = optimizer.analyzeRepositoriesPipelined( + [makeRepo(6), makeRepo(7)], githubApi, fakeAi, []); + + // 等 README 请求真正启动后再中止整个批次,避免固定延时与请求启动竞速 + await readmeStarted; + const start = Date.now(); + optimizer.abort(); + await pending; + + expect(Date.now() - start).toBeLessThan(500); + expect(githubApi.getRepositoryReadme).toHaveBeenCalled(); + // 断言 README 请求确实收到了中止信号,而非流水线提前返回放任其挂起 + expect(readmeAborted).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/src/services/aiAnalysisOptimizer.ts b/src/services/aiAnalysisOptimizer.ts index 08ac816f..13e9d592 100644 --- a/src/services/aiAnalysisOptimizer.ts +++ b/src/services/aiAnalysisOptimizer.ts @@ -2,6 +2,8 @@ import { Repository } from '../types'; import { AIService } from './aiService'; import { GitHubApiService } from './githubApi'; import { backend } from './backendAdapter'; +import { AIRateLimiter, AIRateLimitConfig } from './aiRequestLimiter'; +import { isRateLimitedError, getRetryAfterMsFromError } from './aiService'; export interface AnalysisTask { repo: Repository; @@ -30,6 +32,8 @@ export interface OptimizerConfig { maxRetries: number; retryDelayBaseMs: number; enableAdaptiveConcurrency: boolean; + /** 共享 AI 请求限流配置;不传则使用默认(熔断开启,RPM/并发不限制) */ + rateLimiter?: AIRateLimitConfig; } const DEFAULT_CONFIG: OptimizerConfig = { @@ -41,6 +45,14 @@ const DEFAULT_CONFIG: OptimizerConfig = { maxRetries: 3, retryDelayBaseMs: 1000, enableAdaptiveConcurrency: true, + rateLimiter: { + maxConcurrency: 0, + requestsPerMinute: 0, + cooldownThreshold: 3, + backoffBaseMs: 1000, + backoffCapMs: 60000, + maxRetryAfterMs: 60000, + }, }; export class AIAnalysisOptimizer { @@ -51,18 +63,27 @@ export class AIAnalysisOptimizer { private paused = false; private activeWorkers = 0; private shouldExitWorkers = false; - private abortController: AbortController | null = null; + // 批次级中止信号:所有 worker 的限流等待、AI 请求与重试延迟共用同一信号, + // 保证 abort() 能立即停下所有仍在阻塞的 worker(而非只停最近一次请求)。 + private readonly batchAbortController = new AbortController(); + private readonly sharedLimiter: AIRateLimiter; constructor(config: Partial = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.currentConcurrency = this.config.initialConcurrency; + this.sharedLimiter = new AIRateLimiter(this.config.rateLimiter); } + /** 限流器实例:供外部(请求层 / 测试)复用同一份冷却与统计。 */ + get limiter(): AIRateLimiter { + return this.sharedLimiter; + } + + /** 中止整个批次:所有在飞请求、限流等待与重试延迟立即停止。 */ abort(): void { this.aborted = true; this.shouldExitWorkers = true; - this.abortController?.abort(); - this.abortController = null; + this.batchAbortController.abort(); } pause(): void { @@ -120,13 +141,30 @@ export class AIAnalysisOptimizer { } } - private delay(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + /** 可中止的延迟:abort() 后立即放行,调用方依靠 aborted 标记结束批次。 */ + private abortableDelay(ms: number): Promise { + return new Promise(resolve => { + const signal = this.batchAbortController.signal; + if (signal.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timer); + signal.removeEventListener('abort', onAbort); + resolve(); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort); + }); } private async waitWhilePaused(): Promise { while (this.paused && !this.aborted) { - await this.delay(500); + await this.abortableDelay(500); } } @@ -135,19 +173,20 @@ export class AIAnalysisOptimizer { return this.config.retryDelayBaseMs * Math.pow(2, retryCount) + jitter; } - private async fetchReadme(repo: Repository, githubApi: GitHubApiService): Promise { - if (this.aborted) return ''; + private async fetchReadme(repo: Repository, githubApi: GitHubApiService, signal?: AbortSignal): Promise { + if (this.aborted || signal?.aborted) return ''; await this.waitWhilePaused(); - if (this.aborted) return ''; + if (this.aborted || signal?.aborted) return ''; try { if (backend.isAvailable) { const [owner, name] = repo.full_name.split('/'); - return await backend.getRepositoryReadme(owner, name); + return await backend.getRepositoryReadme(owner, name, signal); } const [owner, name] = repo.full_name.split('/'); - return await githubApi.getRepositoryReadme(owner, name); + return await githubApi.getRepositoryReadme(owner, name, signal); } catch (error) { + if (this.aborted || signal?.aborted) return ''; console.warn(`Failed to fetch README for ${repo.full_name}:`, error); return ''; } @@ -163,12 +202,12 @@ export class AIAnalysisOptimizer { const results = new Map(); const fetchReadme = async (repo: Repository): Promise => { - if (this.aborted) return; + if (this.aborted || this.batchAbortController.signal.aborted) return; await this.waitWhilePaused(); - if (this.aborted) return; + if (this.aborted || this.batchAbortController.signal.aborted) return; try { - const content = await this.fetchReadme(repo, githubApi); + const content = await this.fetchReadme(repo, githubApi, this.batchAbortController.signal); results.set(repo.id, { content }); } catch (error) { results.set(repo.id, { content: '', error: error as Error }); @@ -186,7 +225,7 @@ export class AIAnalysisOptimizer { } if (i + concurrency < repos.length && !this.aborted) { - await this.delay(100); + await this.abortableDelay(100); } } @@ -227,24 +266,28 @@ export class AIAnalysisOptimizer { }; } - const controller = new AbortController(); - this.abortController = controller; - try { - const analysisStart = Date.now(); - const analysis = await aiService.analyzeRepository(task.repo, task.readmeContent, categoryNames, categoryHints, controller.signal); - const analysisDuration = Date.now() - analysisStart; - - this.recordResponseTime(analysisDuration); - - return { - repo: task.repo, - success: true, - summary: analysis.summary, - tags: analysis.tags, - platforms: analysis.platforms, - duration: Date.now() - startTime, - }; + // 通过共享限流器占用一个请求槽:冷却 / RPM 窗口内会自动等待 + const release = await this.sharedLimiter.acquire(this.batchAbortController.signal); + try { + // 起算点放在 acquire 之后:限流排队时间不应计入 provider 响应时长, + // 否则 429 冷却会把「并发调节」误判为 provider 变慢而持续降并发 + const analysisStart = Date.now(); + const analysis = await aiService.analyzeRepository(task.repo, task.readmeContent, categoryNames, categoryHints, this.batchAbortController.signal); + const analysisDuration = Date.now() - analysisStart; + this.sharedLimiter.notifySuccess(); + this.recordResponseTime(analysisDuration); + return { + repo: task.repo, + success: true, + summary: analysis.summary, + tags: analysis.tags, + platforms: analysis.platforms, + duration: Date.now() - startTime, + }; + } finally { + release(); + } } catch (error) { lastError = error as Error; @@ -257,13 +300,17 @@ export class AIAnalysisOptimizer { }; } - if (attempt < this.config.maxRetries) { - const delayMs = this.calculateRetryDelay(attempt); - await this.delay(delayMs); + // 429 / 限流:记入共享限流器,触发全局冷却并采纳服务端 Retry-After + if (isRateLimitedError(error)) { + this.sharedLimiter.notifyRateLimit(getRetryAfterMsFromError(error)); } - } finally { - if (this.abortController === controller) { - this.abortController = null; + + if (attempt < this.config.maxRetries) { + // 限流场景下,最短等待到全局冷却结束(含 Retry-After),避免与冷却窗口竞争 + const cooldownWait = this.sharedLimiter.getStatus().cooldownRemainingMs; + const delayMs = Math.max(this.calculateRetryDelay(attempt), cooldownWait); + // 可中止的等待:abort() 后立即结束等待,由下一轮循环的 aborted 检查兜底返回 + await this.abortableDelay(delayMs); } } } @@ -339,13 +386,13 @@ export class AIAnalysisOptimizer { const concurrencyMonitor = async (): Promise => { while (pendingRepos.length > 0 && !this.aborted) { - await this.delay(1000); - + await this.abortableDelay(1000); + if (this.shouldExitWorkers) { this.shouldExitWorkers = false; continue; } - + if (this.activeWorkers < this.currentConcurrency) { workerPromises.push(worker(totalWorkersStarted++)); } @@ -385,7 +432,7 @@ export class AIAnalysisOptimizer { return readmeFetching.get(repo.id)!; } - const promise = this.fetchReadme(repo, githubApi).then(content => { + const promise = this.fetchReadme(repo, githubApi, this.batchAbortController.signal).then(content => { readmeCache.set(repo.id, content); readmeFetching.delete(repo.id); return content; @@ -451,13 +498,13 @@ export class AIAnalysisOptimizer { const concurrencyMonitor = async (): Promise => { while (pendingRepos.length > 0 && !this.aborted) { - await this.delay(1000); - + await this.abortableDelay(1000); + if (this.shouldExitWorkers) { this.shouldExitWorkers = false; continue; } - + if (this.activeWorkers < this.currentConcurrency) { workerPromises.push(worker(totalWorkersStarted++)); } diff --git a/src/services/aiRequestLimiter.test.ts b/src/services/aiRequestLimiter.test.ts new file mode 100644 index 00000000..c72a7fdb --- /dev/null +++ b/src/services/aiRequestLimiter.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect } from 'vitest'; +import { AIRateLimiter } from './aiRequestLimiter'; + +describe('AIRateLimiter', () => { + describe('acquire/release 并发槽', () => { + it('允许多个并发请求,release 后归还信号', async () => { + const limiter = new AIRateLimiter({ maxConcurrency: 3 }); + const r1 = await limiter.acquire(); + const r2 = await limiter.acquire(); + expect(limiter.activeRequests).toBe(2); + r1(); + expect(limiter.activeRequests).toBe(1); + r2(); + expect(limiter.activeRequests).toBe(0); + }); + + it('超过 maxConcurrency 时阻塞直到信号量释放', async () => { + const limiter = new AIRateLimiter({ maxConcurrency: 1 }); + const release = await limiter.acquire(); + let acquired = false; + const pending = limiter.acquire().then(() => { acquired = true; }); + await new Promise(r => setTimeout(r, 50)); + expect(acquired).toBe(false); + release(); + await pending; + expect(acquired).toBe(true); + }); + }); + + describe('RPM 滑动窗口', () => { + it('超过每分钟限额时等待到下个窗口', async () => { + const limiter = new AIRateLimiter({ requestsPerMinute: 2, rpmWindowMs: 300 }); + const r1 = await limiter.acquire(); + const r2 = await limiter.acquire(); + r1(); r2(); + + const startedAt = Date.now(); + await limiter.acquire(); + const waited = Date.now() - startedAt; + expect(waited).toBeGreaterThanOrEqual(200); // 最早请求约 200ms 后出窗 + }); + }); + + describe('并发准入原子性(Promise.all)', () => { + it('maxConcurrency 下并发 acquire 不超发', async () => { + const limiter = new AIRateLimiter({ maxConcurrency: 2 }); + let peak = 0; + let completed = 0; + const tasks = Array.from({ length: 6 }, async () => { + const release = await limiter.acquire(); + peak = Math.max(peak, limiter.activeRequests); + // 立即归还槽位,让后续等待者继续;峰值由 active 记录 + release(); + completed++; + }); + + await Promise.all(tasks); + expect(completed).toBe(6); + // 任意时刻同时占用的槽位不得超过 maxConcurrency + expect(peak).toBeLessThanOrEqual(2); + expect(limiter.activeRequests).toBe(0); + }); + + it('requestsPerMinute 下并发 acquire 不超发', async () => { + const limiter = new AIRateLimiter({ requestsPerMinute: 2, rpmWindowMs: 500 }); + let acquired = 0; + const pending = Array.from({ length: 4 }, async () => { + const release = await limiter.acquire(); + acquired++; + release(); + }); + + // 前两个请求已占满 RPM 窗口,其余请求必须等待出窗,而非并发穿透 + await new Promise(r => setTimeout(r, 200)); + expect(acquired).toBe(2); + + await Promise.all(pending); + expect(acquired).toBe(4); + }); + }); + + describe('429 冷却与熔断', () => { + it('连续 429 达到阈值后打开熔断', () => { + const limiter = new AIRateLimiter({ cooldownThreshold: 3, backoffBaseMs: 1000 }); + limiter.notifyRateLimit(); + expect(limiter.getStatus().circuitOpen).toBe(false); + limiter.notifyRateLimit(); + expect(limiter.getStatus().circuitOpen).toBe(false); + limiter.notifyRateLimit(); + const status = limiter.getStatus(); + expect(status.circuitOpen).toBe(true); + expect(status.consecutiveRateLimits).toBe(3); + expect(status.cooldownRemainingMs).toBeGreaterThan(0); + }); + + it('notifySuccess 清零连续 429 计数', () => { + const limiter = new AIRateLimiter({ cooldownThreshold: 2 }); + limiter.notifyRateLimit(); + limiter.notifySuccess(); + expect(limiter.getStatus().consecutiveRateLimits).toBe(0); + }); + + it('尊重 Retry-After:服务端时长作为最短等待,抖动不缩短', () => { + const limiter = new AIRateLimiter({ maxRetryAfterMs: 60000 }); + limiter.notifyRateLimit(5000); + // 等待必须以完整 Retry-After(5000ms)为底,绝不低于它 + expect(limiter.getStatus().cooldownRemainingMs).toBeGreaterThanOrEqual(4900); + // 且受 backoffCap 约束 + limiter.notifyRateLimit(999999); + expect(limiter.getStatus().cooldownRemainingMs).toBeLessThanOrEqual(60000); + }); + + it('冷却期间 acquire 阻塞,冷却过后放行', async () => { + const limiter = new AIRateLimiter({ backoffBaseMs: 100, backoffCapMs: 1000, cooldownThreshold: 1 }); + limiter.notifyRateLimit(); + const before = Date.now(); + const release = await limiter.acquire(); + const waited = Date.now() - before; + release(); + // 退避 100ms * (0.75~1.25) = 75~125ms;放宽断言 + expect(waited).toBeGreaterThanOrEqual(50); + }); + }); + + describe('中止', () => { + it('acquire 响应已中止的 signal 抛 AbortError', async () => { + const limiter = new AIRateLimiter({ requestsPerMinute: 1, rpmWindowMs: 100000 }); + const release = await limiter.acquire(); + release(); + + const controller = new AbortController(); + controller.abort(); + await expect(limiter.acquire(controller.signal)).rejects.toThrow('Aborted'); + }); + }); + + describe('RPM 与冷却叠加', () => { + it('取二者中更长的等待', async () => { + // RPM 窗口 500ms、冷却 400ms -> 最终等待 >= 400ms + const limiter = new AIRateLimiter({ requestsPerMinute: 1, rpmWindowMs: 500, backoffBaseMs: 400, cooldownThreshold: 1 }); + limiter.notifyRateLimit(); + const start = Date.now(); + const release = await limiter.acquire(); + const waited = Date.now() - start; + release(); + expect(waited).toBeGreaterThanOrEqual(300); + }); + }); + + it('getStatus 暴露配置值', () => { + const limiter = new AIRateLimiter({ maxConcurrency: 5, requestsPerMinute: 60, cooldownThreshold: 4 }); + const s = limiter.getStatus(); + expect(s.maxConcurrency).toBe(5); + expect(s.requestsPerMinute).toBe(60); + expect(s.circuitOpen).toBe(false); + expect(s.cooldownRemainingMs).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/services/aiRequestLimiter.ts b/src/services/aiRequestLimiter.ts new file mode 100644 index 00000000..592a492d --- /dev/null +++ b/src/services/aiRequestLimiter.ts @@ -0,0 +1,201 @@ +import { logger } from './logger'; + +/** + * 共享 AI 请求限流器:为批量 AI 分析提供统一的 429/RPM 处理。 + * + * 设计参考 OpenAI / Anthropic SDK 与成熟 agent 的做法: + * - Retry-After 优先(合理值内听服务端的等待时长),否则用带抖动的指数退避 + * - 连续 429 触发全局熔断冷却:所有 worker 停手,冷却窗口过后恢复 + * - 并发上限(信号量)与每分钟请求数(RPM,60s 滑动窗口)作为主动准入控制, + * 值为 0 表示不限制(默认) + * - 单次 429 也会设置一个短冷却,避免其余 worker 继续冲击已被限流的桶 + */ +export interface AIRateLimitConfig { + /** 并发上限:同时最多几个请求在飞。0 = 不限制 */ + maxConcurrency?: number; + /** 每分钟请求数上限(60s 滑动窗口)。0 = 不限制 */ + requestsPerMinute?: number; + /** 触发全局熔断的连续 429 次数。默认 3 */ + cooldownThreshold?: number; + /** 指数退避基数(ms)。默认 1000 */ + backoffBaseMs?: number; + /** 退避上限(ms)。默认 60000 */ + backoffCapMs?: number; + /** 尊重 Retry-After 的上限(ms)。默认 60000 */ + maxRetryAfterMs?: number; + /** RPM 统计窗口(ms)。默认 60000;主要供测试缩小窗口 */ + rpmWindowMs?: number; +} + +const RPM_WINDOW_MS = 60_000; +const WAIT_POLL_MS = 100; + +const DEFAULT_CONFIG: Required = { + maxConcurrency: 0, + requestsPerMinute: 0, + cooldownThreshold: 3, + backoffBaseMs: 1000, + backoffCapMs: 60000, + maxRetryAfterMs: 60000, + rpmWindowMs: RPM_WINDOW_MS, +}; + +function abortError(): Error { + const e = new Error('Aborted') as Error & { name: string }; + e.name = 'AbortError'; + return e; +} + +export interface AIRateLimitStatus { + active: number; + consecutiveRateLimits: number; + cooldownRemainingMs: number; + circuitOpen: boolean; + maxConcurrency: number; + requestsPerMinute: number; +} + +export class AIRateLimiter { + private readonly config: AIRateLimitConfig; + private active = 0; + private requestTimestamps: number[] = []; + private consecutiveRateLimits = 0; + private cooldownUntil = 0; + + constructor(config: AIRateLimitConfig = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + /** 请求开始前调用:等待并占用一个并发槽(含冷却 / RPM / 并发上限等待)。 */ + async acquire(signal?: AbortSignal): Promise<() => void> { + for (;;) { + this.assertNotAborted(signal); + const now = Date.now(); + const wait = this.computeWaitMs(now); + const max = this.config.maxConcurrency ?? 0; + // 冷却 / RPM 窗口 / 并发槽全部满足时,同步完成准入:校验与占位之间 + // 不存在 await,单线程下不会与其它并发 acquire 交错,保证原子性。 + if (wait <= 0 && (max === 0 || this.active < max)) { + this.active++; + if ((this.config.requestsPerMinute ?? 0) > 0) { + this.requestTimestamps.push(now); + this.pruneTimestamps(); + } + return () => { + this.active = Math.max(0, this.active - 1); + }; + } + await this.sleep(wait > 0 ? wait : WAIT_POLL_MS, signal); + } + } + + /** 请求成功返回后调用:清零连续 429 计数。 */ + notifySuccess(): void { + this.consecutiveRateLimits = 0; + } + + /** + * 触发 429 时调用。返回本次应等待的毫秒数(已写入全局冷却)。 + * @param retryAfterMs 服务端返回的 Retry-After(可选,ms) + */ + notifyRateLimit(retryAfterMs?: number): number { + this.consecutiveRateLimits++; + const now = Date.now(); + + let retryWait = 0; + if (typeof retryAfterMs === 'number' && Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + retryWait = Math.min(retryAfterMs, this.config.maxRetryAfterMs ?? DEFAULT_CONFIG.maxRetryAfterMs); + } + + const attempt = this.consecutiveRateLimits - 1; + const backoff = Math.min( + this.config.backoffCapMs ?? DEFAULT_CONFIG.backoffCapMs, + (this.config.backoffBaseMs ?? DEFAULT_CONFIG.backoffBaseMs) * 2 ** Math.min(attempt, 6) + ); + + // 抖动只作用于本地指数退避;服务端 Retry-After 作为最短等待,绝不被缩短 + const jitteredBackoff = Math.round(backoff * (0.75 + Math.random() * 0.5)); + const waitMs = Math.max(retryWait, jitteredBackoff); + // 最终等待受退避上限约束,避免单次 Retry-After 造成过长停摆 + const cappedWait = Math.min(this.config.backoffCapMs ?? DEFAULT_CONFIG.backoffCapMs, waitMs); + this.cooldownUntil = Math.max(this.cooldownUntil, now + cappedWait); + + const threshold = this.config.cooldownThreshold ?? DEFAULT_CONFIG.cooldownThreshold; + const circuitOpen = this.consecutiveRateLimits >= threshold; + logger.warn('aiLimiter', 'AI rate limit notified', { + consecutiveRateLimits: this.consecutiveRateLimits, + waitMs, + circuitOpen, + cooldownMs: Math.max(0, this.cooldownUntil - now), + }); + return cappedWait; + } + + getStatus(): AIRateLimitStatus { + const now = Date.now(); + const threshold = this.config.cooldownThreshold ?? DEFAULT_CONFIG.cooldownThreshold; + return { + active: this.active, + requestsPerMinute: (this.config.requestsPerMinute ?? 0), + maxConcurrency: (this.config.maxConcurrency ?? 0), + consecutiveRateLimits: this.consecutiveRateLimits, + cooldownRemainingMs: Math.max(0, this.cooldownUntil - now), + circuitOpen: this.consecutiveRateLimits >= threshold, + }; + } + + /** 仅统计用:当前在飞请求数 */ + get activeRequests(): number { + return this.active; + } + + /** 需要等待的毫秒数:最大(冷却剩余,RPM 释放时刻)。 */ + private computeWaitMs(now: number): number { + let ms = 0; + if (this.cooldownUntil > now) { + ms = this.cooldownUntil - now; + } + const rpm = this.config.requestsPerMinute ?? 0; + if (rpm > 0) { + const windowMs = this.config.rpmWindowMs ?? RPM_WINDOW_MS; + // 注意:429 之类的失败请求同样计入每分钟请求数(与 OpenAI 文档一致), + // 因此按「请求开始时间」计数而不是按成功数。 + this.requestTimestamps = this.requestTimestamps.filter((ts) => ts > now - windowMs); + if (this.requestTimestamps.length >= rpm) { + const earliest = this.requestTimestamps[0]; + const releaseAt = earliest + windowMs; + ms = Math.max(ms, releaseAt - now); + } + } + return ms; + } + + private pruneTimestamps(): void { + const now = Date.now(); + const windowMs = this.config.rpmWindowMs ?? RPM_WINDOW_MS; + this.requestTimestamps = this.requestTimestamps.filter((ts) => ts > now - windowMs); + } + + private assertNotAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw abortError(); + } + } + + /** 可中止的轮询等待:最多等待 WAIT_POLL_MS,供准入循环按区块重判。 */ + private sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(abortError()); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, Math.min(ms, WAIT_POLL_MS)); + signal?.addEventListener('abort', onAbort); + if (signal?.aborted) onAbort(); + }); + } +} \ No newline at end of file diff --git a/src/services/aiService.test.ts b/src/services/aiService.test.ts index 34ee3c30..fefe86cd 100644 --- a/src/services/aiService.test.ts +++ b/src/services/aiService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Repository } from '../types'; -import { AIService } from './aiService'; +import { AIService, AIRequestError, isRateLimitedError, getRetryAfterMsFromError } from './aiService'; // Minimal AIConfig that lets AIService construct without a real token. const makeConfig = () => ({ @@ -128,3 +128,27 @@ describe('AIService.searchRepositoriesWithReranking — enhanced basic search fa expect(results.map((r) => r.id)).toEqual([6]); }); }); + +describe('AIRequestError / 限流辅助函数', () => { + it('构造错误并标记 isRateLimit', () => { + const err = new AIRequestError('rate limited', 429, 5000); + expect(err.status).toBe(429); + expect(err.retryAfterMs).toBe(5000); + expect(err.isRateLimit).toBe(true); + expect(err.name).toBe('AIRequestError'); + }); + + it('isRateLimitedError 识别 429 或限流信息', () => { + expect(isRateLimitedError(new AIRequestError('x', 429))).toBe(true); + expect(isRateLimitedError({ statusCode: 429 })).toBe(true); + expect(isRateLimitedError(new Error('Too Many Requests'))).toBe(true); + expect(isRateLimitedError(new Error('network down'))).toBe(false); + expect(isRateLimitedError(null)).toBe(false); + }); + + it('getRetryAfterMsFromError 取有效毫秒数', () => { + expect(getRetryAfterMsFromError(new AIRequestError('x', 429, 1234))).toBe(1234); + expect(getRetryAfterMsFromError({})).toBeUndefined(); + expect(getRetryAfterMsFromError(undefined)).toBeUndefined(); + }); +}); diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 513a1acd..d4e51794 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -46,6 +46,64 @@ type ParsedAIResponse = RepositoryAnalysisResult & { invalidReason?: string; }; +/** + * 统一的 AI 请求错误,携带 HTTP 状态码与(可选)服务端建议的等待时长。 + * 上层(限流器 / 优化器)依赖 status / retryAfterMs 判断退避策略。 + */ +export class AIRequestError extends Error { + readonly status: number; + readonly retryAfterMs?: number; + readonly isRateLimit: boolean; + + constructor(message: string, status: number, retryAfterMs?: number) { + super(message); + this.name = 'AIRequestError'; + this.status = status; + this.retryAfterMs = retryAfterMs; + this.isRateLimit = status === 429; + } +} + +/** 支持检查一个错误对象是否是 AI 限流(429 或代理透传的限流错误)。 */ +export function isRateLimitedError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const e = error as { statusCode?: unknown; status?: unknown; isRateLimit?: unknown; message?: unknown }; + if (e.isRateLimit === true) return true; + if (typeof e.statusCode === 'number' && e.statusCode === 429) return true; + if (typeof e.status === 'number' && e.status === 429) return true; + const msg = typeof e.message === 'string' ? e.message : ''; + return /429|rate\s*limit|too many requests/i.test(msg); +} + +/** 从限流错误中读取服务端建议的等待毫秒数(Retry-After / retry-after-ms)。 */ +export function getRetryAfterMsFromError(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined; + const e = error as { retryAfterMs?: unknown; retryAfter?: unknown }; + const ms = typeof e.retryAfterMs === 'number' ? e.retryAfterMs : undefined; + if (ms !== undefined && Number.isFinite(ms) && ms > 0) return ms; + return undefined; +} + +/** 解析响应头里的 Retry-After(retry-after-ms → retry-after 秒 → HTTP date)。 */ +function parseRetryAfterMs(response: Response): number | undefined { + const msHeader = response.headers.get('retry-after-ms'); + if (msHeader) { + const v = Number(msHeader); + if (Number.isFinite(v) && v > 0) return Math.round(v); + } + const secHeader = response.headers.get('retry-after'); + if (secHeader) { + const v = Number(secHeader); + if (Number.isFinite(v) && v > 0) return Math.round(v * 1000); + const parsed = Date.parse(secHeader); + if (!Number.isNaN(parsed)) { + const remaining = parsed - Date.now(); + if (remaining > 0) return remaining; + } + } + return undefined; +} + function getStatusCodeMeaning(statusCode: number, language: string): string { const meanings: Record = { 400: { zh: '请求参数错误', en: 'Bad Request' }, @@ -255,7 +313,11 @@ export class AIService { this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }, { url: requestUrl, requestHeaders, requestBody, responseHeaders, responseBody: responseBodyPreview, status: responseStatus, }); - throw new Error(`AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); + throw new AIRequestError( + `AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`, + response.status, + parseRetryAfterMs(response) + ); } data = await response.json(); } @@ -365,7 +427,11 @@ export class AIService { this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }, { url: requestUrl, requestHeaders, requestBody, responseHeaders, responseBody: responseBodyPreview, status: responseStatus, }); - throw new Error(`AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); + throw new AIRequestError( + `AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`, + response.status, + parseRetryAfterMs(response) + ); } data = await response.json(); } @@ -457,7 +523,11 @@ ${options.user}` : options.user; this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }, { url: maskedUrl, requestHeaders, requestBody, responseHeaders, responseBody: responseBodyPreview, status: responseStatus, }); - throw new Error(`AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); + throw new AIRequestError( + `AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`, + response.status, + parseRetryAfterMs(response) + ); } data = await response.json(); } diff --git a/src/services/backendAdapter.test.ts b/src/services/backendAdapter.test.ts new file mode 100644 index 00000000..7e1bc54a --- /dev/null +++ b/src/services/backendAdapter.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { backend } from './backendAdapter'; + +vi.mock('../store/useAppStore', () => ({ + useAppStore: { getState: () => ({ backendApiSecret: '' }) }, +})); + +function make429Response(headers: Record): Response { + return { + ok: false, + status: 429, + statusText: 'Too Many Requests', + json: async () => ({ message: 'rate limited' }), + headers: { + get: (name: string) => headers[name] ?? headers[name.toLowerCase()] ?? null, + }, + } as unknown as Response; +} + +type BackendAdapterLike = { _backendUrl: string | null }; + +describe('backendAdapter 429 Retry-After 解析', () => { + const adapter = backend as unknown as BackendAdapterLike; + + afterEach(() => { + vi.mocked(window.fetch).mockReset(); + adapter._backendUrl = null; + }); + + it('解析 retry-after-ms(毫秒,优先于 retry-after)', async () => { + adapter._backendUrl = 'http://localhost:3000/api'; + vi.mocked(window.fetch).mockResolvedValue(make429Response({ 'retry-after-ms': '60000', 'retry-after': '5' })); + + await expect(backend.checkRateLimit()).rejects.toMatchObject({ + statusCode: 429, + retryAfterMs: 60000, + }); + }); + + it('解析数值型 retry-after(秒 → 毫秒)', async () => { + adapter._backendUrl = 'http://localhost:3000/api'; + vi.mocked(window.fetch).mockResolvedValue(make429Response({ 'retry-after': '120' })); + + await expect(backend.checkRateLimit()).rejects.toMatchObject({ + statusCode: 429, + retryAfterMs: 120000, + }); + }); + + it('解析 HTTP-date 型 retry-after', async () => { + adapter._backendUrl = 'http://localhost:3000/api'; + const future = new Date(Date.now() + 2 * 60 * 60 * 1000); + vi.mocked(window.fetch).mockResolvedValue(make429Response({ 'retry-after': future.toUTCString() })); + + const err = (await backend.checkRateLimit().catch((e: Error) => e)) as Error & { statusCode?: number; retryAfterMs?: number }; + expect(err.statusCode).toBe(429); + expect(typeof err.retryAfterMs).toBe('number'); + expect(err.retryAfterMs!).toBeGreaterThan(0); + expect(err.retryAfterMs!).toBeLessThanOrEqual(2 * 60 * 60 * 1000); + }); + + it('无法解析的 retry-after 不设置 retryAfterMs', async () => { + adapter._backendUrl = 'http://localhost:3000/api'; + vi.mocked(window.fetch).mockResolvedValue(make429Response({ 'retry-after': 'bogus-value' })); + + const err = (await backend.checkRateLimit().catch((e: Error) => e)) as Error & { statusCode?: number; retryAfterMs?: number }; + expect(err.statusCode).toBe(429); + expect(err.retryAfterMs).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 14c9e8da..4b99368c 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -229,9 +229,32 @@ class BackendAdapter { } } catch { /* body not JSON */ } const translated = translateBackendError(code, `${fallbackPrefix}: ${res.status}`); - const error = new Error(detail ? `${translated} - ${detail}` : translated) as Error & { statusCode?: number; code?: string }; + const error = new Error(detail ? `${translated} - ${detail}` : translated) as Error & { statusCode?: number; code?: string; retryAfterMs?: number }; error.statusCode = res.status; if (code) error.code = code; + // 后端透传上游 Retry-After 头后,这里解析成毫秒供限流器使用(retry-after-ms 为毫秒,retry-after 为秒) + if (res.status === 429) { + const retryAfterMsHeader = res.headers.get('retry-after-ms'); + if (retryAfterMsHeader) { + const v = Number(retryAfterMsHeader); + if (Number.isFinite(v) && v > 0) error.retryAfterMs = Math.round(v); + } else { + const retryAfter = res.headers.get('retry-after'); + if (retryAfter) { + // Retry-After 可能是「秒数」或「HTTP-date」;数值解析失败时按日期计算剩余时长 + const numeric = Number(retryAfter); + if (Number.isFinite(numeric) && numeric > 0) { + error.retryAfterMs = Math.round(numeric * 1000); + } else { + const parsedDate = Date.parse(retryAfter); + if (!Number.isNaN(parsedDate)) { + const remaining = parsedDate - Date.now(); + if (remaining > 0) error.retryAfterMs = Math.round(remaining); + } + } + } + } + } throw error; } diff --git a/src/types/index.ts b/src/types/index.ts index 963a8789..374c41ec 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -300,6 +300,7 @@ export interface AIConfig { customPrompt?: string; // 自定义提示词 useCustomPrompt?: boolean; // 是否使用自定义提示词 concurrency?: number; // AI分析并发数,默认为1 + requestsPerMinute?: number; // 每分钟 AI 请求数上限(供批量分析的共享限流器使用),0/缺省=不限制 reasoningEffort?: AIReasoningEffort; // OpenAI GPT-5/Responses 可选 reasoning 强度 mimoPlan?: MiMoPlan; // MiMo 渠道:api(按量付费)或 token-plan(订阅制) apiKeyStatus?: SecretStatus;