feat(recipes): skip rate-limited batch-import items and register download 429s (#1085)

Phase 2 of docs/plans/issue-1085-rate-limit-design.md:

- Batch import: items that fail due to vendor rate limiting are now
  SKIPPED with a "re-run the import later" hint instead of FAILED, so a
  transient 429 no longer pollutes failure accounting; the progress
  broadcast carries a rate_limited flag.
- Batch import UI: show a one-time "rate limited — slowing down" toast
  and swap the running status text while rate_limited; i18n keys synced
  to all locales.
- Downloader: download_file / download_to_memory / get_response_headers
  register 429 cooldowns with the RateLimitCoordinator, so subsequent
  API calls queue behind a download-triggered rate-limit window.
This commit is contained in:
Will Miao
2026-08-27 10:08:32 +08:00
parent c2a2048c8b
commit df34efafbc
16 changed files with 273 additions and 9 deletions
+8 -4
View File
@@ -2,10 +2,14 @@
**Issue:** [#1085 — Large Recipe Ingest Appears to not abide by vendor rate limits, possibly a few other errors?](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1085) **Issue:** [#1085 — Large Recipe Ingest Appears to not abide by vendor rate limits, possibly a few other errors?](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1085)
**Status:** v2 — reviewed; decisions recorded in §10. **Phase 1 implemented** **Status:** v2 — reviewed; decisions recorded in §10. **Phase 1 implemented**
(2026-08-27): coordinator + downloader gate + Fix C failover semantics + (2026-08-27, commit `c2a2048c`): coordinator + downloader gate + Fix C
helper double-wait fix + settings; full regression 2385 passed. Changes vs v1: failover semantics + helper double-wait fix + settings. **Phase 2
Fix C moved to Phase 1, helper double-wait resolved in Phase 1, gate/guard implemented** (2026-08-27): batch-import rate-limit failures map to
ordering specified, WebSocket slowdown hint confirmed in scope (Phase 2). `SKIPPED` + `rate_limited` WebSocket flag + UI slowdown hint (toast + status
text, i18n keys synced); `download_to_memory` / `get_response_headers` /
`download_file` register 429 cooldowns. Changes vs v1: Fix C moved to
Phase 1, helper double-wait resolved in Phase 1, gate/guard ordering
specified.
**Scope:** HTTP API traffic to CivitAI (`civitai.red`) and CivArchive (`civarchive.com`) from metadata fetching (bulk refresh, metadata sync, recipe analysis/enrichment, usage-control lookups). Large binary downloads (model files / preview images via `download_file`) are out of scope for *pacing* (they are already single-connection transfers) but their 429 responses should still be *registered*. **Scope:** HTTP API traffic to CivitAI (`civitai.red`) and CivArchive (`civarchive.com`) from metadata fetching (bulk refresh, metadata sync, recipe analysis/enrichment, usage-control lookups). Large binary downloads (model files / preview images via `download_file`) are out of scope for *pacing* (they are already single-connection transfers) but their 429 responses should still be *registered*.
> Context: a first batch of fixes for this issue was already committed as > Context: a first batch of fixes for this issue was already committed as
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt", "noRecipesSelected": "Keine Rezepte ausgewählt",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "Rate limited — slowing down...",
"rateLimitedHint": "Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected", "noRecipesSelected": "No recipes selected",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas", "noRecipesSelected": "No se han seleccionado recetas",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée", "noRecipesSelected": "Aucune recette sélectionnée",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים", "noRecipesSelected": "לא נבחרו מתכונים",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません", "noRecipesSelected": "レシピが選択されていません",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다", "noRecipesSelected": "선택한 레시피가 없습니다",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны", "noRecipesSelected": "Рецепты не выбраны",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "开始导入", "start": "开始导入",
"startImport": "开始导入", "startImport": "开始导入",
"importing": "正在导入配方...", "importing": "正在导入配方...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "进度", "progress": "进度",
"total": "总计", "total": "总计",
"success": "成功", "success": "成功",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "取消批量导入失败:{message}", "batchImportCancelFailed": "取消批量导入失败:{message}",
"batchImportNoUrls": "请输入至少一个 URL 或文件路径", "batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "请输入目录路径", "batchImportNoDirectory": "请输入目录路径",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}", "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方", "noRecipesSelected": "未选择任何配方",
+3
View File
@@ -1033,6 +1033,8 @@
"start": "開始匯入", "start": "開始匯入",
"startImport": "開始匯入", "startImport": "開始匯入",
"importing": "匯入中...", "importing": "匯入中...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "進度", "progress": "進度",
"total": "總計", "total": "總計",
"success": "成功", "success": "成功",
@@ -2038,6 +2040,7 @@
"batchImportCancelFailed": "取消批量匯入失敗:{message}", "batchImportCancelFailed": "取消批量匯入失敗:{message}",
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑", "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "請輸入目錄路徑", "batchImportNoDirectory": "請輸入目錄路徑",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}", "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜", "noRecipesSelected": "未選取任何食譜",
+32 -1
View File
@@ -71,6 +71,9 @@ class BatchImportProgress:
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
skip_no_metadata: bool = False skip_no_metadata: bool = False
skip_duplicates: bool = False skip_duplicates: bool = False
# Set once any item is skipped due to vendor rate limiting (#1085); lets
# the UI surface a "slowing down / try again later" hint.
rate_limited: bool = False
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
return { return {
@@ -82,6 +85,7 @@ class BatchImportProgress:
"skipped": self.skipped, "skipped": self.skipped,
"current_item": self.current_item, "current_item": self.current_item,
"status": self.status, "status": self.status,
"rate_limited": self.rate_limited,
"started_at": self.started_at, "started_at": self.started_at,
"finished_at": self.finished_at, "finished_at": self.finished_at,
"progress_percent": round((self.completed / self.total) * 100, 1) "progress_percent": round((self.completed / self.total) * 100, 1)
@@ -383,6 +387,13 @@ class BatchImportService:
ext = os.path.splitext(filename)[1].lower() ext = os.path.splitext(filename)[1].lower()
return ext in self.SUPPORTED_EXTENSIONS return ext in self.SUPPORTED_EXTENSIONS
@staticmethod
def _is_rate_limit_error(error: Optional[str]) -> bool:
"""Return True when an error payload represents vendor rate limiting."""
if not error:
return False
return "rate limit" in error.lower()
async def _run_batch_import( async def _run_batch_import(
self, self,
*, *,
@@ -441,6 +452,17 @@ class BatchImportService:
item.status = ImportStatus.SKIPPED item.status = ImportStatus.SKIPPED
item.error_message = result.get("error") item.error_message = result.get("error")
progress.skipped += 1 progress.skipped += 1
elif self._is_rate_limit_error(result.get("error")):
# Vendor rate limit is a transient, external condition —
# do not pollute the failure count with it (#1085). The
# import can simply be re-run later.
item.status = ImportStatus.SKIPPED
item.error_message = (
f"Rate limited by metadata provider; "
f"re-run the import later ({result.get('error')})"
)
progress.skipped += 1
progress.rate_limited = True
else: else:
item.status = ImportStatus.FAILED item.status = ImportStatus.FAILED
item.error_message = result.get("error") item.error_message = result.get("error")
@@ -448,9 +470,18 @@ class BatchImportService:
except Exception as e: except Exception as e:
self._logger.error(f"Error importing {item.source}: {e}") self._logger.error(f"Error importing {item.source}: {e}")
item.duration = time.time() - start_time
if self._is_rate_limit_error(str(e)):
item.status = ImportStatus.SKIPPED
item.error_message = (
f"Rate limited by metadata provider; "
f"re-run the import later ({e})"
)
progress.skipped += 1
progress.rate_limited = True
else:
item.status = ImportStatus.FAILED item.status = ImportStatus.FAILED
item.error_message = str(e) item.error_message = str(e)
item.duration = time.time() - start_time
progress.failed += 1 progress.failed += 1
self._concurrency_controller.record_result(item.duration, False) self._concurrency_controller.record_result(item.duration, False)
await self._concurrency_controller.apply_concurrency() await self._concurrency_controller.apply_concurrency()
+28
View File
@@ -596,6 +596,21 @@ class Downloader:
False, False,
"File not found - the download link may be invalid or expired.", "File not found - the download link may be invalid or expired.",
) )
elif response.status == 429:
# Register the vendor's cooldown so API calls through
# make_request queue behind it (#1085). The download
# itself fails as before; retry policy stays with the
# caller (download manager).
retry_after = self._extract_retry_after(response.headers)
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(
self._guard_destination(url), retry_after
)
logger.warning(
f"Rate limited (429) for {url}, retry_after={retry_after}"
)
return False, f"Download rate limited (429), retry after {retry_after}s"
else: else:
logger.error( logger.error(
f"Download failed for {url} with status {response.status}" f"Download failed for {url} with status {response.status}"
@@ -973,6 +988,11 @@ class Downloader:
elif response.status == 429: elif response.status == 429:
raw_retry_after = response.headers.get("Retry-After") raw_retry_after = response.headers.get("Retry-After")
retry_after = _parse_retry_after(raw_retry_after or "") retry_after = _parse_retry_after(raw_retry_after or "")
# Register the vendor's cooldown so API calls through
# make_request queue behind it (#1085).
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(destination, retry_after)
if raw_retry_after: if raw_retry_after:
logger.warning( logger.warning(
"Rate limited (429) for %s, Retry-After: %ss", url, retry_after "Rate limited (429) for %s, Retry-After: %ss", url, retry_after
@@ -1042,6 +1062,14 @@ class Downloader:
if response.status == 200: if response.status == 200:
guard.register_success(destination) guard.register_success(destination)
return True, dict(response.headers) return True, dict(response.headers)
elif response.status == 429:
# Register the vendor's cooldown so API calls through
# make_request queue behind it (#1085).
retry_after = self._extract_retry_after(response.headers)
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(destination, retry_after)
return False, f"Head request rate limited (429), retry after {retry_after}s"
else: else:
return False, f"Head request failed with status {response.status}" return False, f"Head request failed with status {response.status}"
+9 -1
View File
@@ -427,6 +427,12 @@ export class BatchImportManager {
this.progress = progress; this.progress = progress;
this.updateProgressUI(progress); this.updateProgressUI(progress);
// Surface vendor rate limiting once per import (#1085): requests are
// being paced and some items may be skipped rather than failed.
if (progress.rate_limited && !(prev && prev.rate_limited)) {
showToast('toast.recipes.batchImportRateLimited', {}, 'warning');
}
// Only log when something actually changed (and on the first update), // Only log when something actually changed (and on the first update),
// so per-second polling does not spam the console with identical lines. // so per-second polling does not spam the console with identical lines.
const changed = const changed =
@@ -495,7 +501,9 @@ export class BatchImportManager {
const statusText = document.getElementById('batchStatusText'); const statusText = document.getElementById('batchStatusText');
if (statusText) { if (statusText) {
if (progress.status === 'running') { if (progress.status === 'running') {
statusText.textContent = translate('recipes.batchImport.importing', {}, 'Importing...'); statusText.textContent = progress.rate_limited
? translate('recipes.batchImport.rateLimitedSlowdown', {}, 'Rate limited — slowing down...')
: translate('recipes.batchImport.importing', {}, 'Importing...');
} else if (progress.status === 'completed') { } else if (progress.status === 'completed') {
statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed'); statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.status === 'cancelled') { } else if (progress.status === 'cancelled') {
+100
View File
@@ -655,3 +655,103 @@ class TestInputValidation:
assert service._validate_local_path("../etc/passwd") is False assert service._validate_local_path("../etc/passwd") is False
assert service._validate_local_path("relative/path.png") is False assert service._validate_local_path("relative/path.png") is False
assert service._validate_local_path("") is False assert service._validate_local_path("") is False
class TestRateLimitSkipMapping:
"""#1085: vendor rate limiting must mark items SKIPPED, not FAILED."""
@pytest.fixture
def mock_services(self):
ws_manager = MockWebSocketManager()
persistence_service = MockPersistenceService()
logger = logging.getLogger("test")
return ws_manager, persistence_service, logger
def test_is_rate_limit_error_matching(self):
assert BatchImportService._is_rate_limit_error("Rate limited") is True
assert BatchImportService._is_rate_limit_error(
"Rate limit wait for 'civarchive.com' exceeds the 300s cap"
) is True
assert BatchImportService._is_rate_limit_error("Request rate limited") is True
assert BatchImportService._is_rate_limit_error("No metadata found") is False
assert BatchImportService._is_rate_limit_error(None) is False
assert BatchImportService._is_rate_limit_error("") is False
@pytest.mark.asyncio
async def test_rate_limited_item_becomes_skipped_and_sets_flag(self, mock_services):
ws_manager, persistence_service, logger = mock_services
analysis_service = MockAnalysisService(
{
"https://example.com/limited.png": MockAnalysisResult(
{"error": "Rate limited"}
),
}
)
service = BatchImportService(
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
operation_id = await service.start_batch_import(
recipe_scanner_getter=lambda: SimpleNamespace(),
civitai_client_getter=lambda: SimpleNamespace(),
items=[{"source": "https://example.com/limited.png"}],
)
await asyncio.sleep(0.5)
# The operation may already be cleaned up; inspect the broadcasts.
final = next(
(
b
for b in reversed(ws_manager.broadcasts)
if b.get("type") == "batch_import_progress"
),
None,
)
assert final is not None
assert final["rate_limited"] is True
assert final["skipped"] == 1
assert final["failed"] == 0
item = final["items"][0]
assert item["status"] == "skipped"
assert "re-run the import later" in item["error_message"]
assert service.get_progress(operation_id) is None or True
@pytest.mark.asyncio
async def test_non_rate_limit_error_stays_failed(self, mock_services):
ws_manager, persistence_service, logger = mock_services
analysis_service = MockAnalysisService(
{
"https://example.com/broken.png": MockAnalysisResult(
{"error": "No metadata found"}
),
}
)
service = BatchImportService(
analysis_service=analysis_service, # pyright: ignore[reportArgumentType]
persistence_service=persistence_service,
ws_manager=ws_manager,
logger=logger,
)
await service.start_batch_import(
recipe_scanner_getter=lambda: SimpleNamespace(),
civitai_client_getter=lambda: SimpleNamespace(),
items=[{"source": "https://example.com/broken.png"}],
)
await asyncio.sleep(0.5)
final = next(
(
b
for b in reversed(ws_manager.broadcasts)
if b.get("type") == "batch_import_progress"
),
None,
)
assert final is not None
assert final["rate_limited"] is False
assert final["failed"] == 1
assert final["skipped"] == 0
+64 -1
View File
@@ -188,6 +188,12 @@ class _FakeSession:
assert self._responses, "unexpected extra request" assert self._responses, "unexpected extra request"
return self._responses.pop(0) return self._responses.pop(0)
def get(self, url, headers=None, **kwargs):
return self.request("GET", url, headers=headers, **kwargs)
def head(self, url, headers=None, **kwargs):
return self.request("HEAD", url, headers=headers, **kwargs)
async def close(self): async def close(self):
return None return None
@@ -310,7 +316,13 @@ def _stub_provider(*, result=None, error=None, exc: Exception | None = None):
return SimpleNamespace(get_model_by_hash=call) return SimpleNamespace(get_model_by_hash=call)
async def test_fallback_does_not_fail_over_to_network_provider_on_429(): async def test_fallback_does_not_fail_over_to_network_provider_on_429(monkeypatch):
# The stub error is not gate_handled, so the retry helper would sleep
# retry_after between attempts; patch it out (the helper's own behavior
# is covered by the double-wait tests below).
monkeypatch.setattr(
"py.services.model_metadata_provider.asyncio.sleep", AsyncMock()
)
civitai = _stub_provider(exc=RateLimitError("limited", retry_after=30)) civitai = _stub_provider(exc=RateLimitError("limited", retry_after=30))
civarchive = _stub_provider(result={"id": 1}, error=None) civarchive = _stub_provider(result={"id": 1}, error=None)
sqlite = _stub_provider(result=None, error="not in archive") sqlite = _stub_provider(result=None, error="not in archive")
@@ -398,3 +410,54 @@ async def test_retry_helper_keeps_legacy_retry_for_ungated_errors():
await helper.run("civitai_api", failing) await helper.run("civitai_api", failing)
assert calls == 2 # legacy retry behavior unchanged assert calls == 2 # legacy retry behavior unchanged
# ----------------------------------------------------------------------
# Download-path 429 registration (Phase 2)
class _FakeDownloadResponse(_FakeResponse):
async def read(self):
return b"data"
async def test_download_to_memory_429_registers_cooldown(monkeypatch):
_patch_gate_settings(
monkeypatch,
rate_limit_gate_enabled=True,
rate_limit_min_interval_seconds=0.0,
)
downloader = _build_downloader(
[_FakeDownloadResponse(429, headers={"Retry-After": "120"})]
)
success, error, _ = await downloader.download_to_memory(
"https://api.example.com/preview.png"
)
assert success is False
assert "Rate limited" in error
coordinator = await RateLimitCoordinator.get_instance()
remaining = coordinator.remaining_seconds("api.example.com")
assert 110.0 < remaining <= 120.0
async def test_get_response_headers_429_registers_cooldown(monkeypatch):
_patch_gate_settings(
monkeypatch,
rate_limit_gate_enabled=True,
rate_limit_min_interval_seconds=0.0,
)
downloader = _build_downloader(
[_FakeResponse(429, headers={"Retry-After": "60"})]
)
success, error = await downloader.get_response_headers(
"https://api.example.com/model/file.safetensors"
)
assert success is False
assert "rate limited" in error.lower()
coordinator = await RateLimitCoordinator.get_instance()
remaining = coordinator.remaining_seconds("api.example.com")
assert 50.0 < remaining <= 60.0