Skip to content

Commit 4e6d5e7

Browse files
author
Noveris-Teams
committed
fix(feishu-sync): harden AI timeout recovery
1 parent ed8ec2d commit 4e6d5e7

8 files changed

Lines changed: 183 additions & 33 deletions

File tree

.github/workflows/feishu-sync.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ jobs:
3232
FEISHU_AI_TRANSLATE_ZH_TO_EN: ${{ vars.FEISHU_AI_TRANSLATE_ZH_TO_EN || 'true' }}
3333
FEISHU_AI_STRICT: ${{ vars.FEISHU_AI_STRICT || 'true' }}
3434
FEISHU_AI_TEMPERATURE: ${{ vars.FEISHU_AI_TEMPERATURE }}
35+
FEISHU_AI_TIMEOUT_MS: ${{ vars.FEISHU_AI_TIMEOUT_MS || '180000' }}
36+
FEISHU_AI_RETRY_BACKOFF_MS: ${{ vars.FEISHU_AI_RETRY_BACKOFF_MS || '1500' }}
37+
FEISHU_AI_FAIL_ON_TRANSLATION_ERROR: ${{ vars.FEISHU_AI_FAIL_ON_TRANSLATION_ERROR || 'false' }}
3538
FEISHU_AI_MAX_TOKENS: ${{ vars.FEISHU_AI_MAX_TOKENS }}
3639
FEISHU_AI_TAG_MAX: ${{ vars.FEISHU_AI_TAG_MAX }}
3740
FEISHU_AI_KEYWORD_MAX: ${{ vars.FEISHU_AI_KEYWORD_MAX }}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ Build output is deployed to GitHub Pages via `actions/deploy-pages`.
225225
- Feishu sync exits early:
226226
- verify `FEISHU_APP_ID`, `FEISHU_APP_SECRET`, and folder token/name
227227
- verify `TRANSLATE_API_KEY` when AI translation is enabled
228+
- tune `FEISHU_AI_TIMEOUT_MS` if the provider is slow for long documents
229+
- keep `FEISHU_AI_FAIL_ON_TRANSLATION_ERROR=false` to let nightly sync continue with zh content and preserved stale en output on retryable AI failures
228230

229231
## License
230232

README.zh-CN.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ npm run feishu:sync
225225
- 飞书同步中断:
226226
- 检查 `FEISHU_APP_ID``FEISHU_APP_SECRET`、目录 token/name
227227
- 开启 AI 翻译时检查 `TRANSLATE_API_KEY`
228+
- 长文档遇到模型侧慢响应时,可调大 `FEISHU_AI_TIMEOUT_MS`
229+
- 保持 `FEISHU_AI_FAIL_ON_TRANSLATION_ERROR=false`,可在 AI 可重试异常时继续同步中文并保留旧英文稿,不再让夜间任务整批失败
228230

229231
## License
230232

scripts/feishu-cms-bridge/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ Optional:
2020
- `FEISHU_AI_ENABLED` (default: `true`)
2121
- `FEISHU_AI_TRANSLATE_ZH_TO_EN` (default: `true`)
2222
- `FEISHU_AI_STRICT` (default: `true`)
23+
- `FEISHU_AI_TIMEOUT_MS` (default: `180000`)
24+
- `FEISHU_AI_RETRY_BACKOFF_MS` (default: `1500`)
25+
- `FEISHU_AI_FAIL_ON_TRANSLATION_ERROR` (default: `false`)
2326
- `TRANSLATE_API_KEY` (or `FEISHU_AI_API_KEY`)
2427
- `TRANSLATE_MODEL` / `TRANSLATE_BASE_URL`
2528
- taxonomy config file: `config/blog-taxonomy.json`
@@ -53,6 +56,8 @@ Each category's default tags and zh/en display names are defined in
5356
- AI zh->en pipeline is constrained to allowed categories
5457

5558
When `FEISHU_AI_TRANSLATE_ZH_TO_EN=true`, `zh` docs will auto-generate `en` posts via AI.
59+
If the AI provider hits a retryable timeout or transient error, the bridge now syncs the `zh`
60+
post first and preserves the previous managed `en` post when one already exists.
5661

5762
## Run
5863

scripts/feishu-cms-bridge/ai/content-pipeline.mjs

Lines changed: 74 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { setTimeout as sleep } from 'node:timers/promises';
12
import { slugify } from '../transform/slug.mjs';
23

34
function normalizeModelResponse(raw) {
@@ -74,6 +75,46 @@ function getFinishReason(payload) {
7475
return compactText(payload?.choices?.[0]?.finish_reason || '');
7576
}
7677

78+
function createAiError(message, details = {}) {
79+
const error = new Error(message);
80+
Object.assign(error, details);
81+
return error;
82+
}
83+
84+
function isRetryableStatus(status) {
85+
return status === 408 || status === 409 || status === 425 || status === 429 || status >= 500;
86+
}
87+
88+
function buildAttemptPlan(options) {
89+
const baseTimeoutMs = Number(options.aiTimeoutMs) > 0 ? Number(options.aiTimeoutMs) : 180000;
90+
const baseMaxTokens = Number(options.aiMaxTokens) > 0 ? Number(options.aiMaxTokens) : 8192;
91+
const expandedMaxTokens = Math.max(baseMaxTokens, 12288);
92+
const timeoutCandidates = [...new Set([baseTimeoutMs, Math.round(baseTimeoutMs * 1.5), Math.round(baseTimeoutMs * 2)])];
93+
94+
return [
95+
{
96+
forceJsonObject: true,
97+
maxTokens: baseMaxTokens,
98+
timeoutMs: timeoutCandidates[0]
99+
},
100+
{
101+
forceJsonObject: false,
102+
maxTokens: baseMaxTokens,
103+
timeoutMs: timeoutCandidates[Math.min(1, timeoutCandidates.length - 1)]
104+
},
105+
{
106+
forceJsonObject: false,
107+
maxTokens: expandedMaxTokens,
108+
timeoutMs: timeoutCandidates[timeoutCandidates.length - 1]
109+
}
110+
];
111+
}
112+
113+
function getRetryDelayMs(options, attemptIndex) {
114+
const baseDelayMs = Number(options.aiRetryBackoffMs) > 0 ? Number(options.aiRetryBackoffMs) : 1500;
115+
return Math.min(baseDelayMs * 2 ** attemptIndex, 10000);
116+
}
117+
77118
function buildCategoryGuide(options) {
78119
if (!options?.taxonomy || typeof options.taxonomy.buildPromptCategoryGuide !== 'function') {
79120
return [];
@@ -227,20 +268,14 @@ export async function runAiContentPipeline(input, options) {
227268

228269
const endpoint = `${String(options.aiBaseUrl || '').replace(/\/$/, '')}/chat/completions`;
229270
const prompt = buildPrompt(input, options);
230-
231-
const requestTimeoutMs = Number(options.aiTimeoutMs) > 0 ? Number(options.aiTimeoutMs) : 120000;
232-
const baseMaxTokens = Number(options.aiMaxTokens) > 0 ? Number(options.aiMaxTokens) : 8192;
233-
const tokenCandidates = [...new Set([baseMaxTokens, Math.max(baseMaxTokens, 12288)])];
234-
const attempts = tokenCandidates.flatMap((maxTokens) => [
235-
{ maxTokens, forceJsonObject: true },
236-
{ maxTokens, forceJsonObject: false }
237-
]);
271+
const title = compactText(input?.title, `document ${String(input?.token || '').slice(0, 8)}`);
272+
const attempts = buildAttemptPlan(options);
238273

239274
let lastError = null;
240275
let lastFinishReason = '';
241276
let lastPreview = '';
242277

243-
for (const attempt of attempts) {
278+
for (const [attemptIndex, attempt] of attempts.entries()) {
244279
const requestBody = {
245280
model: options.aiModel,
246281
temperature: options.aiTemperature,
@@ -264,7 +299,7 @@ export async function runAiContentPipeline(input, options) {
264299

265300
try {
266301
const controller = new AbortController();
267-
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
302+
const timeout = setTimeout(() => controller.abort(), attempt.timeoutMs);
268303
let response;
269304
try {
270305
response = await fetch(endpoint, {
@@ -282,7 +317,11 @@ export async function runAiContentPipeline(input, options) {
282317

283318
if (!response.ok) {
284319
const errorText = await response.text();
285-
throw new Error(`AI pipeline request failed: ${response.status} ${errorText.slice(0, 400)}`);
320+
throw createAiError(`AI pipeline request failed: ${response.status} ${errorText.slice(0, 400)}`, {
321+
code: 'AI_HTTP_ERROR',
322+
retryable: isRetryableStatus(response.status),
323+
status: response.status
324+
});
286325
}
287326

288327
const payload = await response.json();
@@ -305,15 +344,35 @@ export async function runAiContentPipeline(input, options) {
305344
return normalized;
306345
} catch (error) {
307346
if (error?.name === 'AbortError') {
308-
lastError = new Error(`AI pipeline request timed out after ${requestTimeoutMs}ms`);
309-
continue;
347+
lastError = createAiError(`AI pipeline request timed out after ${attempt.timeoutMs}ms`, {
348+
code: 'AI_TIMEOUT',
349+
retryable: true,
350+
timeoutMs: attempt.timeoutMs
351+
});
352+
} else {
353+
lastError = error;
354+
}
355+
356+
const canRetry = attemptIndex < attempts.length - 1 && lastError?.retryable !== false;
357+
if (!canRetry) {
358+
break;
310359
}
311-
lastError = error;
360+
361+
const delayMs = getRetryDelayMs(options, attemptIndex);
362+
console.warn(
363+
`[feishu-sync] AI retry ${attemptIndex + 2}/${attempts.length} for "${title}" after ${lastError.message} (wait ${delayMs}ms)`
364+
);
365+
await sleep(delayMs);
312366
}
313367
}
314368

315369
const finishReasonPart = lastFinishReason ? ` finish_reason=${lastFinishReason}.` : '';
316370
const previewPart = lastPreview ? ` preview=${JSON.stringify(lastPreview)}.` : '';
317371
const errorMessage = lastError?.message || 'AI pipeline failed after retries';
318-
throw new Error(`${errorMessage}.${finishReasonPart}${previewPart}`);
372+
throw createAiError(`${errorMessage}.${finishReasonPart}${previewPart}`, {
373+
code: lastError?.code || 'AI_PIPELINE_FAILED',
374+
retryable: lastError?.retryable !== false,
375+
status: lastError?.status,
376+
timeoutMs: lastError?.timeoutMs
377+
});
319378
}

scripts/feishu-cms-bridge/config.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,9 @@ export const config = {
7171
'FEISHU_AI_TEMPERATURE',
7272
readNumber('TRANSLATE_TEMPERATURE', Number(translationDefaults.temperature || 0.2))
7373
),
74-
aiTimeoutMs: readNumber('FEISHU_AI_TIMEOUT_MS', 120000),
74+
aiTimeoutMs: readNumber('FEISHU_AI_TIMEOUT_MS', 180000),
75+
aiRetryBackoffMs: readNumber('FEISHU_AI_RETRY_BACKOFF_MS', 1500),
76+
aiFailOnTranslationError: readBool('FEISHU_AI_FAIL_ON_TRANSLATION_ERROR', false),
7577
aiMaxTokens: readNumber(
7678
'FEISHU_AI_MAX_TOKENS',
7779
readNumber('TRANSLATE_MAX_TOKENS', Number(translationDefaults.maxTokens || 8192))

scripts/feishu-cms-bridge/index.mjs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ async function main() {
5252
skipped: 0,
5353
changed: 0,
5454
unchanged: 0,
55+
degraded: 0,
5556
failed: 0,
5657
removed: 0,
57-
errors: []
58+
errors: [],
59+
warnings: []
5860
};
5961

6062
for (const sourceDoc of docs) {
@@ -79,12 +81,18 @@ async function main() {
7981
aiModel: config.aiModel,
8082
aiTemperature: config.aiTemperature,
8183
aiTimeoutMs: config.aiTimeoutMs,
84+
aiRetryBackoffMs: config.aiRetryBackoffMs,
85+
aiFailOnTranslationError: config.aiFailOnTranslationError,
8286
aiMaxTokens: config.aiMaxTokens,
8387
aiTagMax: config.aiTagMax,
8488
aiKeywordMax: config.aiKeywordMax,
8589
aiProtectedTerms: config.aiProtectedTerms,
8690
aiTermStyleRules: config.aiTermStyleRules,
87-
taxonomy: config.taxonomy
91+
taxonomy: config.taxonomy,
92+
existingOutputs: {
93+
zh: previousState.docs[sourceDoc.token] || null,
94+
en: previousState.docs[`${sourceDoc.token}::en`] || null
95+
}
8896
});
8997

9098
const outputs = Array.isArray(result.outputs) && result.outputs.length > 0 ? result.outputs : [result];
@@ -109,6 +117,11 @@ async function main() {
109117
} else {
110118
summary.unchanged += 1;
111119
}
120+
121+
if (Array.isArray(result.warnings) && result.warnings.length > 0) {
122+
summary.degraded += 1;
123+
summary.warnings.push(...result.warnings);
124+
}
112125
} catch (error) {
113126
summary.failed += 1;
114127
summary.errors.push({ token: sourceDoc.token, title: sourceDoc.title, message: error.message });
@@ -127,13 +140,17 @@ async function main() {
127140
}
128141

129142
console.log(
130-
`[feishu-sync] done: total=${summary.total}, skipped=${summary.skipped}, changed=${summary.changed}, unchanged=${summary.unchanged}, removed=${summary.removed}, failed=${summary.failed}`
143+
`[feishu-sync] done: total=${summary.total}, skipped=${summary.skipped}, changed=${summary.changed}, unchanged=${summary.unchanged}, degraded=${summary.degraded}, removed=${summary.removed}, failed=${summary.failed}`
131144
);
132145

133146
if (summary.failed > 0) {
134147
throw new Error(`[feishu-sync] ${summary.failed} document(s) failed`);
135148
}
136149

150+
if (summary.degraded > 0) {
151+
console.warn(`[feishu-sync] completed with ${summary.degraded} degraded document(s); zh content was synced and stale en content was preserved when available.`);
152+
}
153+
137154
if (config.autoPush) {
138155
const pushed = commitAndPushIfNeeded(config, summary);
139156
if (pushed) {

scripts/feishu-cms-bridge/sync/doc-syncer.mjs

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,16 @@ function compactText(value, fallback = '') {
4040
return normalized || fallback;
4141
}
4242

43+
async function pathExists(targetPath) {
44+
if (!targetPath) return false;
45+
try {
46+
await fs.access(targetPath);
47+
return true;
48+
} catch {
49+
return false;
50+
}
51+
}
52+
4353
function normalizeList(input, max = 12) {
4454
const values = Array.isArray(input)
4555
? input
@@ -220,6 +230,29 @@ async function resolveManagedOutputPath({ blogContentDir, lang, preferredSlug, t
220230
};
221231
}
222232

233+
async function buildPreservedOutput(existingEntry, stateKey) {
234+
if (!existingEntry?.contentPath) {
235+
return null;
236+
}
237+
238+
const contentExists = await pathExists(existingEntry.contentPath);
239+
if (!contentExists) {
240+
return null;
241+
}
242+
243+
return {
244+
stateKey,
245+
token: existingEntry.token,
246+
lang: existingEntry.lang,
247+
slug: existingEntry.slug,
248+
title: existingEntry.title,
249+
changed: false,
250+
contentPath: existingEntry.contentPath,
251+
assetDir: existingEntry.assetDir || null,
252+
preserved: true
253+
};
254+
}
255+
223256
export async function syncSingleDocument(client, sourceDoc, context) {
224257
const blocks = await fetchDocBlocks(client, sourceDoc.token);
225258
if (!Array.isArray(blocks) || blocks.length === 0) {
@@ -233,7 +266,9 @@ export async function syncSingleDocument(client, sourceDoc, context) {
233266
blocks
234267
};
235268

236-
const renderer = new MarkdownRenderer(docPayload);
269+
const MarkdownRendererImpl = context.markdownRenderer || MarkdownRenderer;
270+
const aiRunner = context.aiRunner || runAiContentPipeline;
271+
const renderer = new MarkdownRendererImpl(docPayload);
237272
const rawMarkdown = renderer.parse();
238273
const docMeta = renderer.meta && typeof renderer.meta === 'object' ? renderer.meta : {};
239274
const fileTokens = renderer.fileTokens || {};
@@ -284,6 +319,7 @@ export async function syncSingleDocument(client, sourceDoc, context) {
284319

285320
const outputs = [];
286321
let aiResult = null;
322+
const warnings = [];
287323

288324
const shouldTranslateToEn = Boolean(context.aiEnabled && context.aiTranslateZhToEn && lang === 'zh');
289325
if (shouldTranslateToEn) {
@@ -293,18 +329,29 @@ export async function syncSingleDocument(client, sourceDoc, context) {
293329
}
294330
console.warn(`[feishu-sync] AI disabled for "${title}" because API key is empty.`);
295331
} else {
296-
aiResult = await runAiContentPipeline(
297-
{
298-
token: sourceDoc.token,
299-
title,
300-
description,
301-
category,
302-
tags,
303-
slug,
304-
markdownBody
305-
},
306-
context
307-
);
332+
try {
333+
aiResult = await aiRunner(
334+
{
335+
token: sourceDoc.token,
336+
title,
337+
description,
338+
category,
339+
tags,
340+
slug,
341+
markdownBody
342+
},
343+
context
344+
);
345+
} catch (error) {
346+
const canDowngrade = error?.retryable !== false && !context.aiFailOnTranslationError;
347+
if (!canDowngrade) {
348+
throw error;
349+
}
350+
351+
const fallbackMessage = `[feishu-sync] AI translation degraded for "${title}": ${error.message}`;
352+
warnings.push(fallbackMessage);
353+
console.warn(fallbackMessage);
354+
}
308355
}
309356
}
310357

@@ -403,6 +450,18 @@ export async function syncSingleDocument(client, sourceDoc, context) {
403450
contentPath: enOutputPath,
404451
assetDir: null
405452
});
453+
} else if (shouldTranslateToEn) {
454+
const preservedEn = await buildPreservedOutput(context?.existingOutputs?.en, `${sourceDoc.token}::en`);
455+
if (preservedEn) {
456+
outputs.push(preservedEn);
457+
const preserveMessage = `[feishu-sync] preserved existing en/${preservedEn.slug} for "${title}" until AI translation recovers.`;
458+
warnings.push(preserveMessage);
459+
console.warn(preserveMessage);
460+
} else if (warnings.length > 0) {
461+
const zhOnlyMessage = `[feishu-sync] no existing English post to preserve for "${title}", synced zh only.`;
462+
warnings.push(zhOnlyMessage);
463+
console.warn(zhOnlyMessage);
464+
}
406465
}
407466

408467
const changed = outputs.some((entry) => entry.changed);
@@ -415,6 +474,7 @@ export async function syncSingleDocument(client, sourceDoc, context) {
415474
changed,
416475
contentPath: sourceOutputPath,
417476
assetDir,
418-
outputs
477+
outputs,
478+
warnings
419479
};
420480
}

0 commit comments

Comments
 (0)