free-short-video 7.0.5 → 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.
- package/README.md +14 -5
- package/core/api/agnes_image.py +50 -25
- package/core/api/agnes_video.py +177 -81
- package/core/api/key_manager.py +76 -0
- package/core/api/rate_limiter.py +37 -14
- package/core/api/retry_policy.py +124 -0
- package/core/config.py +12 -7
- package/models/task.py +3 -3
- package/package.json +1 -1
- package/static/assets/{ar-BGwnHyw2.js → ar-C1KWUlNh.js} +1 -1
- package/static/assets/{bn-BZ1SVmZv.js → bn-C4SfxNGN.js} +1 -1
- package/static/assets/de-B4OvLzBp.js +1 -0
- package/static/assets/es-IQACehBJ.js +1 -0
- package/static/assets/{fa-DTgnsV-R.js → fa-BzLk3cXs.js} +1 -1
- package/static/assets/fr-BMhNx-iQ.js +1 -0
- package/static/assets/{hi-CiRTbxUR.js → hi-B7Vaoj8j.js} +1 -1
- package/static/assets/id-B5vxBOhd.js +1 -0
- package/static/assets/index-BYtvF5AK.js +46 -0
- package/static/assets/index-X0RXgNf5.css +1 -0
- package/static/assets/it-DwDZ9skF.js +1 -0
- package/static/assets/ja-DyBi5ySN.js +1 -0
- package/static/assets/ko-DtnJDAHo.js +1 -0
- package/static/assets/ms-CCjt7pyM.js +1 -0
- package/static/assets/nl-Bqpu5-or.js +1 -0
- package/static/assets/pt-DzUv3ga-.js +1 -0
- package/static/assets/{ru-CYIQhw4j.js → ru-CMGJcpUZ.js} +1 -1
- package/static/assets/{th-BHjOht4_.js → th-DnlP-4Ac.js} +1 -1
- package/static/assets/tl-BzNuvuWV.js +1 -0
- package/static/assets/tr-Ca1gT8-V.js +1 -0
- package/static/assets/{ur-CNslvoMb.js → ur-DMIuAGiE.js} +1 -1
- package/static/assets/vi-BURrm61d.js +1 -0
- package/static/index.html +2 -2
- package/web/routes/config_routes.py +36 -13
- package/web/routes/task_creation_routes.py +4 -4
- package/static/assets/de-CUKZPeV-.js +0 -1
- package/static/assets/es-DKx-teoF.js +0 -1
- package/static/assets/fr-QDOIUFKu.js +0 -1
- package/static/assets/id-DVJD5Y5W.js +0 -1
- package/static/assets/index-D9sLMHUd.css +0 -1
- package/static/assets/index-KzCkI4Os.js +0 -45
- package/static/assets/it-BsQ3SshU.js +0 -1
- package/static/assets/ja-D9O41FnB.js +0 -1
- package/static/assets/ko-BH0PhAjf.js +0 -1
- package/static/assets/ms-qcRbwWeG.js +0 -1
- package/static/assets/nl-BuXaX-G6.js +0 -1
- package/static/assets/pt-CC3c_p8Q.js +0 -1
- package/static/assets/tl-CLPCfL8g.js +0 -1
- package/static/assets/tr-_PXfKikt.js +0 -1
- package/static/assets/vi-BCoL1tPB.js +0 -1
package/README.md
CHANGED
|
@@ -1,21 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
|
|
3
|
-
# What's New in v7.0
|
|
3
|
+
# What's New in v7.1.0
|
|
4
4
|
|
|
5
5
|
## What's New
|
|
6
6
|
|
|
7
7
|
### Features & Improvements
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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.
|
|
15
24
|
|
|
16
25
|
---
|
|
17
26
|
|
|
18
|
-
|
|
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).
|
|
19
28
|
|
|
20
29
|
---
|
|
21
30
|
|
package/core/api/agnes_image.py
CHANGED
|
@@ -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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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=
|
|
197
|
-
error_message=
|
|
198
|
-
|
|
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=
|
|
217
|
+
retry_count=busy.retries,
|
|
201
218
|
)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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=
|
|
210
|
-
error_message=
|
|
211
|
-
|
|
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=
|
|
238
|
+
retry_count=busy.retries,
|
|
214
239
|
)
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
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
|
|
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..."
|
package/core/api/agnes_video.py
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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"[
|
|
396
|
-
f"(
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
608
|
-
#
|
|
609
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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"[
|
|
652
|
-
f"
|
|
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
|
-
#
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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}:
|
|
681
|
-
f"(HTTP {resp.status_code}
|
|
682
|
-
f"
|
|
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 =
|
|
685
|
-
if
|
|
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=
|
|
690
|
-
error_message=
|
|
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=
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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
|
-
|
|
705
|
-
|
|
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]
|
|
708
|
-
f"(HTTP {resp.status_code}
|
|
709
|
-
f"
|
|
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=
|
|
715
|
-
error_message=
|
|
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=
|
|
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
|
-
|
|
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":
|
|
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
|
-
#
|
|
744
|
-
|
|
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
|
|
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..."
|