Files
VoiceStudio/backend/core/error_journal.py
T
14f1257d1f fix(errors): name the configured HF mirror when a model download fails (#874) (#890)
When a non-default HF_ENDPOINT (Settings → Models → Hugging Face mirror,
e.g. hf-mirror.com) is configured and a model load/download fails with a
connectivity error, the raw transformers message ("We couldn't connect to
'https://hf-mirror.com' to load the files…") leaked to the UI as a bare 500
with no next step.

Class fix — one shared classifier in core/failure.py covers every surface:

- classify()/build_failure(): new HF_MIRROR_UNREACHABLE class with a dynamic
  hint that names the configured mirror, says it may be down, points at
  Settings → Models → Hugging Face mirror, suggests the official endpoint
  when the model isn't cached, and notes the restart requirement (HF reads
  HF_ENDPOINT at backend start). Checked before the video-download network
  class so a model download's "timed out" no longer gets the "video server"
  hint. Feeds /model/status and every build_failure event (dub, tasks).
- main.py global 500 handler: appends the hint to the surfaced detail, so
  ALL routes that can leak a model-load error benefit (generate, dub,
  archetypes, …), not just TTS generate.
- setup/download.py install SSE: the install_error event gets the same hint.
- error_journal: "couldn't connect to" / "max retries exceeded" now classify
  as NETWORK_ERROR (was UNKNOWN) for auto-attached bug reports.
- model_manager (#886 family): the "cache incomplete and could not be
  auto-repaired" message now names WHY the auto-repair failed (mirror
  outage, offline mode, full disk no longer read identically), which also
  lets the mirror hint fire on that surface when applicable.

Fail-before/pass-after regression tests in tests/test_hf_mirror_error_class.py
(12 of 13 fail on main).

Fixes #874

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 18:23:47 +05:30

214 lines
7.3 KiB
Python

"""Ring journal of recent backend errors — the "what just broke" store.
The global exception handler (main.py) records every unhandled exception
here. Unlike crash_log.txt (append-only plain text for humans), the journal
is structured and deduplicated, so the UI and the bug-report pipeline can
answer:
- what was the most recent backend error? (auto-attach to a report)
- is it the same error repeating? (count by fingerprint, "x14 since start")
- what KIND of failure is it? (error_class — GPU_OOM, HF_AUTH_FAILED, …)
Everything stored is pre-scrubbed (core.scrub) because journal entries feed
the diagnostic bundle and prefilled GitHub issues. In-memory ring of
``_MAX_ENTRIES`` fingerprints, mirrored to ``DATA_DIR/error_journal.jsonl``
(rewritten on each record — entry count is small, atomicity beats append
here) so the journal survives restarts and the crash it just recorded.
``error_class`` values: the install-time classes reuse the locked taxonomy
keys from core.error_docs_map (HF_AUTH_FAILED, PYANNOTE_LICENSE_REQUIRED) so
docs deeplinks keep working; runtime classes (GPU_OOM, DISK_FULL,
NETWORK_ERROR, FFMPEG_MISSING) are journal-local and fall back to
DEFAULT_DOCS in lookup(). Don't add them to ERROR_DOCS without following
the 4-step mirror contract documented there.
"""
from __future__ import annotations
import json
import os
import threading
import time
from collections import OrderedDict
from core.config import DATA_DIR
from core.scrub import scrub_text
JOURNAL_PATH = os.path.join(DATA_DIR, "error_journal.jsonl")
_MAX_ENTRIES = 50
_MAX_TRACE_CHARS = 4000
_lock = threading.Lock()
# fingerprint -> entry, oldest first (move_to_end on repeat).
_entries: "OrderedDict[str, dict]" = OrderedDict()
# Ordered: first match wins, most specific patterns up top.
_CLASS_RULES: tuple[tuple[str, tuple[str, ...]], ...] = (
("GPU_OOM", (
"cuda out of memory",
"mps backend out of memory",
"hip out of memory",
"out of memory on device",
)),
("PYANNOTE_LICENSE_REQUIRED", (
"pyannote", # only meaningful combined with an auth marker — see classify()
)),
("HF_AUTH_FAILED", (
"401 client error",
"403 client error",
"gatedrepoerror",
"repository not found",
"invalid user token",
"huggingface_hub.errors",
)),
("DISK_FULL", (
"no space left on device",
"errno 28",
"disk quota exceeded",
)),
("FFMPEG_MISSING", (
"ffmpeg not found",
"ffmpeg is not installed",
"no such file or directory: 'ffmpeg'",
)),
("NETWORK_ERROR", (
"connection refused",
"connection reset",
"connection aborted",
# transformers' download-failure wording ("We couldn't connect to
# '<endpoint>' to load the files") — the #874 mirror-down class was
# journaled as UNKNOWN without these.
"couldn't connect to",
"could not connect to",
"max retries exceeded",
"timed out",
"timeout",
"name or service not known",
"temporary failure in name resolution",
"ssl",
"proxyerror",
)),
)
_AUTH_MARKERS = ("401", "403", "gated", "access", "token")
def classify_exception(exc: BaseException, trace: str = "") -> str:
"""Best-effort classification of an exception into a stable class key.
Pattern-matching on message text is inherently fuzzy — the goal is
triage ("which docs page / which hint"), not perfection. UNKNOWN is an
acceptable answer.
"""
blob = f"{type(exc).__name__}: {exc}\n{trace}".lower()
for cls, needles in _CLASS_RULES:
if cls == "PYANNOTE_LICENSE_REQUIRED":
# pyannote in the trace alone is too broad (any diarization bug
# would match); require an auth/gating marker alongside it.
if "pyannote" in blob and any(m in blob for m in _AUTH_MARKERS):
return cls
continue
if any(n in blob for n in needles):
return cls
return "UNKNOWN"
def _fingerprint(error_class: str, exc: BaseException) -> str:
import hashlib
raw = f"{error_class}|{type(exc).__name__}|{scrub_text(str(exc))[:200]}"
# Dedup key for the journal, not a security boundary.
return hashlib.sha1(raw.encode("utf-8", "replace"), usedforsecurity=False).hexdigest()[:16]
def _persist_locked() -> None:
"""Rewrite the JSONL mirror from the in-memory ring. Caller holds _lock.
Never raises — losing persistence must not break the exception handler."""
try:
tmp = JOURNAL_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
for entry in _entries.values():
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp, JOURNAL_PATH)
except Exception:
pass
def _hydrate() -> None:
"""Load persisted entries at import so 'recent errors' survives restarts
(and shows the error that killed the previous run)."""
try:
with open(JOURNAL_PATH, encoding="utf-8") as f:
for line in f:
try:
entry = json.loads(line)
fp = entry.get("fingerprint")
if fp:
_entries[fp] = entry
except Exception:
continue
while len(_entries) > _MAX_ENTRIES:
_entries.popitem(last=False)
except FileNotFoundError:
pass
except Exception:
pass
_hydrate()
def record(exc: BaseException, route: str = "", trace: str = "") -> dict:
"""Record an unhandled exception. Returns the (scrubbed) journal entry.
Never raises — this runs inside the global exception handler, where a
second failure would shadow the one being reported.
"""
try:
error_class = classify_exception(exc, trace)
fp = _fingerprint(error_class, exc)
now = time.strftime("%Y-%m-%dT%H:%M:%S")
with _lock:
existing = _entries.get(fp)
if existing:
existing["count"] = int(existing.get("count", 1)) + 1
existing["last_seen"] = now
existing["route"] = scrub_text(route) or existing.get("route", "")
_entries.move_to_end(fp)
entry = existing
else:
entry = {
"fingerprint": fp,
"error_class": error_class,
"type": type(exc).__name__,
"message": scrub_text(str(exc)),
"route": scrub_text(route),
"trace": scrub_text(trace)[:_MAX_TRACE_CHARS],
"first_seen": now,
"last_seen": now,
"count": 1,
}
_entries[fp] = entry
while len(_entries) > _MAX_ENTRIES:
_entries.popitem(last=False)
_persist_locked()
return entry
except Exception:
return {"error_class": "UNKNOWN", "type": type(exc).__name__, "count": 1}
def recent(limit: int = 20) -> list[dict]:
"""Most recent errors first."""
with _lock:
items = list(_entries.values())
return list(reversed(items))[: max(1, min(limit, _MAX_ENTRIES))]
def clear() -> None:
with _lock:
_entries.clear()
try:
os.remove(JOURNAL_PATH)
except OSError:
pass