From 69e7cbaceabbe0e9b1780a93358154294088518c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 17 May 2026 06:16:24 +0000 Subject: [PATCH] Fix Zep retries and frontend API proxy --- backend/app/services/zep_tools.py | 79 +++++++++++++++- backend/app/utils/zep_paging.py | 82 ++++++++++++++-- frontend/src/api/graph.js | 15 ++- frontend/src/api/index.js | 12 ++- frontend/src/api/report.js | 12 +-- frontend/src/api/simulation.js | 43 +++++---- frontend/src/components/Step4Report.vue | 120 ++++++++++++++++++++++++ frontend/vite.config.js | 1 - 8 files changed, 310 insertions(+), 54 deletions(-) diff --git a/backend/app/services/zep_tools.py b/backend/app/services/zep_tools.py index 3bc8a57a..251327d0 100644 --- a/backend/app/services/zep_tools.py +++ b/backend/app/services/zep_tools.py @@ -10,10 +10,12 @@ Zep检索工具服务 import time import json +import re from typing import Dict, Any, List, Optional from dataclasses import dataclass, field from zep_cloud.client import Zep +from zep_cloud.core.api_error import ApiError from ..config import Config from ..utils.logger import get_logger @@ -23,6 +25,55 @@ from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges logger = get_logger('mirofish.zep_tools') +_MAX_RATE_LIMIT_DELAY = 60.0 + + +def _is_rate_limit_error(error: Exception) -> bool: + status_code = getattr(error, "status_code", None) + if status_code == 429: + return True + + body = getattr(error, "body", None) + if isinstance(body, str) and "rate limit" in body.lower(): + return True + + text = str(error).lower() + return "status_code: 429" in text or "rate limit" in text or "too many requests" in text + + +def _parse_retry_after(error: Exception) -> float | None: + headers = getattr(error, "headers", None) + if isinstance(headers, dict): + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after: + try: + return max(float(retry_after), 0.0) + except (TypeError, ValueError): + pass + + reset = headers.get("x-ratelimit-reset") or headers.get("X-RateLimit-Reset") + if reset: + try: + reset_seconds = float(reset) + if reset_seconds > 1_000_000_000: + wait_seconds = reset_seconds - time.time() + else: + wait_seconds = reset_seconds + if wait_seconds > 0: + return wait_seconds + except (TypeError, ValueError): + pass + + text = str(error) + match = re.search(r"retry-after['\"]?\s*:\s*['\"]?(\d+(?:\.\d+)?)", text, re.IGNORECASE) + if match: + try: + return max(float(match.group(1)), 0.0) + except (TypeError, ValueError): + pass + + return None + @dataclass class SearchResult: @@ -448,14 +499,32 @@ class ZepToolsService: for attempt in range(max_retries): try: return func() - except Exception as e: + except (Exception, ApiError) as e: last_exception = e if attempt < max_retries - 1: - logger.warning( - t("console.zepRetryAttempt", operation=operation_name, attempt=attempt + 1, error=str(e)[:100], delay=f"{delay:.1f}") - ) + if _is_rate_limit_error(e): + retry_after = _parse_retry_after(e) + if retry_after is not None: + delay = min(max(retry_after, self.RETRY_DELAY), _MAX_RATE_LIMIT_DELAY) + else: + delay = min(max(delay, self.RETRY_DELAY), _MAX_RATE_LIMIT_DELAY) + else: + delay = min(delay, _MAX_RATE_LIMIT_DELAY) + + if _is_rate_limit_error(e): + logger.warning( + f"Zep {operation_name} rate limit hit (attempt {attempt + 1}/{max_retries}); " + f"retrying in {delay:.1f}s..." + ) + else: + logger.warning( + t("console.zepRetryAttempt", operation=operation_name, attempt=attempt + 1, error=str(e)[:100], delay=f"{delay:.1f}") + ) time.sleep(delay) - delay *= 2 + if _is_rate_limit_error(e): + delay = min(delay * 1.25, _MAX_RATE_LIMIT_DELAY) + else: + delay = min(delay * 2, _MAX_RATE_LIMIT_DELAY) else: logger.error(t("console.zepAllRetriesFailed", operation=operation_name, retries=max_retries, error=str(e))) diff --git a/backend/app/utils/zep_paging.py b/backend/app/utils/zep_paging.py index 943cd1ae..c18b1463 100644 --- a/backend/app/utils/zep_paging.py +++ b/backend/app/utils/zep_paging.py @@ -7,10 +7,12 @@ Zep 的 node/edge 列表接口使用 UUID cursor 分页, from __future__ import annotations import time +import re from collections.abc import Callable from typing import Any from zep_cloud import InternalServerError +from zep_cloud.core.api_error import ApiError from zep_cloud.client import Zep from .logger import get_logger @@ -19,8 +21,57 @@ logger = get_logger('mirofish.zep_paging') _DEFAULT_PAGE_SIZE = 100 _MAX_NODES = 2000 -_DEFAULT_MAX_RETRIES = 3 +_DEFAULT_MAX_RETRIES = 5 _DEFAULT_RETRY_DELAY = 2.0 # seconds, doubles each retry +_MAX_RETRY_DELAY = 60.0 + + +def _is_rate_limit_error(error: Exception) -> bool: + status_code = getattr(error, "status_code", None) + if status_code == 429: + return True + + body = getattr(error, "body", None) + if isinstance(body, str) and "rate limit" in body.lower(): + return True + + text = str(error).lower() + return "status_code: 429" in text or "rate limit" in text or "too many requests" in text + + +def _parse_retry_after(error: Exception) -> float | None: + headers = getattr(error, "headers", None) + if isinstance(headers, dict): + retry_after = headers.get("retry-after") or headers.get("Retry-After") + if retry_after: + try: + return max(float(retry_after), 0.0) + except (TypeError, ValueError): + pass + + reset = headers.get("x-ratelimit-reset") or headers.get("X-RateLimit-Reset") + if reset: + try: + reset_seconds = float(reset) + if reset_seconds > 1_000_000_000: + # Some providers return a unix timestamp instead of a delta. + wait_seconds = reset_seconds - time.time() + else: + wait_seconds = reset_seconds + if wait_seconds > 0: + return wait_seconds + except (TypeError, ValueError): + pass + + text = str(error) + match = re.search(r"retry-after['\"]?\s*:\s*['\"]?(\d+(?:\.\d+)?)", text, re.IGNORECASE) + if match: + try: + return max(float(match.group(1)), 0.0) + except (TypeError, ValueError): + pass + + return None def _fetch_page_with_retry( @@ -41,14 +92,33 @@ def _fetch_page_with_retry( for attempt in range(max_retries): try: return api_call(*args, **kwargs) - except (ConnectionError, TimeoutError, OSError, InternalServerError) as e: + except (ConnectionError, TimeoutError, OSError, InternalServerError, ApiError) as e: last_exception = e if attempt < max_retries - 1: - logger.warning( - f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..." - ) + if _is_rate_limit_error(e): + retry_after = _parse_retry_after(e) + if retry_after is not None: + delay = min(max(retry_after, retry_delay), _MAX_RETRY_DELAY) + else: + delay = min(max(delay, retry_delay), _MAX_RETRY_DELAY) + else: + delay = min(delay, _MAX_RETRY_DELAY) + + if _is_rate_limit_error(e): + logger.warning( + f"Zep {page_description} rate limit hit (attempt {attempt + 1}/{max_retries}); " + f"retrying in {delay:.1f}s..." + ) + else: + logger.warning( + f"Zep {page_description} attempt {attempt + 1} failed: {str(e)[:100]}, retrying in {delay:.1f}s..." + ) time.sleep(delay) - delay *= 2 + if _is_rate_limit_error(e): + # Respect server-advised retry delays; keep the same delay or back off slightly. + delay = min(delay * 1.25, _MAX_RETRY_DELAY) + else: + delay = min(delay * 2, _MAX_RETRY_DELAY) else: logger.error(f"Zep {page_description} failed after {max_retries} attempts: {str(e)}") diff --git a/frontend/src/api/graph.js b/frontend/src/api/graph.js index ef90a2b6..2ea57c9f 100644 --- a/frontend/src/api/graph.js +++ b/frontend/src/api/graph.js @@ -8,12 +8,9 @@ import service, { requestWithRetry } from './index' export function generateOntology(formData) { return requestWithRetry(() => service({ - url: '/api/graph/ontology/generate', + url: '/graph/ontology/generate', method: 'post', - data: formData, - headers: { - 'Content-Type': 'multipart/form-data' - } + data: formData }) ) } @@ -26,7 +23,7 @@ export function generateOntology(formData) { export function buildGraph(data) { return requestWithRetry(() => service({ - url: '/api/graph/build', + url: '/graph/build', method: 'post', data }) @@ -40,7 +37,7 @@ export function buildGraph(data) { */ export function getTaskStatus(taskId) { return service({ - url: `/api/graph/task/${taskId}`, + url: `/graph/task/${taskId}`, method: 'get' }) } @@ -52,7 +49,7 @@ export function getTaskStatus(taskId) { */ export function getGraphData(graphId) { return service({ - url: `/api/graph/data/${graphId}`, + url: `/graph/data/${graphId}`, method: 'get' }) } @@ -64,7 +61,7 @@ export function getGraphData(graphId) { */ export function getProject(projectId) { return service({ - url: `/api/graph/project/${projectId}`, + url: `/graph/project/${projectId}`, method: 'get' }) } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index e840e116..5439a36e 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -3,17 +3,19 @@ import i18n from '../i18n' // 创建axios实例 const service = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5001', - timeout: 300000, // 5分钟超时(本体生成可能需要较长时间) - headers: { - 'Content-Type': 'application/json' - } + // Default to the Vite proxy so the frontend works both on localhost + // and when accessed from another machine via the dev server host. + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', + timeout: 300000 // 5分钟超时(本体生成可能需要较长时间) }) // 请求拦截器 service.interceptors.request.use( config => { config.headers['Accept-Language'] = i18n.global.locale.value + if (config.data instanceof FormData) { + delete config.headers['Content-Type'] + } return config }, error => { diff --git a/frontend/src/api/report.js b/frontend/src/api/report.js index c89a67d8..db80c887 100644 --- a/frontend/src/api/report.js +++ b/frontend/src/api/report.js @@ -5,7 +5,7 @@ import service, { requestWithRetry } from './index' * @param {Object} data - { simulation_id, force_regenerate? } */ export const generateReport = (data) => { - return requestWithRetry(() => service.post('/api/report/generate', data), 3, 1000) + return requestWithRetry(() => service.post('/report/generate', data), 3, 1000) } /** @@ -13,7 +13,7 @@ export const generateReport = (data) => { * @param {string} reportId */ export const getReportStatus = (reportId) => { - return service.get(`/api/report/generate/status`, { params: { report_id: reportId } }) + return service.get(`/report/generate/status`, { params: { report_id: reportId } }) } /** @@ -22,7 +22,7 @@ export const getReportStatus = (reportId) => { * @param {number} fromLine - 从第几行开始获取 */ export const getAgentLog = (reportId, fromLine = 0) => { - return service.get(`/api/report/${reportId}/agent-log`, { params: { from_line: fromLine } }) + return service.get(`/report/${reportId}/agent-log`, { params: { from_line: fromLine } }) } /** @@ -31,7 +31,7 @@ export const getAgentLog = (reportId, fromLine = 0) => { * @param {number} fromLine - 从第几行开始获取 */ export const getConsoleLog = (reportId, fromLine = 0) => { - return service.get(`/api/report/${reportId}/console-log`, { params: { from_line: fromLine } }) + return service.get(`/report/${reportId}/console-log`, { params: { from_line: fromLine } }) } /** @@ -39,7 +39,7 @@ export const getConsoleLog = (reportId, fromLine = 0) => { * @param {string} reportId */ export const getReport = (reportId) => { - return service.get(`/api/report/${reportId}`) + return service.get(`/report/${reportId}`) } /** @@ -47,5 +47,5 @@ export const getReport = (reportId) => { * @param {Object} data - { simulation_id, message, chat_history? } */ export const chatWithReport = (data) => { - return requestWithRetry(() => service.post('/api/report/chat', data), 3, 1000) + return requestWithRetry(() => service.post('/report/chat', data), 3, 1000) } diff --git a/frontend/src/api/simulation.js b/frontend/src/api/simulation.js index f878586f..5d1f4223 100644 --- a/frontend/src/api/simulation.js +++ b/frontend/src/api/simulation.js @@ -5,7 +5,7 @@ import service, { requestWithRetry } from './index' * @param {Object} data - { project_id, graph_id?, enable_twitter?, enable_reddit? } */ export const createSimulation = (data) => { - return requestWithRetry(() => service.post('/api/simulation/create', data), 3, 1000) + return requestWithRetry(() => service.post('/simulation/create', data), 3, 1000) } /** @@ -13,7 +13,7 @@ export const createSimulation = (data) => { * @param {Object} data - { simulation_id, entity_types?, use_llm_for_profiles?, parallel_profile_count?, force_regenerate? } */ export const prepareSimulation = (data) => { - return requestWithRetry(() => service.post('/api/simulation/prepare', data), 3, 1000) + return requestWithRetry(() => service.post('/simulation/prepare', data), 3, 1000) } /** @@ -21,7 +21,7 @@ export const prepareSimulation = (data) => { * @param {Object} data - { task_id?, simulation_id? } */ export const getPrepareStatus = (data) => { - return service.post('/api/simulation/prepare/status', data) + return service.post('/simulation/prepare/status', data) } /** @@ -29,7 +29,7 @@ export const getPrepareStatus = (data) => { * @param {string} simulationId */ export const getSimulation = (simulationId) => { - return service.get(`/api/simulation/${simulationId}`) + return service.get(`/simulation/${simulationId}`) } /** @@ -38,7 +38,7 @@ export const getSimulation = (simulationId) => { * @param {string} platform - 'reddit' | 'twitter' */ export const getSimulationProfiles = (simulationId, platform = 'reddit') => { - return service.get(`/api/simulation/${simulationId}/profiles`, { params: { platform } }) + return service.get(`/simulation/${simulationId}/profiles`, { params: { platform } }) } /** @@ -47,7 +47,7 @@ export const getSimulationProfiles = (simulationId, platform = 'reddit') => { * @param {string} platform - 'reddit' | 'twitter' */ export const getSimulationProfilesRealtime = (simulationId, platform = 'reddit') => { - return service.get(`/api/simulation/${simulationId}/profiles/realtime`, { params: { platform } }) + return service.get(`/simulation/${simulationId}/profiles/realtime`, { params: { platform } }) } /** @@ -55,7 +55,7 @@ export const getSimulationProfilesRealtime = (simulationId, platform = 'reddit') * @param {string} simulationId */ export const getSimulationConfig = (simulationId) => { - return service.get(`/api/simulation/${simulationId}/config`) + return service.get(`/simulation/${simulationId}/config`) } /** @@ -64,7 +64,7 @@ export const getSimulationConfig = (simulationId) => { * @returns {Promise} 返回配置信息,包含元数据和配置内容 */ export const getSimulationConfigRealtime = (simulationId) => { - return service.get(`/api/simulation/${simulationId}/config/realtime`) + return service.get(`/simulation/${simulationId}/config/realtime`) } /** @@ -73,7 +73,7 @@ export const getSimulationConfigRealtime = (simulationId) => { */ export const listSimulations = (projectId) => { const params = projectId ? { project_id: projectId } : {} - return service.get('/api/simulation/list', { params }) + return service.get('/simulation/list', { params }) } /** @@ -81,7 +81,7 @@ export const listSimulations = (projectId) => { * @param {Object} data - { simulation_id, platform?, max_rounds?, enable_graph_memory_update? } */ export const startSimulation = (data) => { - return requestWithRetry(() => service.post('/api/simulation/start', data), 3, 1000) + return requestWithRetry(() => service.post('/simulation/start', data), 3, 1000) } /** @@ -89,7 +89,7 @@ export const startSimulation = (data) => { * @param {Object} data - { simulation_id } */ export const stopSimulation = (data) => { - return service.post('/api/simulation/stop', data) + return service.post('/simulation/stop', data) } /** @@ -97,7 +97,7 @@ export const stopSimulation = (data) => { * @param {string} simulationId */ export const getRunStatus = (simulationId) => { - return service.get(`/api/simulation/${simulationId}/run-status`) + return service.get(`/simulation/${simulationId}/run-status`) } /** @@ -105,7 +105,7 @@ export const getRunStatus = (simulationId) => { * @param {string} simulationId */ export const getRunStatusDetail = (simulationId) => { - return service.get(`/api/simulation/${simulationId}/run-status/detail`) + return service.get(`/simulation/${simulationId}/run-status/detail`) } /** @@ -116,7 +116,7 @@ export const getRunStatusDetail = (simulationId) => { * @param {number} offset - 偏移量 */ export const getSimulationPosts = (simulationId, platform = 'reddit', limit = 50, offset = 0) => { - return service.get(`/api/simulation/${simulationId}/posts`, { + return service.get(`/simulation/${simulationId}/posts`, { params: { platform, limit, offset } }) } @@ -132,7 +132,7 @@ export const getSimulationTimeline = (simulationId, startRound = 0, endRound = n if (endRound !== null) { params.end_round = endRound } - return service.get(`/api/simulation/${simulationId}/timeline`, { params }) + return service.get(`/simulation/${simulationId}/timeline`, { params }) } /** @@ -140,7 +140,7 @@ export const getSimulationTimeline = (simulationId, startRound = 0, endRound = n * @param {string} simulationId */ export const getAgentStats = (simulationId) => { - return service.get(`/api/simulation/${simulationId}/agent-stats`) + return service.get(`/simulation/${simulationId}/agent-stats`) } /** @@ -149,7 +149,7 @@ export const getAgentStats = (simulationId) => { * @param {Object} params - { limit, offset, platform, agent_id, round_num } */ export const getSimulationActions = (simulationId, params = {}) => { - return service.get(`/api/simulation/${simulationId}/actions`, { params }) + return service.get(`/simulation/${simulationId}/actions`, { params }) } /** @@ -157,7 +157,7 @@ export const getSimulationActions = (simulationId, params = {}) => { * @param {Object} data - { simulation_id, timeout? } */ export const closeSimulationEnv = (data) => { - return service.post('/api/simulation/close-env', data) + return service.post('/simulation/close-env', data) } /** @@ -165,7 +165,7 @@ export const closeSimulationEnv = (data) => { * @param {Object} data - { simulation_id } */ export const getEnvStatus = (data) => { - return service.post('/api/simulation/env-status', data) + return service.post('/simulation/env-status', data) } /** @@ -173,7 +173,7 @@ export const getEnvStatus = (data) => { * @param {Object} data - { simulation_id, interviews: [{ agent_id, prompt }] } */ export const interviewAgents = (data) => { - return requestWithRetry(() => service.post('/api/simulation/interview/batch', data), 3, 1000) + return requestWithRetry(() => service.post('/simulation/interview/batch', data), 3, 1000) } /** @@ -182,6 +182,5 @@ export const interviewAgents = (data) => { * @param {number} limit - 返回数量限制 */ export const getSimulationHistory = (limit = 20) => { - return service.get('/api/simulation/history', { params: { limit } }) + return service.get('/simulation/history', { params: { limit } }) } - diff --git a/frontend/src/components/Step4Report.vue b/frontend/src/components/Step4Report.vue index 8e53ceb5..e98a4f6d 100644 --- a/frontend/src/components/Step4Report.vue +++ b/frontend/src/components/Step4Report.vue @@ -105,6 +105,15 @@ +
+
+
+
External API rate limit detected
+
{{ rateLimitNotice.message }}
+
+ +
+
{ return '' } +const clearRetryNoticeTimer = () => { + if (rateLimitNoticeTimer) { + clearTimeout(rateLimitNoticeTimer) + rateLimitNoticeTimer = null + } +} + +const dismissRetryNotice = () => { + rateLimitNotice.value = null + clearRetryNoticeTimer() +} + +const setRetryNotice = (message, retrySeconds = null) => { + rateLimitNotice.value = { + message, + retrySeconds + } + + clearRetryNoticeTimer() + if (retrySeconds && retrySeconds > 0) { + rateLimitNoticeTimer = setTimeout(() => { + rateLimitNotice.value = null + rateLimitNoticeTimer = null + }, Math.min(retrySeconds * 1000 + 1000, 120000)) + } +} + +const maybeHandleRetryNotice = (logLine) => { + if (!logLine) return + + const text = String(logLine) + const rateLimitMatch = text.match(/rate limit hit.*retrying in ([\d.]+)s/i) + if (rateLimitMatch) { + const retrySeconds = Number.parseFloat(rateLimitMatch[1]) + const retryText = Number.isFinite(retrySeconds) + ? `Zep is rate limited. The system will retry automatically in about ${retrySeconds.toFixed(1)} seconds.` + : 'Zep is rate limited. The system will retry automatically.' + setRetryNotice(retryText, Number.isFinite(retrySeconds) ? retrySeconds : null) + return + } + + if (text.includes('Rate limit exceeded for FREE plan') || text.includes('429')) { + setRetryNotice('Zep is rate limited. The system will retry automatically.', null) + } +} + // Polling let agentLogTimer = null let consoleLogTimer = null @@ -2055,6 +2112,7 @@ const fetchAgentLog = async () => { if (log.action === 'report_complete') { isComplete.value = true currentSectionIndex.value = null // 确保清除 loading 状态 + dismissRetryNotice() emit('update-status', 'completed') stopPolling() // 滚动逻辑统一在循环结束后的 nextTick 中处理 @@ -2141,6 +2199,8 @@ const fetchConsoleLog = async () => { if (newLogs.length > 0) { consoleLogs.value.push(...newLogs) consoleLogLine.value = res.data.from_line + newLogs.length + + newLogs.forEach((log) => maybeHandleRetryNotice(log)) nextTick(() => { if (logContent.value) { @@ -2185,6 +2245,7 @@ onMounted(() => { onUnmounted(() => { stopPolling() + clearRetryNoticeTimer() }) watch(() => props.reportId, (newId) => { @@ -2193,6 +2254,7 @@ watch(() => props.reportId, (newId) => { consoleLogs.value = [] agentLogLine.value = 0 consoleLogLine.value = 0 + dismissRetryNotice() reportOutline.value = null currentSectionIndex.value = null generatedSections.value = {} @@ -2289,6 +2351,64 @@ watch(() => props.reportId, (newId) => { flex-shrink: 0; } +.retry-notice { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 12px 20px 0; + padding: 12px 14px; + border: 1px solid #F59E0B; + border-radius: 12px; + background: rgba(245, 158, 11, 0.08); + color: #92400E; +} + +.retry-notice__icon { + width: 28px; + height: 28px; + border-radius: 999px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(245, 158, 11, 0.18); + flex-shrink: 0; + font-size: 16px; +} + +.retry-notice__body { + flex: 1; + min-width: 0; +} + +.retry-notice__title { + font-size: 13px; + font-weight: 700; + line-height: 1.2; +} + +.retry-notice__text { + margin-top: 4px; + font-size: 12px; + line-height: 1.5; + color: #B45309; +} + +.retry-notice__dismiss { + flex-shrink: 0; + border: 0; + border-radius: 999px; + padding: 6px 10px; + background: rgba(245, 158, 11, 0.16); + color: #92400E; + cursor: pointer; + font-size: 12px; + font-weight: 600; +} + +.retry-notice__dismiss:hover { + background: rgba(245, 158, 11, 0.24); +} + /* Panel header status variants */ .panel-header--active { background: #FAFAFA; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8f1e4c11..761c945f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -13,7 +13,6 @@ export default defineConfig({ }, server: { port: 3000, - open: true, proxy: { '/api': { target: 'http://localhost:5001',