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.
19 KiB
Plan: Global Rate-Limit Abidance for Recipe Ingest & Metadata Fetching
Issue: #1085 — Large Recipe Ingest Appears to not abide by vendor rate limits, possibly a few other errors?
Status: v2 — reviewed; decisions recorded in §10. Phase 1 implemented
(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
ee233548("fix(recipes): enforce batch-import concurrency bound and harden ingest errors (#1085)"): the batch-import concurrency controller now shares a real semaphore (bounds 1–5 actually apply), the Comfy parser tolerates list/Noneckpt_name, CivArchive treats empty error payloads as failures, and offline-cooldown short-circuits log at DEBUG. This plan covers the two remaining orchestration-level fixes: Fix 2 — slow down globally when a vendor rate limit is hit (respectRetry-After, queue instead of hammering); Fix 3 — stop immediately failing over to CivArchive when CivitAI is rate-limited.
1. Problem Statement
During a large recipe ingest (e.g. importing the example-images directory,
which can be thousands of images), the manager fires one metadata request per
checkpoint + per LoRA per image through the fallback provider chain
(civitai_api → civarchive_api → sqlite). Consequences observed in #1085:
- CivitAI gets hammered → 429s. The consumer then immediately tries CivArchive for the same lookup, so CivArchive gets hammered too before it was ever naturally needed (its only real job is recovering metadata for models deleted from CivitAI).
- Requests are retried per-call after
Retry-After, but each concurrent call sleeps independently → thundering herd: thousands of coroutines wake at the same moment and re-flood the vendor. - While CivArchive is in the
ConnectivityGuardcooldown, every batch item short-circuits and is markedFAILED— the batch import's success/failure accounting is polluted by a transient vendor state (log spam was fixed inee233548; the item-failure accounting is not). ConnectivityGuard(py/services/connectivity_guard.py) only treats transport-level unreachability as offline; HTTP 429 is invisible to it, so nothing ever intentionally paces request rate.
User expectation from the issue: "once a vendor rate limit time out is hit, you should trigger a slow down with intentional reduction in request rate".
2. Current State (verified against code)
2.1 Where 429s are surfaced
Downloader.make_request(py/services/downloader.py:1120-1132): HTTP 429 → returnsRateLimitError(message, retry_after=…)parsed fromRetry-After(missing header defaults toNone).CivitaiClient._make_request(py/services/civitai_client.py:97-100): convertsRateLimitErrorto a raise immediately; no waiting. Transient 5xx/connection errors are retried 3× with 1s/2s/4s backoff.CivArchiveClient._make_request(py/services/civarchive_client.py): raisesRateLimitErrorwithprovider="civarchive_api"when not set._RateLimitRetryHelper(py/services/model_metadata_provider.py:45-102): per-call retry loop — sleepsretry_after(capped at 1800 s;≥120 s⇒ no retry), then re-raises. Because every concurrent call runs its own helper, they sleep in parallel and re-fire in parallel.FallbackMetadataProvider(py/services/model_metadata_provider.py:488-508, 564-584etc.): on a finalRateLimitErrorfrom one provider it logs "skipping to next provider" and continues to the next network provider — this is the direct cause of the CivArchive flood.MetadataSyncService.fetch_and_update_model(py/services/metadata_sync_service.py:248-333): manually iteratesprovider_attempts; onRateLimitErroritcontinues to the next provider (same failover problem), then reports"Rate limited"when nothing succeeded.Downloader.make_requesthas a per-destination scope already available:_guard_destination(url)returns the hostname (downloader.py:1194-1199), used byConnectivityGuard.
2.2 What pacing exists today
ConnectivityGuard: per-destination cooldown (30 s base, ×2 per extra failure batch, 300 s cap) triggered only by transport errors (connectivity_guard.py:168-197).AdaptiveConcurrencyController(batch import, fixed inee233548): shared semaphore enforces 1–5 concurrent items; duration-based adjustment only — it never sees HTTP statuses, so it cannot distinguish "slow because rate limited" from "slow because big image".- No token bucket, no minimum inter-request interval, no shared
Retry-Aftergate anywhere (grepfor throttle/token-bucket/rate-limiter: 0 hits).
3. Requirements & Constraints
R1. Respect Retry-After. After a 429, no further request to that
destination may be sent before the vendor's retry window elapses.
R2. No thundering herd. Concurrent waiters must share one wake-up (gate),
not sleep independently.
R3. No double load. A CivitAI 429 must not trigger a CivArchive request
for the same lookup. CivArchive should only be consulted when CivitAI
legitimately has no answer (404 / "not found"), or when CivitAI is
unreachable long-term.
R4. No spurious item failures. A rate-limited request must not turn a
batch-import item into FAILED; it should wait (bounded) and retry, or at
worst be SKIPPED with a clear "rate limited" reason (re-runnable import).
R5. Never hang forever. All waiting is bounded by a configurable cap; on
expiry the caller receives the RateLimitError and can decide.
R6. Keep legitimate failover. Deleted-model recovery via CivArchive/sqlite
must keep working (404 paths unchanged).
R7. Single choke point. The pacing gate should live where every API call
passes (the Downloader), so bulk refresh, metadata sync, recipe
analysis, and usage-control lookups all benefit without per-feature work.
4. Approach Comparison
A. Reactive gate — shared Retry-After deadman clock (recommended core)
A process-wide, per-destination coordinator records the next-allowed-send
timestamp from each 429 (now + max(retry_after, backoff)). Every request
through Downloader.make_request consults the gate before sending and when
a 429 arrives; waiters block on a shared asyncio.Event that fires when the
cooldown expires.
- Pros: single choke point (R7); herd-free (R2); honors server guidance (R1); no guessing at vendor limits; covers all providers automatically; reuses existing per-destination scoping.
- Cons: still experiences 429s before slowing down (reactive); long
Retry-Afterwindows (CivArchive has been observed at ~1500 s) need a sane wait cap + skip/retry UX.
B. Preemptive pacing — minimum inter-request interval (recommended companion)
Per-destination token bucket (simplest form: capacity 1 — at least N seconds
between consecutive API requests; N configurable, default ~0.75 s ≈ 80
r/min ceiling).
- Pros: prevents most 429s before they happen — exactly the "intentional reduction in request rate" the issue asks for; trivial to implement on top of A's coordinator.
- Cons: adds latency to bulk operations (thousands of models ×
N); the exact vendor limits are unknown (CivitAI anonymous vs keyed vscivitai.redmirror differ), so the default must be conservative-but-not-crippling and settings-tunable.
C. Fallback semantics change — stop network→network failover on 429 (must-do, low risk)
FallbackMetadataProvider (and MetadataSyncService.fetch_and_update_model's
manual loop) must treat a final RateLimitError as a terminal, non-failover
result for network providers. Local-only providers (sqlite archive DB) may
stay as a last resort (no vendor cost).
- Pros: directly removes the CivArchive flood; small, surgical change.
- Cons: none significant; requires care to keep 404-failover intact (R6).
Rejected / deferred
- Per-feature retry queues (batch import pauses & resumes whole batches): richer UX but much larger change (batch state machine, WebSocket states); unnecessary once A+B make requests wait at the choke point. Defer unless review finds the bounded-wait UX insufficient.
- Full token bucket with burst credit: overkill; capacity-1 interval is enough given the shared semaphore already caps concurrency at 5.
- Retrying in
connectivity_guard: wrong layer — the guard is about transport reachability, not vendor quota.
5. Recommended Architecture
New singleton RateLimitCoordinator (py/services/rate_limit_coordinator.py,
mirroring ConnectivityGuard's singleton + per-destination patterns):
state per destination (hostname):
next_allowed_send: float (monotonic) # from 429 Retry-After + backoff
consecutive_429: int # for backoff growth
last_send_at: float # for min-interval pacing
waiters: list[Future] | asyncio.Event # shared wake-up per cooldown cycle
API:
async wait_for_slot(destination, request_started_within_window: bool)— called byDownloader.make_requestbefore sending (blocks untilmin(now >= next_allowed_send)and inter-request interval elapses) and re-armable after a 429.register_rate_limit(destination, retry_after: float | None)— called on 429:next_allowed_send = max(now + retry_after_or_backoff, current);consecutive_429 += 1; backoff =retry_afterhonored, else exponential30 · 2^(n-1)capped at 1800 s; creates/re-arms the shared wake-up event.register_success(destination)— resetsconsecutive_429(called from the existing 200 path inmake_request).remaining_seconds(destination),in_cooldown(destination)— for tests and diagnostics.
Enforcement points:
Downloader.make_request(downloader.py:1102-1132): ordering inside the method is connectivity-guard fail-fast first (offline short-circuit costs nothing to check), thenawait coordinator.wait_for_slot(destination)beforesession.request. On 429:coordinator.register_rate_limit(...), then wait for the gate and re-send (loop, bounded byrate_limit_max_wait_seconds, default 300;retry_after ≥ cap⇒ fail immediately). After the loop, return theRateLimitErrorto the caller (unchanged contract) withexc.gate_handled = Trueset so downstream retry helpers know the wait already happened. 200 path callsregister_success.Downloader.download_to_memory/get_response_headers(phase 2): register 429s (so API calls queue); waiting only inmake_requestinitially.FallbackMetadataProvider(model_metadata_provider.py): remove network→network failover onRateLimitError— re-raise; only sqlite stays as a local last resort (implementation: per-methodexcept RateLimitErrorhandler that marks the chain rate-limited and stops iterating).MetadataSyncService.fetch_and_update_model(metadata_sync_service.py:248-333): onRateLimitErrorfrom the default provider, stop appending further network providers (sqlite may remain); the existingany_rate_limitedmerge already produces"Rate limited".- Batch import (
batch_import_service.py): no structural change needed — items now wait insidemake_request; optionally (phase 2) map residual rate-limit failures (after the wait cap) toSKIPPEDwith"rate limited (retry_after=…s); re-run the import later"instead ofFAILED, and surface arate_limitedflag in the WebSocket progress broadcast. _RateLimitRetryHelperretries (model_metadata_provider.py): Phase 1 — when the raisedRateLimitErrorcarriesgate_handled = True(set by the downloader after honoring the gate), the helper skips its ownretry_aftersleep and re-raises immediately, eliminating the double wait. The wiring stays so aRateLimitErrorstill propagates cleanly; full demotion/removal can follow once the gate proves out.
Settings (settings.json, schema extension in SettingsManager):
| key | default | meaning |
|---|---|---|
rate_limit_gate_enabled |
true |
master switch for the coordinator |
rate_limit_max_wait_seconds |
300 |
how long make_request waits on a 429 gate before returning the error |
rate_limit_min_interval_seconds |
0.75 |
minimum seconds between API requests per destination (pacing, R6-friendly conservative default) |
6. Changes by File
| File | Change |
|---|---|
py/services/rate_limit_coordinator.py (new) |
coordinator singleton + per-destination state + tests seam |
py/services/downloader.py |
gate pre-check + 429 register/wait/retry loop + register_success; log the 429 notice at INFO once per cooldown, then DEBUG |
py/services/model_metadata_provider.py |
FallbackMetadataProvider: stop network failover on RateLimitError; helper skips its sleep when the error is marked gate_handled |
py/services/metadata_sync_service.py |
fetch_and_update_model: same failover semantics; keep sqlite last resort |
py/services/batch_import_service.py |
(phase 2) rate-limit failures → SKIPPED + rate_limited progress flag |
py/services/settings_manager.py |
new settings keys + defaults |
tests/services/test_rate_limit_coordinator.py (new) |
gate unit tests |
tests/services/test_civitai_client.py / test_civarchive_client.py |
provider-level 429 behavior |
tests/services/test_metadata_service.py |
failover-chain tests |
tests/services/test_batch_import_service.py |
SKIPPED-on-rate-limit |
7. Impact, Risks, Open Questions
- Behavior change: with the gate in
make_request, any request can block up to the wait cap — UI actions that call the API (e.g. a model-details fetch) may take longer during cooldowns. Mitigation: bounded cap + INFO log- the existing async request handling already tolerates slow responses. Decided (§10): interactive requests take the same bounded wait — one behavior, no call-source plumbing; cooldowns are usually short.
- Gate waits occupy batch slots: with the 1–5 batch semaphore, all slots
can park on a gate simultaneously, freezing visible progress for up to one
wait cap per wave. Bounded and acceptable; the phase-2
SKIPPEDmapping + WebSocketrate_limitedflag (both confirmed in scope, §10) make the stall visible and recoverable. - Rate limit reality check: CivitAI anonymous vs keyed limits, and whether
civitai.reddiffers, is unverified. Default pacing0.75 s/reqis a conservative guess (R6). Open question for maintainer: preferred default and whether an API-keyed ceiling should be higher. - Long CivArchive windows:
Retry-After ~1500 sobserved in code comments. Decided (§10): keep the 300 s default cap — such lookups fail/skip rather than park a request path for 25 minutes; batch import maps them toSKIPPED(phase 2) so the user can re-run later. - Double waiting:
_RateLimitRetryHelper+ gate could stack waits. Resolved in Phase 1: the downloader marks gate-honored errors withgate_handled = Trueand the helper skips its own sleep for those. - Downloads:
download_file429s return an error to download managers unchanged (already handled); only registration is proposed, so future API calls queue behind a largeRetry-Afterfrom a download burst.
8. Test Plan
- Coordinator unit tests (new file):
- 429 with
retry_after→wait_for_slotblocks ~that long, then passes. - N concurrent waiters all wake together (herd test, wall-clock ≈ one window, not N windows).
- Consecutive 429s grow backoff;
register_successresets. - Missing
Retry-After→ default backoff path. - Wait cap: request fails after
rate_limit_max_wait_secondswithRateLimitError.
- 429 with
- Downloader tests (mock aiohttp session): 429 then 200 →
make_requestreturns success after gate delay; two back-to-back calls to the same destination are spaced ≥min_interval; different destinations are not spaced. - Provider tests:
FallbackMetadataProvider.get_model_version_info— Civitai raisesRateLimitError→ CivArchive mock not called; 404 still falls through to CivArchive; sqlite still tried after network 429. - Sync-service test:
fetch_and_update_modelwith a rate-limited default provider → result error contains"Rate limited"and sqlite attempt state unchanged. - Batch-import test: analysis provider 429s first, then succeeds →
item ends
SUCCESS(wait path), and post-cap 429 →SKIPPEDwith rate-limit reason (phase 2). - Full regression:
pytest tests/services tests/routes tests/standalone(currently 1582 passing).
9. Implementation Phases
- Phase 1 (this plan, reviewed):
RateLimitCoordinator+Downloader.make_requestintegration (guard fail-fast → gate pre-check pacing → 429 register/wait/retry loop with cap →gate_handledmarking) + settings + Fix C failover semantics (FallbackMetadataProvider,fetch_and_update_model— moved up from phase 2: smallest diff, kills the CivArchive flood immediately, independent of coordinator correctness) +_RateLimitRetryHelperdouble-wait fix + coordinator/downloader/provider/ sync tests. - Phase 2: batch-import
SKIPPED-on-rate-limit +rate_limitedWebSocket progress flag + slowdown hint (confirmed, §10),download_to_memory/HEAD 429 registration, batch tests. - Phase 3: full regression + docs + commit referencing
(#1085).
10. Review Checklist — Decisions (2026-08-27)
- Default pacing interval
0.75 s— accepted as conservative default; tunable viarate_limit_min_interval_seconds. Revisit if CivitAI publishes keyed/anonymous ceilings. - Wait cap
300 s— accepted; long-window CivArchive lookups fail → batch import marks themSKIPPEDwith a rate-limit reason (phase 2). - Interactive API calls also wait (bounded) — yes, same behavior for all callers.
- Keep sqlite as last resort behind a network rate limit — yes (local-only, no vendor cost).
- UI hint — yes: WebSocket
rate_limitedflag + "rate limited — slowing down" hint in batch-import progress (phase 2); INFO logging regardless.