From 032a2ca0326049f10fda6acd49c5a81639732a99 Mon Sep 17 00:00:00 2001 From: silu Date: Wed, 6 May 2026 22:21:33 +0800 Subject: [PATCH 1/2] Improve tailor preview timeout handling --- apps/backend/app/routers/resumes.py | 4 +- apps/frontend/app/(default)/tailor/page.tsx | 52 ++++++++++++++------- apps/frontend/lib/api/client.ts | 12 +++-- apps/frontend/lib/api/resume.ts | 2 +- apps/frontend/messages/en.json | 1 + apps/frontend/messages/es.json | 1 + apps/frontend/messages/ja.json | 1 + apps/frontend/messages/pt-BR.json | 1 + apps/frontend/messages/zh.json | 1 + apps/frontend/next.config.ts | 2 +- 10 files changed, 53 insertions(+), 24 deletions(-) diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index f06b794b1..c42e2e7ea 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -693,11 +693,11 @@ async def improve_resume_preview_endpoint( language=language, prompt_id=prompt_id, ), - timeout=240.0, # 4-minute hard limit + timeout=600.0, # 10-minute hard limit ) except asyncio.TimeoutError: logger.error( - "Improve preview timed out after 240s for resume %s / job %s", + "Improve preview timed out after 600s for resume %s / job %s", request.resume_id, request.job_id, ) diff --git a/apps/frontend/app/(default)/tailor/page.tsx b/apps/frontend/app/(default)/tailor/page.tsx index ddee62341..7c9be3260 100644 --- a/apps/frontend/app/(default)/tailor/page.tsx +++ b/apps/frontend/app/(default)/tailor/page.tsx @@ -119,6 +119,40 @@ export default function TailorPage() { if (e.key === 'Enter') e.stopPropagation(); }; + const getPreviewErrorMessage = (err: unknown) => { + const errorMessage = err instanceof Error ? err.message : String(err ?? ''); + const normalized = errorMessage.toLowerCase(); + + if ( + normalized.includes('api key') || + normalized.includes('unauthorized') || + normalized.includes('authentication') || + errorMessage.includes('401') + ) { + return t('tailor.errors.apiKeyError'); + } + + if ( + normalized.includes('rate limit') || + normalized.includes('insufficient_quota') || + errorMessage.includes('429') + ) { + return t('tailor.errors.rateLimit'); + } + + if ( + normalized.includes('timed out') || + normalized.includes('timeout') || + normalized.includes('signal is aborted') || + normalized.includes('aborterror') || + errorMessage.includes('504') + ) { + return t('tailor.errors.timeout'); + } + + return t('tailor.errors.failedToPreview'); + }; + const buildConfirmPayload = (result: ImprovedResult) => { if (!masterResumeId) { throw new Error('Master resume ID is missing.'); @@ -197,23 +231,7 @@ export default function TailorPage() { setShowDiffModal(true); } catch (err) { console.error(err); - // Check for common error patterns - const errorMessage = err instanceof Error ? err.message : ''; - if ( - errorMessage.toLowerCase().includes('api key') || - errorMessage.toLowerCase().includes('unauthorized') || - errorMessage.toLowerCase().includes('authentication') || - errorMessage.includes('401') - ) { - setError(t('tailor.errors.apiKeyError')); - } else if ( - errorMessage.toLowerCase().includes('rate limit') || - errorMessage.includes('429') - ) { - setError(t('tailor.errors.rateLimit')); - } else { - setError(t('tailor.errors.failedToPreview')); - } + setError(getPreviewErrorMessage(err)); } }; diff --git a/apps/frontend/lib/api/client.ts b/apps/frontend/lib/api/client.ts index 0d3952951..c302b7dee 100644 --- a/apps/frontend/lib/api/client.ts +++ b/apps/frontend/lib/api/client.ts @@ -6,6 +6,7 @@ const DEFAULT_PUBLIC_API_URL = '/'; const INTERNAL_API_ORIGIN = 'http://127.0.0.1:8000'; +const DEFAULT_REQUEST_TIMEOUT_MS = 600_000; function normalizeApiUrl(value: string): string { const trimmed = value.trim(); @@ -38,7 +39,7 @@ export const API_BASE = resolveRuntimeApiBase(toApiBase(API_URL)); * * @param endpoint - API endpoint path or absolute URL * @param options - Standard RequestInit options - * @param timeoutMs - Optional request timeout in milliseconds (default: 240_000) + * @param timeoutMs - Optional request timeout in milliseconds (default: 600_000) */ export async function apiFetch( endpoint: string, @@ -56,13 +57,18 @@ export async function apiFetch( url = resolveRuntimeApiBase(normalizedEndpoint); } - // Matches the backend's 240s hard limit (resumes.py wait_for timeout) - const timeout = timeoutMs ?? 240_000; + // Matches the backend's 10-minute hard limit for long-running AI requests. + const timeout = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { return await fetch(url, { ...options, signal: controller.signal }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`Request timed out after ${Math.ceil(timeout / 1000)} seconds.`); + } + throw error; } finally { clearTimeout(timer); } diff --git a/apps/frontend/lib/api/resume.ts b/apps/frontend/lib/api/resume.ts index 35ec0995f..51420c373 100644 --- a/apps/frontend/lib/api/resume.ts +++ b/apps/frontend/lib/api/resume.ts @@ -114,7 +114,7 @@ async function postImprove( ): Promise { let response: Response; try { - response = await apiPost(endpoint, payload, 240_000); + response = await apiPost(endpoint, payload, 600_000); } catch (networkError) { console.error(`Network error during ${endpoint}:`, networkError); throw networkError; diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index c60ac0215..bbed7c926 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -563,6 +563,7 @@ "jobDescriptionTooShort": "Job description is too short. Add more detail and try again.", "apiKeyError": "Your API key isn't working. Check it in Settings.", "rateLimit": "Rate limit reached. Wait about a minute and try again.", + "timeout": "Resume preview timed out after 10 minutes. Try again with a shorter job description or a simpler prompt.", "failedToGenerate": "Couldn't generate the resume. Check your API key in Settings, then try again.", "failedToPreview": "Couldn't preview the resume. Check your API key in Settings, then try again.", "failedToConfirm": "Couldn't apply the changes. Please try again." diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 6c4dae0e4..d87a97ff9 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -563,6 +563,7 @@ "jobDescriptionTooShort": "La descripción del trabajo es demasiado corta. Añade más detalles e inténtalo de nuevo.", "apiKeyError": "Tu clave API no funciona. Revísala en Configuración.", "rateLimit": "Se alcanzó el límite de tasa. Espera aproximadamente un minuto e inténtalo de nuevo.", + "timeout": "La vista previa del currículum agotó el tiempo de espera tras 10 minutos. Inténtalo de nuevo con una descripción del trabajo más corta o un prompt más simple.", "failedToGenerate": "No se pudo generar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", "failedToPreview": "No se pudo previsualizar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", "failedToConfirm": "No se pudieron aplicar los cambios. Inténtalo de nuevo." diff --git a/apps/frontend/messages/ja.json b/apps/frontend/messages/ja.json index 7ec30c4d5..864ea6459 100644 --- a/apps/frontend/messages/ja.json +++ b/apps/frontend/messages/ja.json @@ -563,6 +563,7 @@ "jobDescriptionTooShort": "求人内容が短すぎます。詳細を追加してから、もう一度お試しください。", "apiKeyError": "API キーが機能していません。設定で確認してください。", "rateLimit": "レート制限に達しました。約1分待ってから再試行してください。", + "timeout": "履歴書プレビューは 10 分でタイムアウトしました。求人内容を短くするか、よりシンプルなプロンプトで再試行してください。", "failedToGenerate": "履歴書を生成できませんでした。設定で API キーを確認してから、もう一度お試しください。", "failedToPreview": "履歴書をプレビューできませんでした。設定で API キーを確認してから、もう一度お試しください。", "failedToConfirm": "変更を適用できませんでした。もう一度お試しください。" diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index a9f81d457..1f79b8e07 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -563,6 +563,7 @@ "jobDescriptionTooShort": "Descrição da vaga muito curta. Adicione mais detalhes e tente novamente.", "apiKeyError": "Sua chave de API não está funcionando. Verifique em Configurações.", "rateLimit": "Limite de requisições atingido. Aguarde cerca de um minuto e tente novamente.", + "timeout": "A prévia do currículo expirou após 10 minutos. Tente novamente com uma descrição de vaga mais curta ou um prompt mais simples.", "failedToGenerate": "Não foi possível gerar o currículo. Verifique sua chave de API em Configurações e tente novamente.", "failedToPreview": "Não foi possível pré-visualizar o currículo. Verifique sua chave de API em Configurações e tente novamente.", "failedToConfirm": "Não foi possível aplicar as alterações. Tente novamente." diff --git a/apps/frontend/messages/zh.json b/apps/frontend/messages/zh.json index b06860f7d..f6b758956 100644 --- a/apps/frontend/messages/zh.json +++ b/apps/frontend/messages/zh.json @@ -563,6 +563,7 @@ "jobDescriptionTooShort": "职位描述过短。请添加更多细节后重试。", "apiKeyError": "您的 API 密钥无效。请在设置中检查。", "rateLimit": "已达到速率限制。请等待约一分钟后重试。", + "timeout": "简历预览在 10 分钟后超时。请缩短职位描述,或改用更简单的提示词后重试。", "failedToGenerate": "无法生成简历。请在设置中检查 API 密钥后重试。", "failedToPreview": "无法预览简历。请在设置中检查 API 密钥后重试。", "failedToConfirm": "无法应用更改。请重试。" diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 1018e672b..487191268 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -5,7 +5,7 @@ const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN || 'http://127.0.0.1:8000'; const nextConfig: NextConfig = { output: 'standalone', experimental: { - proxyTimeout: 240_000, + proxyTimeout: 600_000, // Tree-shake barrel imports — saves ~200-800ms cold start per route optimizePackageImports: [ 'lucide-react', From 363e50e1991ce9e25e266af657beaad3f1e95531 Mon Sep 17 00:00:00 2001 From: silu Date: Wed, 6 May 2026 23:16:18 +0800 Subject: [PATCH 2/2] Keep preview timeout at 240 seconds --- apps/backend/app/routers/resumes.py | 4 ++-- apps/frontend/lib/api/client.ts | 12 +++--------- apps/frontend/lib/api/resume.ts | 2 +- apps/frontend/messages/en.json | 2 +- apps/frontend/messages/es.json | 2 +- apps/frontend/messages/ja.json | 2 +- apps/frontend/messages/pt-BR.json | 2 +- apps/frontend/messages/zh.json | 2 +- apps/frontend/next.config.ts | 2 +- 9 files changed, 12 insertions(+), 18 deletions(-) diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index c42e2e7ea..f06b794b1 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -693,11 +693,11 @@ async def improve_resume_preview_endpoint( language=language, prompt_id=prompt_id, ), - timeout=600.0, # 10-minute hard limit + timeout=240.0, # 4-minute hard limit ) except asyncio.TimeoutError: logger.error( - "Improve preview timed out after 600s for resume %s / job %s", + "Improve preview timed out after 240s for resume %s / job %s", request.resume_id, request.job_id, ) diff --git a/apps/frontend/lib/api/client.ts b/apps/frontend/lib/api/client.ts index c302b7dee..0d3952951 100644 --- a/apps/frontend/lib/api/client.ts +++ b/apps/frontend/lib/api/client.ts @@ -6,7 +6,6 @@ const DEFAULT_PUBLIC_API_URL = '/'; const INTERNAL_API_ORIGIN = 'http://127.0.0.1:8000'; -const DEFAULT_REQUEST_TIMEOUT_MS = 600_000; function normalizeApiUrl(value: string): string { const trimmed = value.trim(); @@ -39,7 +38,7 @@ export const API_BASE = resolveRuntimeApiBase(toApiBase(API_URL)); * * @param endpoint - API endpoint path or absolute URL * @param options - Standard RequestInit options - * @param timeoutMs - Optional request timeout in milliseconds (default: 600_000) + * @param timeoutMs - Optional request timeout in milliseconds (default: 240_000) */ export async function apiFetch( endpoint: string, @@ -57,18 +56,13 @@ export async function apiFetch( url = resolveRuntimeApiBase(normalizedEndpoint); } - // Matches the backend's 10-minute hard limit for long-running AI requests. - const timeout = timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + // Matches the backend's 240s hard limit (resumes.py wait_for timeout) + const timeout = timeoutMs ?? 240_000; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { return await fetch(url, { ...options, signal: controller.signal }); - } catch (error) { - if (controller.signal.aborted) { - throw new Error(`Request timed out after ${Math.ceil(timeout / 1000)} seconds.`); - } - throw error; } finally { clearTimeout(timer); } diff --git a/apps/frontend/lib/api/resume.ts b/apps/frontend/lib/api/resume.ts index 51420c373..35ec0995f 100644 --- a/apps/frontend/lib/api/resume.ts +++ b/apps/frontend/lib/api/resume.ts @@ -114,7 +114,7 @@ async function postImprove( ): Promise { let response: Response; try { - response = await apiPost(endpoint, payload, 600_000); + response = await apiPost(endpoint, payload, 240_000); } catch (networkError) { console.error(`Network error during ${endpoint}:`, networkError); throw networkError; diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index bbed7c926..ff8c1dc40 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -563,7 +563,7 @@ "jobDescriptionTooShort": "Job description is too short. Add more detail and try again.", "apiKeyError": "Your API key isn't working. Check it in Settings.", "rateLimit": "Rate limit reached. Wait about a minute and try again.", - "timeout": "Resume preview timed out after 10 minutes. Try again with a shorter job description or a simpler prompt.", + "timeout": "Resume preview timed out after 4 minutes. Try again with a shorter job description or a simpler prompt.", "failedToGenerate": "Couldn't generate the resume. Check your API key in Settings, then try again.", "failedToPreview": "Couldn't preview the resume. Check your API key in Settings, then try again.", "failedToConfirm": "Couldn't apply the changes. Please try again." diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index d87a97ff9..46df67dd8 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -563,7 +563,7 @@ "jobDescriptionTooShort": "La descripción del trabajo es demasiado corta. Añade más detalles e inténtalo de nuevo.", "apiKeyError": "Tu clave API no funciona. Revísala en Configuración.", "rateLimit": "Se alcanzó el límite de tasa. Espera aproximadamente un minuto e inténtalo de nuevo.", - "timeout": "La vista previa del currículum agotó el tiempo de espera tras 10 minutos. Inténtalo de nuevo con una descripción del trabajo más corta o un prompt más simple.", + "timeout": "La vista previa del currículum agotó el tiempo de espera tras 4 minutos. Inténtalo de nuevo con una descripción del trabajo más corta o un prompt más simple.", "failedToGenerate": "No se pudo generar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", "failedToPreview": "No se pudo previsualizar el currículum. Revisa tu clave API en Configuración e inténtalo de nuevo.", "failedToConfirm": "No se pudieron aplicar los cambios. Inténtalo de nuevo." diff --git a/apps/frontend/messages/ja.json b/apps/frontend/messages/ja.json index 864ea6459..a5083dfb9 100644 --- a/apps/frontend/messages/ja.json +++ b/apps/frontend/messages/ja.json @@ -563,7 +563,7 @@ "jobDescriptionTooShort": "求人内容が短すぎます。詳細を追加してから、もう一度お試しください。", "apiKeyError": "API キーが機能していません。設定で確認してください。", "rateLimit": "レート制限に達しました。約1分待ってから再試行してください。", - "timeout": "履歴書プレビューは 10 分でタイムアウトしました。求人内容を短くするか、よりシンプルなプロンプトで再試行してください。", + "timeout": "履歴書プレビューは 4 分でタイムアウトしました。求人内容を短くするか、よりシンプルなプロンプトで再試行してください。", "failedToGenerate": "履歴書を生成できませんでした。設定で API キーを確認してから、もう一度お試しください。", "failedToPreview": "履歴書をプレビューできませんでした。設定で API キーを確認してから、もう一度お試しください。", "failedToConfirm": "変更を適用できませんでした。もう一度お試しください。" diff --git a/apps/frontend/messages/pt-BR.json b/apps/frontend/messages/pt-BR.json index 1f79b8e07..c198b885b 100644 --- a/apps/frontend/messages/pt-BR.json +++ b/apps/frontend/messages/pt-BR.json @@ -563,7 +563,7 @@ "jobDescriptionTooShort": "Descrição da vaga muito curta. Adicione mais detalhes e tente novamente.", "apiKeyError": "Sua chave de API não está funcionando. Verifique em Configurações.", "rateLimit": "Limite de requisições atingido. Aguarde cerca de um minuto e tente novamente.", - "timeout": "A prévia do currículo expirou após 10 minutos. Tente novamente com uma descrição de vaga mais curta ou um prompt mais simples.", + "timeout": "A prévia do currículo expirou após 4 minutos. Tente novamente com uma descrição de vaga mais curta ou um prompt mais simples.", "failedToGenerate": "Não foi possível gerar o currículo. Verifique sua chave de API em Configurações e tente novamente.", "failedToPreview": "Não foi possível pré-visualizar o currículo. Verifique sua chave de API em Configurações e tente novamente.", "failedToConfirm": "Não foi possível aplicar as alterações. Tente novamente." diff --git a/apps/frontend/messages/zh.json b/apps/frontend/messages/zh.json index f6b758956..6a4abecbf 100644 --- a/apps/frontend/messages/zh.json +++ b/apps/frontend/messages/zh.json @@ -563,7 +563,7 @@ "jobDescriptionTooShort": "职位描述过短。请添加更多细节后重试。", "apiKeyError": "您的 API 密钥无效。请在设置中检查。", "rateLimit": "已达到速率限制。请等待约一分钟后重试。", - "timeout": "简历预览在 10 分钟后超时。请缩短职位描述,或改用更简单的提示词后重试。", + "timeout": "简历预览在 4 分钟后超时。请缩短职位描述,或改用更简单的提示词后重试。", "failedToGenerate": "无法生成简历。请在设置中检查 API 密钥后重试。", "failedToPreview": "无法预览简历。请在设置中检查 API 密钥后重试。", "failedToConfirm": "无法应用更改。请重试。" diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index 487191268..1018e672b 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -5,7 +5,7 @@ const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN || 'http://127.0.0.1:8000'; const nextConfig: NextConfig = { output: 'standalone', experimental: { - proxyTimeout: 600_000, + proxyTimeout: 240_000, // Tree-shake barrel imports — saves ~200-800ms cold start per route optimizePackageImports: [ 'lucide-react',