From df34efafbc604fa81fbd58f09f723842a73dadfd Mon Sep 17 00:00:00 2001 From: Will Miao Date: Thu, 27 Aug 2026 10:08:32 +0800 Subject: [PATCH] feat(recipes): skip rate-limited batch-import items and register download 429s (#1085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/issue-1085-rate-limit-design.md | 12 ++- locales/de.json | 3 + locales/en.json | 3 + locales/es.json | 3 + locales/fr.json | 3 + locales/he.json | 3 + locales/ja.json | 3 + locales/ko.json | 3 + locales/ru.json | 3 + locales/zh-CN.json | 3 + locales/zh-TW.json | 3 + py/services/batch_import_service.py | 37 ++++++- py/services/downloader.py | 28 +++++ static/js/managers/BatchImportManager.js | 10 +- tests/services/test_batch_import_service.py | 100 ++++++++++++++++++ tests/services/test_rate_limit_coordinator.py | 65 +++++++++++- 16 files changed, 273 insertions(+), 9 deletions(-) diff --git a/docs/plans/issue-1085-rate-limit-design.md b/docs/plans/issue-1085-rate-limit-design.md index 46393d87..c82fb9c5 100644 --- a/docs/plans/issue-1085-rate-limit-design.md +++ b/docs/plans/issue-1085-rate-limit-design.md @@ -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) **Status:** v2 — reviewed; decisions recorded in §10. **Phase 1 implemented** -(2026-08-27): coordinator + downloader gate + Fix C failover semantics + -helper double-wait fix + settings; full regression 2385 passed. Changes vs v1: -Fix C moved to Phase 1, helper double-wait resolved in Phase 1, gate/guard -ordering specified, WebSocket slowdown hint confirmed in scope (Phase 2). +(2026-08-27, commit `c2a2048c`): coordinator + downloader gate + Fix C +failover semantics + helper double-wait fix + settings. **Phase 2 +implemented** (2026-08-27): batch-import rate-limit failures map to +`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*. > Context: a first batch of fixes for this issue was already committed as diff --git a/locales/de.json b/locales/de.json index 91cf7c74..4ad2ca78 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "Keine Rezepte ausgewählt", diff --git a/locales/en.json b/locales/en.json index 5c1cc8c1..228ef318 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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", "total": "Total", "success": "Success", @@ -2038,6 +2040,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "No recipes selected", diff --git a/locales/es.json b/locales/es.json index b6c412f3..391414f9 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "No se han seleccionado recetas", diff --git a/locales/fr.json b/locales/fr.json index 582bcb16..6e9f1eb0 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "Aucune recette sélectionnée", diff --git a/locales/he.json b/locales/he.json index 69406420..573e2024 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "לא נבחרו מתכונים", diff --git a/locales/ja.json b/locales/ja.json index 874af76d..26a67532 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "レシピが選択されていません", diff --git a/locales/ko.json b/locales/ko.json index a33811dd..67b25feb 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "선택한 레시피가 없습니다", diff --git a/locales/ru.json b/locales/ru.json index e14cb4cc..9b527020 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1033,6 +1033,8 @@ "start": "Start Import", "startImport": "Start Import", "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": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file 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}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "Рецепты не выбраны", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 7c47be49..db012a63 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1033,6 +1033,8 @@ "start": "开始导入", "startImport": "开始导入", "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": "进度", "total": "总计", "success": "成功", @@ -2038,6 +2040,7 @@ "batchImportCancelFailed": "取消批量导入失败:{message}", "batchImportNoUrls": "请输入至少一个 URL 或文件路径", "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}", "batchImportDirectorySelected": "已选择目录:{path}", "noRecipesSelected": "未选择任何配方", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 39773d12..45f3f7a3 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1033,6 +1033,8 @@ "start": "開始匯入", "startImport": "開始匯入", "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": "進度", "total": "總計", "success": "成功", @@ -2038,6 +2040,7 @@ "batchImportCancelFailed": "取消批量匯入失敗:{message}", "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑", "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}", "batchImportDirectorySelected": "已選擇目錄:{path}", "noRecipesSelected": "未選取任何食譜", diff --git a/py/services/batch_import_service.py b/py/services/batch_import_service.py index 7906ad42..b5f9ec4b 100644 --- a/py/services/batch_import_service.py +++ b/py/services/batch_import_service.py @@ -71,6 +71,9 @@ class BatchImportProgress: tags: List[str] = field(default_factory=list) skip_no_metadata: 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]: return { @@ -82,6 +85,7 @@ class BatchImportProgress: "skipped": self.skipped, "current_item": self.current_item, "status": self.status, + "rate_limited": self.rate_limited, "started_at": self.started_at, "finished_at": self.finished_at, "progress_percent": round((self.completed / self.total) * 100, 1) @@ -383,6 +387,13 @@ class BatchImportService: ext = os.path.splitext(filename)[1].lower() 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( self, *, @@ -441,6 +452,17 @@ class BatchImportService: item.status = ImportStatus.SKIPPED item.error_message = result.get("error") 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: item.status = ImportStatus.FAILED item.error_message = result.get("error") @@ -448,10 +470,19 @@ class BatchImportService: except Exception as e: self._logger.error(f"Error importing {item.source}: {e}") - item.status = ImportStatus.FAILED - item.error_message = str(e) item.duration = time.time() - start_time - progress.failed += 1 + 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.error_message = str(e) + progress.failed += 1 self._concurrency_controller.record_result(item.duration, False) await self._concurrency_controller.apply_concurrency() diff --git a/py/services/downloader.py b/py/services/downloader.py index 16211942..94be53a7 100644 --- a/py/services/downloader.py +++ b/py/services/downloader.py @@ -596,6 +596,21 @@ class Downloader: False, "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: logger.error( f"Download failed for {url} with status {response.status}" @@ -973,6 +988,11 @@ class Downloader: elif response.status == 429: raw_retry_after = response.headers.get("Retry-After") 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: logger.warning( "Rate limited (429) for %s, Retry-After: %ss", url, retry_after @@ -1042,6 +1062,14 @@ class Downloader: if response.status == 200: guard.register_success(destination) 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: return False, f"Head request failed with status {response.status}" diff --git a/static/js/managers/BatchImportManager.js b/static/js/managers/BatchImportManager.js index 3631eebf..8a3f166c 100644 --- a/static/js/managers/BatchImportManager.js +++ b/static/js/managers/BatchImportManager.js @@ -427,6 +427,12 @@ export class BatchImportManager { this.progress = 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), // so per-second polling does not spam the console with identical lines. const changed = @@ -495,7 +501,9 @@ export class BatchImportManager { const statusText = document.getElementById('batchStatusText'); if (statusText) { 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') { statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed'); } else if (progress.status === 'cancelled') { diff --git a/tests/services/test_batch_import_service.py b/tests/services/test_batch_import_service.py index 3cd8e4f7..9232a4eb 100644 --- a/tests/services/test_batch_import_service.py +++ b/tests/services/test_batch_import_service.py @@ -655,3 +655,103 @@ class TestInputValidation: assert service._validate_local_path("../etc/passwd") is False assert service._validate_local_path("relative/path.png") 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 diff --git a/tests/services/test_rate_limit_coordinator.py b/tests/services/test_rate_limit_coordinator.py index a557c3d8..c4df4ebe 100644 --- a/tests/services/test_rate_limit_coordinator.py +++ b/tests/services/test_rate_limit_coordinator.py @@ -188,6 +188,12 @@ class _FakeSession: assert self._responses, "unexpected extra request" 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): return None @@ -310,7 +316,13 @@ def _stub_provider(*, result=None, error=None, exc: Exception | None = None): 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)) civarchive = _stub_provider(result={"id": 1}, error=None) 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) 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