free-short-video 7.0.6 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +14 -4
  2. package/core/api/agnes_image.py +50 -25
  3. package/core/api/agnes_video.py +177 -81
  4. package/core/api/key_manager.py +76 -0
  5. package/core/api/rate_limiter.py +37 -14
  6. package/core/api/retry_policy.py +124 -0
  7. package/core/config.py +12 -7
  8. package/package.json +1 -1
  9. package/static/assets/{ar-DDU7VnXA.js → ar-C1KWUlNh.js} +1 -1
  10. package/static/assets/{bn-nY2VgxNf.js → bn-C4SfxNGN.js} +1 -1
  11. package/static/assets/de-B4OvLzBp.js +1 -0
  12. package/static/assets/es-IQACehBJ.js +1 -0
  13. package/static/assets/{fa-DC45iYzo.js → fa-BzLk3cXs.js} +1 -1
  14. package/static/assets/fr-BMhNx-iQ.js +1 -0
  15. package/static/assets/{hi-DU2jFclD.js → hi-B7Vaoj8j.js} +1 -1
  16. package/static/assets/id-B5vxBOhd.js +1 -0
  17. package/static/assets/index-BYtvF5AK.js +46 -0
  18. package/static/assets/index-X0RXgNf5.css +1 -0
  19. package/static/assets/it-DwDZ9skF.js +1 -0
  20. package/static/assets/ja-DyBi5ySN.js +1 -0
  21. package/static/assets/ko-DtnJDAHo.js +1 -0
  22. package/static/assets/ms-CCjt7pyM.js +1 -0
  23. package/static/assets/nl-Bqpu5-or.js +1 -0
  24. package/static/assets/pt-DzUv3ga-.js +1 -0
  25. package/static/assets/{ru-DUk7xiI4.js → ru-CMGJcpUZ.js} +1 -1
  26. package/static/assets/{th-DBGXh4cH.js → th-DnlP-4Ac.js} +1 -1
  27. package/static/assets/tl-BzNuvuWV.js +1 -0
  28. package/static/assets/tr-Ca1gT8-V.js +1 -0
  29. package/static/assets/{ur-CBrqZI5F.js → ur-DMIuAGiE.js} +1 -1
  30. package/static/assets/vi-BURrm61d.js +1 -0
  31. package/static/index.html +2 -2
  32. package/web/routes/config_routes.py +36 -13
  33. package/static/assets/de-t0gEvqFh.js +0 -1
  34. package/static/assets/es-BrDX0bsl.js +0 -1
  35. package/static/assets/fr-jNdU3xMA.js +0 -1
  36. package/static/assets/id-xVm_LLjT.js +0 -1
  37. package/static/assets/index-B_DQr-bu.css +0 -1
  38. package/static/assets/index-D06v2INE.js +0 -45
  39. package/static/assets/it-CNEcqCqv.js +0 -1
  40. package/static/assets/ja-BMrQ7ES8.js +0 -1
  41. package/static/assets/ko-DLcdCpqD.js +0 -1
  42. package/static/assets/ms-lz3IP0pE.js +0 -1
  43. package/static/assets/nl-JdaJ6OFu.js +0 -1
  44. package/static/assets/pt-BAJ6NELp.js +0 -1
  45. package/static/assets/tl-cWM0TcNr.js +0 -1
  46. package/static/assets/tr-DY7yRLf-.js +0 -1
  47. package/static/assets/vi-BWMCwlNI.js +0 -1
package/README.md CHANGED
@@ -1,20 +1,30 @@
1
1
  ---
2
2
 
3
- # What's New in v7.0.6
3
+ # What's New in v7.1.0
4
4
 
5
5
  ## What's New
6
6
 
7
7
  ### Features & Improvements
8
8
 
9
- * **Style preset picker shows what is active.** The preset dropdown was rebuilt as a native-select-style control: the trigger displays the currently selected preset name, the list marks the active entry with a checkmark, and a one-line hint under the field explains what picking a preset does. The trigger label, placeholder and hint are localized in all 22 interface languages.
9
+ - **Off-peak retry guidance for AI video generation.** When the upstream video queue is full (HTTP 503 / queue-full) the failure panel and the create-task toast now suggest the time windows in which upstream is historically smoother, based on observed success rates rather than guesswork. The windows are stored in UTC and rendered in each viewer's own local time with a timezone label, so users in different regions see their own best hours; the wording is localized in all 22 interface languages.
10
+ - **API key panel shows upstream authentication failures.** Keys that hit an upstream `401 Invalid token` are now badged **Auth failed ×N** in the key list, with a tooltip showing the failure count, the last failure time and the upstream message, plus a matching log line. Registration is observation only: no key is removed, reweighted or reordered automatically — whether to delete a key stays entirely your decision in the configuration panel. Keys are only ever shown masked.
11
+
12
+ ### Refactoring & Optimizations
13
+
14
+ - **Unified upstream retry policy ("busy / fault" two-track model).** Every upstream call — video submission, image generation, image upload and chat — now shares one retry policy module (`core/api/retry_policy.py`) as the single source of truth for classifying a response and computing the next interval:
15
+ - **Busy track** (HTTP 429, *all* 503 including bare 503 without an error code, and queue-full codes): the first retry fires immediately, hugging the rate limit so the token bucket — not a hand-rolled sleep — sets the pace; subsequent retries use a fixed interval (steady "knocking") under an attempt cap.
16
+ - **Fault track** (other 5xx, timeouts, connection errors): the previous escalating backoff `base × (n + 1)`, also attempt-capped.
17
+ This replaces the previously scattered per-provider retry branches, so behavior is consistent across all upstream calls and easier to reason about and tune. The busy-track cap also moved from a wall-clock budget to an attempt count, which on multi-key setups lets the same number of attempts finish sooner.
18
+ - **Robust image upload path.** The image-upload retry loop was rebuilt on the same two-track policy, with a bounded loop and consistent backoff instead of a hard-coded pause.
10
19
 
11
20
  ### Bug Fixes
12
21
 
13
- * **No more Chinese visual style inside non-Chinese tasks.** The hardcoded Chinese style default was removed from the Creative and Poetry task forms, from the shared legacy task-creation endpoint, and from the Creative and Poetry task models. Leaving the style field empty now pre-fills it on page load with the language-neutral default preset (an English cinematic prompt), so the task, the generated scene prompts and the diagnostic report all stay in the language the user is working in.
22
+ - **Queue-full 503s are no longer mistaken for hard failures.** Bare 503 responses carrying no error code are now classified as "server busy" and retried on the busy track, so video tasks ride out queue saturation instead of giving up early.
23
+ - **Image upload can no longer spin on an unusable response.** An upload response that contains no usable hosting URL no longer loops indefinitely and falls back cleanly.
14
24
 
15
25
  ---
16
26
 
17
- Existing projects do not need to be recreated. Reopening a Creative or Poetry form after upgrading shows the preset name in the style field, and you can pick any other preset — or clear the field — as before.
27
+ No action is required after upgrading. If you previously set `AGNES_VIDEO_QUEUE_RETRY_SECONDS`, switch to `AGNES_VIDEO_BUSY_RETRY_ATTEMPTS` (see Upgrade notes).
18
28
 
19
29
  ---
20
30
 
@@ -12,6 +12,7 @@ import requests
12
12
  from core.api.error_collector import collect_error, collect_error_from_exception
13
13
  from core.api.key_manager import get_key_ring
14
14
  from core.api.rate_limiter import get_rate_limiter
15
+ from core.api.retry_policy import BusyTracker, busy_delay, fault_delay, is_busy_signal
15
16
  from core.config import get_base_url_for_key
16
17
  from utils.image import download_image
17
18
  from utils.image_normalizer import normalize_reference_path
@@ -21,6 +22,9 @@ logger = logging.getLogger(__name__)
21
22
  # 图像生成读超时基数(秒):timeout = 基数 * (attempt + 1),首次 120s 逐步放宽
22
23
  _READ_TIMEOUT_BASE_SECONDS = 120
23
24
 
25
+ # v7.2 忙轨次数封顶:等效旧的 300s 预算 ÷ 20s 间隔基数
26
+ _BUSY_MAX_ATTEMPTS = 15
27
+
24
28
 
25
29
  class ImageOutput:
26
30
  def __init__(self, fmt: str, ext: str, data: str):
@@ -159,6 +163,8 @@ class AgnesImageAPI:
159
163
  rotations = 0
160
164
  ring = get_key_ring()
161
165
  max_rotations = len(ring) * max_retries
166
+ # 忙轨(429 / 所有 503):首跳贴限流 + 固定间隔 + 次数封顶
167
+ busy: BusyTracker | None = None
162
168
  while attempt < max_retries:
163
169
  try:
164
170
  # 全局限速:在发起 HTTP 请求前获取令牌(2.3 异步原生)
@@ -174,9 +180,12 @@ class AgnesImageAPI:
174
180
  timeout=(30, read_timeout),
175
181
  )
176
182
 
177
- # 429 限流:多 Key 换 Key 立即重试;否则指数退避
178
- if resp.status_code == 429:
179
- if ring.has_multiple() and rotations < max_rotations:
183
+ # ── 忙轨(429 / 所有 503)────────────────────────────────
184
+ # 多 Key 先换 Key 立即重试;换无可换则首跳贴限流(交给共享令牌桶)、
185
+ # 之后固定间隔重试(不逐级加长),由次数封顶兜底。
186
+ if is_busy_signal(resp.status_code):
187
+ if (resp.status_code == 429 and ring.has_multiple()
188
+ and rotations < max_rotations):
180
189
  rotations += 1
181
190
  ring.rotate()
182
191
  logger.warning(
@@ -184,40 +193,56 @@ class AgnesImageAPI:
184
193
  f"(image, rotation {rotations})"
185
194
  )
186
195
  continue
187
- if attempt < max_retries - 1:
188
- delay = retry_base_delay * (attempt + 1)
189
- logger.warning(
190
- f"[AgnesImage] 429 rate limit, "
191
- f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
196
+ if busy is None:
197
+ from core.config import get_settings
198
+ busy = BusyTracker(
199
+ get_settings().agnes_busy_retry_attempts
200
+ or _BUSY_MAX_ATTEMPTS
192
201
  )
202
+ error_type = (
203
+ "RateLimit429" if resp.status_code == 429
204
+ else f"Busy{resp.status_code}"
205
+ )
206
+ if busy.exhausted():
207
+ # 次数用尽:交给调用方按原语义抛错(记录最终失败)
193
208
  collect_error(
194
209
  "image", "generate_single_image",
195
210
  prompt=prompt,
196
- error_type="RateLimit429",
197
- error_message="HTTP 429: rate limited",
198
- status_code=429,
211
+ error_type=error_type,
212
+ error_message=(
213
+ f"HTTP {resp.status_code}: busy retries exhausted"
214
+ ),
215
+ status_code=resp.status_code,
199
216
  response_body=resp.text,
200
- retry_count=attempt + 1,
217
+ retry_count=busy.retries,
201
218
  )
202
- await asyncio.sleep(delay)
203
- attempt += 1
204
- continue
205
- # 退避耗尽:记录最终失败并抛出
219
+ resp.raise_for_status()
220
+ break
221
+ busy.retries += 1
222
+ # 第 1 次重试 0s(贴着限流),之后固定基数
223
+ delay = busy_delay(retry_base_delay, 0.0, busy.retries - 1)
224
+ logger.warning(
225
+ f"[AgnesImage] {resp.status_code} busy, "
226
+ f"retry #{busy.retries}/{busy.max_attempts} in {delay:.0f}s..."
227
+ )
206
228
  collect_error(
207
229
  "image", "generate_single_image",
208
230
  prompt=prompt,
209
- error_type="RateLimit429",
210
- error_message="HTTP 429: retries exhausted",
211
- status_code=429,
231
+ error_type=error_type,
232
+ error_message=(
233
+ "HTTP 429: rate limited" if resp.status_code == 429
234
+ else f"HTTP {resp.status_code}: server busy"
235
+ ),
236
+ status_code=resp.status_code,
212
237
  response_body=resp.text,
213
- retry_count=max_retries,
238
+ retry_count=busy.retries,
214
239
  )
215
- resp.raise_for_status()
216
- break
240
+ await asyncio.sleep(delay)
241
+ continue
217
242
 
218
- # 5xx 服务端错误:退避重试
243
+ # 故障轨(非忙 5xx):退避逐级加长 + max_retries 次数封顶
219
244
  if resp.status_code >= 500 and attempt < max_retries - 1:
220
- delay = retry_base_delay * (attempt + 1)
245
+ delay = fault_delay(retry_base_delay, attempt)
221
246
  logger.warning(
222
247
  f"[AgnesImage] {resp.status_code} server error, "
223
248
  f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
@@ -247,7 +272,7 @@ class AgnesImageAPI:
247
272
  exc=e, prompt=prompt, retry_count=attempt + 1,
248
273
  )
249
274
  if attempt < max_retries - 1:
250
- delay = retry_base_delay * (attempt + 1)
275
+ delay = fault_delay(retry_base_delay, attempt)
251
276
  logger.warning(
252
277
  f"[AgnesImage] {type(e).__name__}, "
253
278
  f"retry {attempt + 1}/{max_retries} in {delay:.0f}s..."
@@ -6,7 +6,6 @@ import json
6
6
  import logging
7
7
  import mimetypes
8
8
  import os
9
- import random
10
9
  import subprocess
11
10
  import time
12
11
  from typing import List, Optional
@@ -16,8 +15,16 @@ import requests
16
15
  from core.api.error_collector import collect_error, collect_error_from_exception
17
16
  from core.api.key_manager import get_key_ring
18
17
  from core.api.rate_limiter import get_rate_limiter, get_video_submit_limiter
18
+ from core.api.retry_policy import (
19
+ QUEUE_FULL_CODES,
20
+ BusyTracker,
21
+ busy_delay,
22
+ fault_delay,
23
+ is_busy_signal,
24
+ )
19
25
  from core.config import (
20
26
  get_agnes_api_root,
27
+ get_api_key_domains,
21
28
  get_base_url_for_key,
22
29
  is_v25_video_model,
23
30
  width_height_to_aspect_ratio,
@@ -35,8 +42,8 @@ DURATION_PRESETS = {
35
42
  20: (409, 24), # capped at 409 (API max for 720p); actual ~17s
36
43
  }
37
44
 
38
- # 图片上传 429 退避间隔基数(秒):delay = 基数 * (attempt + 1)
39
- _UPLOAD_RETRY_BASE_DELAY_SECONDS = 30
45
+ _UPLOAD_RETRY_BASE_DELAY_SECONDS = 30 # 上传退避基数:忙轨取固定值,故障轨取 base×(n+1)
46
+ _UPLOAD_RETRY_MAX_ATTEMPTS = 10 # v7.2 上传忙轨次数封顶(等效旧的 300s 预算 ÷ 30s)
40
47
 
41
48
 
42
49
  def _adaptive_poll_interval(interval: int, poll_count: int) -> int:
@@ -51,16 +58,23 @@ def _adaptive_poll_interval(interval: int, poll_count: int) -> int:
51
58
  return min(interval, 20 + (poll_count // 5) * 5)
52
59
 
53
60
 
54
- # ── v7.0 上游可靠性加固(docs/plans/v7.0/upstream_error_handling_plan.md)──
55
-
56
- # 提交侧「队列类」瞬时错误的 body code(U1):走独立退避轨道,不消耗普通 5xx
57
- # 的重试配额。实测 video_queue_full 可持续 12 分钟以上(连续 25 次被拒),
58
- # 而普通 5xx 退避(5 次 × 30s 递增)约 5.5 分钟就会放弃。
59
- _QUEUE_FULL_CODES = {"video_queue_full", "fail_to_fetch_task"}
61
+ # ── v7.0/v7.1 上游可靠性加固(docs/plans/v7.0/upstream_error_handling_plan.md)──
62
+ #
63
+ # 所有上游调用的重试已收敛到「忙轨 / 故障轨」两轨模型(core/api/retry_policy.py):
64
+ # - 忙轨(429 / 所有 503 / 队列类 code):首跳 0s 贴着限流(交给令牌桶),
65
+ # 之后固定间隔 + 次数封顶;
66
+ # - 故障轨(非忙 5xx / 超时 / 连接错误):base×(attempt+1) 逐级 + 次数上限。
67
+ #
68
+ # 队列类瞬时错误(U1)走忙轨的意义:实测 video_queue_full 可持续 12 分钟以上
69
+ # (连续 25 次被拒),而故障轨退避(5 次 × 30s 递增)约 5.5 分钟就会放弃。
60
70
 
61
- # 队列满退避间隔(秒):固定基数 + 随机抖动(模块级常量,便于测试缩小)
71
+ # 视频提交忙轨间隔(秒):第 1 次重试 0s(贴限流),之后为固定基数 + 随机抖动
72
+ # (模块级常量,便于测试缩小)
62
73
  _QUEUE_RETRY_BASE_DELAY = 30.0
63
74
  _QUEUE_RETRY_JITTER = 30.0
75
+ # 视频提交忙轨次数封顶(v7.2):单 Key 下 15 次 × 60s(配额下限) ≈ 旧的 900s 窗口,
76
+ # 多 Key 下每次重试更快,跑完同样次数的总耗时随 Key 数缩短。
77
+ _QUEUE_RETRY_MAX_ATTEMPTS = 15
64
78
 
65
79
 
66
80
  def _upstream_error(body) -> tuple:
@@ -93,6 +107,22 @@ def _upstream_error(body) -> tuple:
93
107
  return "", str(data)[:200]
94
108
 
95
109
 
110
+ def _register_auth_failure(ring, key: str, resp) -> None:
111
+ """把一次 401(Invalid token)上报给 KeyRing 做归因登记(v7.1)。
112
+
113
+ 判定与归因收口在 ``core/api/key_manager.py``;此处只上报事实,**不换 Key、
114
+ 不重试、不改池**——该 Key 是否删除由用户在配置页自行决定。域名后缀取自
115
+ Key 的绑定配置(''=未绑定,走全局域名),用于归因「Key 与域名不匹配」。
116
+ """
117
+ _, message = _upstream_error(resp)
118
+ ring.mark_auth_failed(
119
+ key,
120
+ status=resp.status_code,
121
+ domain=get_api_key_domains().get(key, ""),
122
+ message=message,
123
+ )
124
+
125
+
96
126
  def _needs_portrait_rotation_fix(perf_w, perf_h, cont_w, cont_h) -> bool:
97
127
  """U3:判定 2.5 系列「容器竖、像素横」的竖屏躺倒缺陷签名。
98
128
 
@@ -361,6 +391,8 @@ class AgnesVideoAPI:
361
391
  rotations = 0
362
392
  ring = get_key_ring()
363
393
  max_rotations = len(ring) * retries
394
+ # 忙轨(429 / 所有 503):首跳贴限流 + 固定间隔 + 次数封顶(core.api.retry_policy)
395
+ busy: BusyTracker | None = None
364
396
  while attempt < retries:
365
397
  if self.shutdown_event and self.shutdown_event.is_set():
366
398
  logger.info("[AgnesVideo] Image upload cancelled by shutdown")
@@ -387,20 +419,61 @@ class AgnesVideoAPI:
387
419
  json=payload,
388
420
  timeout=(30, 120),
389
421
  )
390
- if resp.status_code == 429:
391
- if ring.has_multiple() and rotations < max_rotations:
392
- rotations += 1
393
- ring.rotate()
422
+ if resp.status_code == 429 and ring.has_multiple() and rotations < max_rotations:
423
+ rotations += 1
424
+ ring.rotate()
425
+ logger.warning(
426
+ f"[KeyRotation] HTTP 429, 换 Key 立即重试 "
427
+ f"(upload, rotation {rotations})"
428
+ )
429
+ continue
430
+
431
+ code, _message = _upstream_error(resp)
432
+
433
+ # ── 忙轨(429 / 所有 503):首跳贴限流 + 固定间隔 + 次数封顶 ──
434
+ if is_busy_signal(resp.status_code, code):
435
+ if busy is None:
436
+ from core.config import get_settings
437
+ busy = BusyTracker(
438
+ get_settings().agnes_busy_retry_attempts
439
+ or _UPLOAD_RETRY_MAX_ATTEMPTS
440
+ )
441
+ if busy.exhausted():
394
442
  logger.warning(
395
- f"[KeyRotation] HTTP 429, 换 Key 立即重试 "
396
- f"(upload, rotation {rotations})"
443
+ f"[AgnesVideo] Image upload busy retries exhausted "
444
+ f"(HTTP {resp.status_code}, {busy.retries} attempts, "
445
+ f"waited {busy.waited_s:.0f}s), falling back to base64"
397
446
  )
398
- continue
399
- delay = _UPLOAD_RETRY_BASE_DELAY_SECONDS * (attempt + 1)
400
- logger.warning(f"[AgnesVideo] Image upload 429, retry in {delay}s...")
447
+ return None
448
+ busy.retries += 1
449
+ # 第 1 次重试 0s(贴着限流,交给共享令牌桶),之后固定基数
450
+ delay = busy_delay(
451
+ _UPLOAD_RETRY_BASE_DELAY_SECONDS, 0.0, busy.retries - 1
452
+ )
453
+ logger.warning(
454
+ f"[AgnesVideo] Image upload busy (HTTP {resp.status_code}"
455
+ f"{f' · {code}' if code else ''}), retry "
456
+ f"#{busy.retries}/{busy.max_attempts} in {delay:.0f}s..."
457
+ )
401
458
  await asyncio.sleep(delay)
459
+ continue
460
+
461
+ # ── 故障轨(非忙 5xx):逐级加长 + retries 次数封顶 ──
462
+ if resp.status_code >= 500:
463
+ if attempt < retries - 1:
464
+ delay = fault_delay(_UPLOAD_RETRY_BASE_DELAY_SECONDS, attempt)
465
+ logger.warning(
466
+ f"[AgnesVideo] Image upload {resp.status_code} server error, "
467
+ f"retry {attempt + 1}/{retries} in {delay:.0f}s..."
468
+ )
469
+ await asyncio.sleep(delay)
402
470
  attempt += 1
403
471
  continue
472
+
473
+ # 401:上报归因(不换 Key、不改退避节奏,随后的 raise_for_status
474
+ # 仍按原逻辑进入重试/降级路径)
475
+ if resp.status_code == 401:
476
+ _register_auth_failure(ring, key, resp)
404
477
  resp.raise_for_status()
405
478
  result = resp.json()
406
479
  data_list = result.get("data", [])
@@ -409,10 +482,15 @@ class AgnesVideoAPI:
409
482
  if url:
410
483
  logger.info(f"[AgnesVideo] Image uploaded to hosted URL: {url[:80]}...")
411
484
  return url
485
+ # 响应里没有可用 URL:计一次尝试,避免原地死循环
486
+ attempt += 1
412
487
  except Exception as e:
488
+ # ── 故障轨(连接/超时/4xx 等):逐级加长(原为写死 15s)──
413
489
  logger.warning(f"[AgnesVideo] Image upload attempt {attempt + 1}/{retries} failed: {e}")
414
490
  if attempt < retries - 1:
415
- await asyncio.sleep(15)
491
+ delay = fault_delay(_UPLOAD_RETRY_BASE_DELAY_SECONDS, attempt)
492
+ await asyncio.sleep(delay)
493
+ attempt += 1
416
494
  return None
417
495
 
418
496
  # API frame limits by resolution tier (from Agnes API error messages)
@@ -604,11 +682,9 @@ class AgnesVideoAPI:
604
682
  rotations = 0
605
683
  ring = get_key_ring()
606
684
  max_rotations = len(ring) * self.max_retries
607
- # U1:队列类 503(video_queue_full / fail_to_fetch_task)独立退避轨道
608
- # —— 不计入普通 5xx 的 max_retries 配额,预算单独可配
609
- queue_started = None # 首次命中时的时间戳(time.monotonic)
610
- queue_deadline = None # queue_started + 预算秒数
611
- queue_retries = 0
685
+ # 忙轨(429 / 所有 503 / 队列类 code):首跳贴限流 + 固定间隔 + 次数封顶,
686
+ # 不计入 max_retries 配额;次数上限首次命中时惰性读取(便于测试覆盖)
687
+ busy: BusyTracker | None = None
612
688
  while attempt < self.max_retries:
613
689
  if self.shutdown_event and self.shutdown_event.is_set():
614
690
  raise VideoTaskCancelled("Video generation cancelled by user")
@@ -636,97 +712,111 @@ class AgnesVideoAPI:
636
712
  if video_id:
637
713
  return video_id
638
714
 
639
- if resp.status_code == 429:
715
+ if resp.status_code == 429 and ring.has_multiple() and rotations < max_rotations:
640
716
  # 多 Key:换 Key 立即重试(不 sleep、不计入退避)
641
- if ring.has_multiple() and rotations < max_rotations:
642
- rotations += 1
643
- ring.rotate()
644
- logger.warning(
645
- f"[KeyRotation] HTTP 429 on submit, 换 Key 立即重试 "
646
- f"(rotation {rotations})"
647
- )
648
- continue
649
- delay = self.retry_base_delay * (attempt + 1)
717
+ rotations += 1
718
+ ring.rotate()
650
719
  logger.warning(
651
- f"[AgnesVideo] 429 rate limit on {mode_desc}, "
652
- f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."
653
- )
654
- collect_error(
655
- "video", "submit_video",
656
- prompt=payload.get("prompt", ""),
657
- error_type="RateLimit429",
658
- error_message="HTTP 429: rate limited",
659
- status_code=429,
660
- response_body=resp.text,
661
- retry_count=attempt + 1,
662
- extra={"mode": mode_desc},
720
+ f"[KeyRotation] HTTP 429 on submit, 换 Key 立即重试 "
721
+ f"(rotation {rotations})"
663
722
  )
664
- await asyncio.sleep(delay)
665
- attempt += 1
666
723
  continue
667
724
 
668
- if resp.status_code >= 500:
725
+ if resp.status_code == 429 or resp.status_code >= 500:
669
726
  # U2:解析响应体的 code/message(此前统一丢成 "server error")
670
727
  code, message = _upstream_error(resp)
671
728
 
672
- # U1:队列满走独立退避轨道(不计入普通 5xx 配额)
673
- if code in _QUEUE_FULL_CODES:
674
- if queue_deadline is None:
729
+ # ── 忙轨(429 / 所有 503 / 队列类 code)──────────────────
730
+ # 服务器忙:首跳 0s 贴着限流(节奏交给令牌桶,单 Key ≈60s、
731
+ # 多 Key 先用满各 Key 突发额度)+ 之后固定间隔 + 次数封顶。
732
+ if is_busy_signal(resp.status_code, code):
733
+ if busy is None:
675
734
  from core.config import get_settings
676
- budget = get_settings().agnes_video_queue_retry_seconds
677
- queue_started = time.monotonic()
678
- queue_deadline = queue_started + budget
735
+ attempts = (
736
+ get_settings().agnes_video_busy_retry_attempts
737
+ or _QUEUE_RETRY_MAX_ATTEMPTS
738
+ )
739
+ busy = BusyTracker(attempts)
679
740
  logger.warning(
680
- f"[AgnesVideo] {mode_desc}: Agnes video queue full "
681
- f"(HTTP {resp.status_code} · {code}), entering queue "
682
- f"retry track (budget {budget}s)"
741
+ f"[AgnesVideo] {mode_desc}: entering busy retry "
742
+ f"track (HTTP {resp.status_code}"
743
+ f"{f' · {code}' if code else ''}, "
744
+ f"max {busy.max_attempts} attempts)"
683
745
  )
684
- waited = time.monotonic() - queue_started
685
- if time.monotonic() >= queue_deadline:
746
+ waited = busy.waited_s
747
+ if resp.status_code == 429:
748
+ error_type = "RateLimit429"
749
+ error_message = "HTTP 429: rate limited"
750
+ elif code in QUEUE_FULL_CODES:
751
+ error_type = f"QueueFull_{code}"
752
+ error_message = message or f"HTTP {resp.status_code}: {code}"
753
+ else:
754
+ error_type = f"Busy{resp.status_code}"
755
+ error_message = message or f"HTTP {resp.status_code}: server busy"
756
+ if busy.exhausted():
686
757
  collect_error(
687
758
  "video", "submit_video",
688
759
  prompt=payload.get("prompt", ""),
689
- error_type=f"QueueFull_{code}",
690
- error_message=message or f"HTTP {resp.status_code}: {code}",
760
+ error_type=error_type,
761
+ error_message=error_message,
691
762
  status_code=resp.status_code,
692
763
  response_body=resp.text,
693
- retry_count=queue_retries,
764
+ retry_count=busy.retries,
694
765
  extra={
695
766
  "mode": mode_desc, "upstream_code": code,
696
767
  "waited_s": int(waited),
697
768
  },
698
769
  )
699
- # 结构化异常:用户可见文案由 UI 层按 22 语言渲染,
700
- # 此处只保留技术事实(含 HTTP 码 / body code / 等待时长)
701
- raise AgnesQueueFullError(
702
- status=resp.status_code, code=code, waited_s=int(waited),
770
+ if code in QUEUE_FULL_CODES:
771
+ # 结构化异常:用户可见文案由 UI 层按 22 语言渲染,
772
+ # 此处只保留技术事实(HTTP 码 / body code / 等待时长)
773
+ raise AgnesQueueFullError(
774
+ status=resp.status_code, code=code,
775
+ waited_s=int(waited),
776
+ )
777
+ # 429 / 裸 503 等无队列语义的「忙」:走通用技术报错
778
+ raise RuntimeError(
779
+ f"[AgnesVideo] {mode_desc}: upstream busy "
780
+ f"(HTTP {resp.status_code}"
781
+ f"{f' · {code}' if code else ''}) after "
782
+ f"{busy.retries} retries"
783
+ f"{f' / {int(waited)}s' if waited else ''}; "
784
+ f"no job was created"
703
785
  )
704
- delay = _QUEUE_RETRY_BASE_DELAY + random.uniform(0, _QUEUE_RETRY_JITTER)
705
- queue_retries += 1
786
+ busy.retries += 1
787
+ # 第 1 次重试 0s(贴着限流),之后固定基数 + 抖动
788
+ delay = busy_delay(
789
+ _QUEUE_RETRY_BASE_DELAY, _QUEUE_RETRY_JITTER,
790
+ busy.retries - 1,
791
+ )
706
792
  logger.warning(
707
- f"[AgnesVideo] Agnes video queue full on {mode_desc} "
708
- f"(HTTP {resp.status_code} · {code}), queue retry "
709
- f"#{queue_retries} (waited {waited:.0f}s) in {delay:.0f}s..."
793
+ f"[AgnesVideo] {mode_desc}: upstream busy "
794
+ f"(HTTP {resp.status_code}"
795
+ f"{f' · {code}' if code else ''}), busy retry "
796
+ f"#{busy.retries}/{busy.max_attempts} "
797
+ f"(waited {waited:.0f}s) in {delay:.0f}s..."
710
798
  )
711
799
  collect_error(
712
800
  "video", "submit_video",
713
801
  prompt=payload.get("prompt", ""),
714
- error_type=f"QueueFull_{code}",
715
- error_message=message or f"HTTP {resp.status_code}: {code}",
802
+ error_type=error_type,
803
+ error_message=error_message,
716
804
  status_code=resp.status_code,
717
805
  response_body=resp.text,
718
- retry_count=queue_retries,
806
+ retry_count=busy.retries,
719
807
  extra={
720
808
  "mode": mode_desc, "upstream_code": code,
721
809
  "waited_s": int(waited),
722
810
  },
723
811
  )
724
- if progress_callback:
812
+ # 仅队列类信号下发进度回调:前端据此渲染「Agnes 队列已满」
813
+ # 文案,429 / 裸 503 无队列语义,不误用该 key。
814
+ if progress_callback and code in QUEUE_FULL_CODES:
725
815
  try:
726
816
  # (stage, payload):payload 带原样报错(HTTP 码 + body code)
727
817
  # 与等待信息,供前端拼出「Agnes 队列已满 + 原始报错 + 错峰建议」
728
818
  progress_callback("queue_full", {
729
- "attempt": queue_retries,
819
+ "attempt": busy.retries,
730
820
  "waited_s": waited,
731
821
  "status": resp.status_code,
732
822
  "code": code,
@@ -740,8 +830,9 @@ class AgnesVideoAPI:
740
830
  await asyncio.sleep(delay)
741
831
  continue
742
832
 
743
- # 普通 5xx:沿用 5 次退避配额
744
- delay = self.retry_base_delay * (attempt + 1)
833
+ # ── 故障轨(非忙 5xx)──────────────────────────────────
834
+ # 服务侧故障:间隔 base×(attempt+1) 逐级加长 + max_retries 次数封顶
835
+ delay = fault_delay(self.retry_base_delay, attempt)
745
836
  logger.warning(
746
837
  f"[AgnesVideo] {resp.status_code} server error on {mode_desc}"
747
838
  f"{f' (code={code})' if code else ''}, "
@@ -791,6 +882,11 @@ class AgnesVideoAPI:
791
882
  frame_reductions_left -= 1
792
883
  continue
793
884
 
885
+ # 401:认证失败只做归因登记(收口在 KeyRing),失败行为保持不变
886
+ # —— 不换 Key、不重试,Key 是否删除由用户在配置页决定
887
+ if resp.status_code == 401:
888
+ _register_auth_failure(ring, key, resp)
889
+
794
890
  logger.error(f"[AgnesVideo] HTTP {resp.status_code}: {error_text}")
795
891
  collect_error(
796
892
  "video", "submit_video",
@@ -814,7 +910,7 @@ class AgnesVideoAPI:
814
910
  extra={"mode": mode_desc},
815
911
  )
816
912
  if attempt < self.max_retries - 1:
817
- delay = self.retry_base_delay * (attempt + 1)
913
+ delay = fault_delay(self.retry_base_delay, attempt)
818
914
  logger.warning(
819
915
  f"[AgnesVideo] {type(e).__name__} on {mode_desc}, "
820
916
  f"retry {attempt + 1}/{self.max_retries} in {delay:.0f}s..."