mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 00:11:27 -03:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "לא נבחרו מתכונים",
|
||||
|
||||
@@ -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": "レシピが選択されていません",
|
||||
|
||||
@@ -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": "선택한 레시피가 없습니다",
|
||||
|
||||
@@ -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": "Рецепты не выбраны",
|
||||
|
||||
@@ -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": "未选择任何配方",
|
||||
|
||||
@@ -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": "未選取任何食譜",
|
||||
|
||||
@@ -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,9 +470,18 @@ class BatchImportService:
|
||||
|
||||
except Exception as 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.error_message = str(e)
|
||||
item.duration = time.time() - start_time
|
||||
progress.failed += 1
|
||||
self._concurrency_controller.record_result(item.duration, False)
|
||||
await self._concurrency_controller.apply_concurrency()
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user