fix(generation): classify network/download failures — stop mislabeling every unknown error as OOM (#880) (#893)

A kittentts first-use HuggingFace download died with httpx's "Cannot send
a request, as the client has been closed", and the generation error
classifier's catch-all fallback told the user (CPU-only ~80 MB ONNX engine,
12 GB-VRAM box) they were OUT OF MEMORY and to press Flush — the wrong
remedy for a network failure.

Three-part class fix:

- generation.py: new #880 branch (before the OOM hint) classifies
  httpx/requests transport failures — matched over the whole exception
  chain (type names like ConnectError/ReadTimeout plus stringified
  signatures like "client has been closed") — as a download/network
  problem with a retry/check-connection remedy.
- generation.py (the real class bug): the OOM hint is no longer the
  catch-all. It now requires an actual OOM signature (typed
  OutOfMemoryError/MemoryError anywhere in the chain, or CUDA/MPS/CPU
  allocator wording); genuinely unknown errors surface as unrecognized
  with the underlying detail instead of a false "ran out of memory".
- tts_backend.py: KittenTTS's first-use load retries exactly once with a
  fresh HF Hub client (huggingface_hub.utils.close_session()) on the
  specific closed-client failure — hub ≥1.x shares one global httpx
  client, and a closed one is recoverable, so the download self-heals
  instead of failing the generation.

Fail-before/pass-after tests: classifier (closed-client message, wrapped
httpx type names, unknown error, real OOM signatures incl. typed
OutOfMemoryError, WinError 1455) + the retry helper (recovers once,
walks the chain, no retry on unrelated errors, single-shot).

Fixes #880

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-07-02 18:38:02 +05:30
committed by GitHub
co-authored by mergetest Claude Fable 5
parent 14f1257d1f
commit 6e600c48cb
4 changed files with 320 additions and 4 deletions
+122 -3
View File
@@ -142,6 +142,97 @@ def _apply_effect_chain(audio_out, sample_rate, effect_preset, *, skip_mastering
return normalize_audio(audio_out, target_dBFS=-2.0)
def _exception_chain(e):
"""Yield ``e`` plus every ``__cause__``/``__context__`` beneath it
(cycle-safe). Engines and hub libraries routinely wrap the original
transport/allocator error, so classification must look at the whole
chain, not just the outermost message."""
seen = set()
stack = [e]
while stack:
exc = stack.pop()
if exc is None or id(exc) in seen:
continue
seen.add(id(exc))
yield exc
stack.append(exc.__cause__)
stack.append(exc.__context__)
# #880: transport-level exception type names from httpx (huggingface_hub ≥1.x
# downloads over it) and requests/urllib3 (older engine deps). Any of these
# anywhere in the exception chain means the network — not memory — killed the
# generation.
_NETWORK_EXC_NAMES = frozenset({
# httpx
"ConnectError", "ConnectTimeout", "ReadTimeout", "ReadError",
"WriteError", "WriteTimeout", "PoolTimeout", "NetworkError",
"TransportError", "RemoteProtocolError", "ProxyError", "CloseError",
# requests / urllib3
"ConnectionError", "ChunkedEncodingError", "MaxRetryError",
"NewConnectionError", "ProtocolError",
# stdlib socket-level drops mid-download
"ConnectionResetError", "ConnectionAbortedError", "ConnectionRefusedError",
# huggingface_hub: failed first-use download with nothing in the disk cache
"LocalEntryNotFoundError",
})
# Same class, but the transport error was stringified into a wrapper message
# (so the type name is gone). All lowercase; matched against .lower().
_NETWORK_MSG_SIGNATURES = (
"client has been closed", # httpx closed-client lifecycle error (#880)
"cannot send a request", # httpx: same error, message head
"connection error", # requests / huggingface_hub wording
"connection reset", # ECONNRESET mid-download
"read timed out", # requests/urllib3 timeout wording
"max retries exceeded", # urllib3 retry exhaustion
"temporary failure in name resolution", # DNS down (glibc)
"name or service not known", # DNS down (glibc)
"getaddrinfo failed", # DNS down (Windows)
)
def _is_network_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) is an HTTP-client
lifecycle / network-transport error — e.g. a first-use model download
from the HF Hub dying mid-generation (#880)."""
for exc in _exception_chain(e):
if type(exc).__name__ in _NETWORK_EXC_NAMES:
return True
low = str(exc).lower()
if any(sig in low for sig in _NETWORK_MSG_SIGNATURES):
return True
return False
# Signatures of an *actual* out-of-memory condition. All lowercase.
_OOM_MSG_SIGNATURES = (
"out of memory", # CUDA / MPS / generic torch wording
"not enough memory", # torch CPU DefaultCPUAllocator
"cannot allocate memory", # OS-level ENOMEM
"std::bad_alloc", # C++ allocator failure
"cublas_status_alloc_failed", # cuBLAS workspace allocation
"cuda_error_out_of_memory", # raw CUDA driver error name
"paging file is too small", # Windows [WinError 1455] mapping DLLs
)
def _is_oom_failure(e) -> bool:
"""True iff the failure (anywhere in its chain) actually looks like an
out-of-memory condition — the only case where the Flush hint is honest."""
for exc in _exception_chain(e):
if isinstance(exc, MemoryError):
return True
# torch.cuda.OutOfMemoryError subclasses RuntimeError; match by name
# so this needs no torch import (and covers other frameworks' twins).
if type(exc).__name__ == "OutOfMemoryError":
return True
low = str(exc).lower()
if any(sig in low for sig in _OOM_MSG_SIGNATURES):
return True
return False
def _oom_friendly_reraise(e):
"""Best-effort cache flush + the user-facing OOM hint shared by both
inference paths."""
@@ -233,10 +324,38 @@ def _oom_friendly_reraise(e):
f"Restart the app and try again; the Flush button won't help here. "
f"Underlying error: {e}"
) from e
# #880: an httpx/requests transport failure surfacing from generation —
# most commonly a first-use model download from the HF Hub dying with
# httpx's "Cannot send a request, as the client has been closed" (the
# shared client got closed mid-lifecycle), a connect/read timeout, or a
# dropped connection — is NOT out of memory. The model never finished
# loading, so Flush is the wrong remedy; retrying is. Matched over the
# whole exception chain (type names + stringified signatures) because
# engines wrap the original transport error.
if _is_network_failure(e):
raise RuntimeError(
f"A model download or network call failed mid-generation (usually "
f"the engine fetching its model files on first use). This is a "
f"network problem, not a memory problem — flushing VRAM won't "
f"help. Retry the generation; if it keeps failing, check your "
f"internet connection and any HF_ENDPOINT/mirror setting. "
f"Underlying error: {e}"
) from e
# #880 (the class bug): the OOM hint used to be the catch-all fallback,
# so ANY unrecognized error told the user to press Flush for memory they
# never ran out of. Only claim OOM when something in the chain actually
# looks like one; everything else surfaces as what it is — unrecognized —
# with the real error front and center.
if _is_oom_failure(e):
raise RuntimeError(
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
) from e
raise RuntimeError(
f"TTS engine stopped mid-generation. This usually means it ran out of memory. "
f"Try the Flush button to reload the model, then regenerate. Underlying error: {e}"
)
f"TTS engine stopped mid-generation with an error OmniVoice doesn't "
f"recognize. Retry once; if it keeps failing, please report it with "
f"the full trace. Underlying error: {e}"
) from e
def _run_inference(
+60 -1
View File
@@ -55,6 +55,59 @@ def _mask_hf_tokens(value):
return _HF_TOKEN_MASK_RE.sub(_HF_TOKEN_MASK, value)
# ── HF Hub closed-client recovery (#880) ────────────────────────────────────
#
# huggingface_hub ≥1.x shares ONE global httpx client across every download.
# If anything closes it mid-lifecycle, every later hub call — e.g. an engine's
# first-use model download inside the generate path — dies with httpx's
# "Cannot send a request, as the client has been closed". The client is
# recoverable: ``close_session()`` drops it and the next hub call builds a
# fresh one, so the correct handling is a single targeted retry, not a
# user-facing failure.
def _is_closed_client_error(e) -> bool:
"""True iff ``e`` (or anything in its __cause__/__context__ chain) is
httpx's closed-client lifecycle error. Cycle-safe."""
seen, stack = set(), [e]
while stack:
exc = stack.pop()
if exc is None or id(exc) in seen:
continue
seen.add(id(exc))
low = str(exc).lower()
if "client has been closed" in low or "cannot send a request" in low:
return True
stack.append(exc.__cause__)
stack.append(exc.__context__)
return False
def _retry_once_with_fresh_hf_client(loader, what: str):
"""Run ``loader()`` — a model constructor that may download from the HF
Hub on first use. On the specific closed-client failure above, reset the
hub's shared client and retry exactly ONCE. Any other failure (and a
repeat closed-client failure) propagates untouched, where the generation
error classifier labels it as a network problem (#880)."""
try:
return loader()
except Exception as e:
if not _is_closed_client_error(e):
raise
logger.warning(
"%s: HF Hub httpx client was closed mid-download (%s); "
"retrying once with a fresh client.", what, e,
)
try:
from huggingface_hub.utils import close_session
close_session()
except Exception: # pragma: no cover — hub too old / API renamed
logger.warning(
"%s: couldn't reset the HF Hub client; retrying anyway.", what,
)
return loader()
# ── Protocol ────────────────────────────────────────────────────────────────
@@ -587,7 +640,13 @@ class KittenTTSBackend(TTSBackend):
"OMNIVOICE_KITTENTTS_MODEL", "KittenML/kitten-tts-mini-0.8"
)
logger.info("Loading KittenTTS from %s", checkpoint)
self._model = KittenTTS(checkpoint)
# #880: the first-use load downloads ~80 MB from the HF Hub inside the
# generate path; if the hub's shared httpx client was closed
# mid-lifecycle, retry once with a fresh client instead of failing
# the whole generation.
self._model = _retry_once_with_fresh_hf_client(
lambda: KittenTTS(checkpoint), what="KittenTTS"
)
def generate(self, text: str, **kw) -> torch.Tensor:
import numpy as np
+65
View File
@@ -131,3 +131,68 @@ def test_llm_auto_selects_openai_compat_when_configured(monkeypatch):
except ImportError:
pytest.skip("openai package not available in this environment")
assert llm_backend.active_backend_id() == "openai-compat"
# ── HF Hub closed-client recovery (#880) ────────────────────────────────────
#
# huggingface_hub ≥1.x shares one global httpx client; if it gets closed
# mid-lifecycle, an engine's first-use model download inside the generate
# path dies with "Cannot send a request, as the client has been closed".
# The load must retry exactly once with a fresh client — and must NOT retry
# unrelated failures.
def test_hf_retry_recovers_from_closed_client_once():
calls = []
def loader():
calls.append(1)
if len(calls) == 1:
raise RuntimeError("Cannot send a request, as the client has been closed.")
return "model"
assert tts_backend._retry_once_with_fresh_hf_client(loader, what="test") == "model"
assert len(calls) == 2
def test_hf_retry_matches_wrapped_closed_client_error():
# An engine can wrap the httpx error — detection walks the chain.
calls = []
def loader():
calls.append(1)
if len(calls) == 1:
try:
raise RuntimeError("Cannot send a request, as the client has been closed.")
except RuntimeError as inner:
raise RuntimeError("KittenTTS init failed") from inner
return "model"
assert tts_backend._retry_once_with_fresh_hf_client(loader, what="test") == "model"
assert len(calls) == 2
def test_hf_retry_does_not_retry_unrelated_errors():
calls = []
def loader():
calls.append(1)
raise ValueError("bad checkpoint id")
with pytest.raises(ValueError):
tts_backend._retry_once_with_fresh_hf_client(loader, what="test")
assert len(calls) == 1
def test_hf_retry_is_single_shot():
# A second closed-client failure propagates (the generation classifier
# then labels it a network problem) — no infinite retry loop.
calls = []
def loader():
calls.append(1)
raise RuntimeError("Cannot send a request, as the client has been closed.")
with pytest.raises(RuntimeError):
tts_backend._retry_once_with_fresh_hf_client(loader, what="test")
assert len(calls) == 2
+73
View File
@@ -55,6 +55,79 @@ def test_generic_failure_still_uses_oom_hint():
assert "ran out of memory" in str(ei.value)
def test_httpx_closed_client_is_a_download_failure_not_oom():
# #880: kittentts's first-use HF download died with httpx's closed-client
# lifecycle error, and the OOM catch-all told a user running a CPU-only
# ~80 MB ONNX engine on a 12 GB-VRAM box to press Flush. It's a network
# failure — say so, and don't send them to the Flush button.
err = RuntimeError("Cannot send a request, as the client has been closed.")
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "network" in msg
assert "download" in msg
assert "Retry" in msg
assert "client has been closed" in msg # underlying detail preserved
assert "ran out of memory" not in msg
assert "Try the Flush button" not in msg
@pytest.mark.parametrize("exc_name", ["ConnectError", "ReadTimeout"])
def test_httpx_transport_error_in_chain_is_a_download_failure(exc_name):
# #880: engines wrap the original httpx error, so classification must
# look at exception TYPE NAMES anywhere in the chain, not just the
# outermost message (which here carries no network signature at all).
fake_httpx_exc = type(exc_name, (Exception,), {})
try:
try:
raise fake_httpx_exc("")
except Exception as inner:
raise RuntimeError("model load failed") from inner
except RuntimeError as wrapped:
err = wrapped
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(err)
msg = str(ei.value)
assert "network" in msg
assert "ran out of memory" not in msg
assert "Try the Flush button" not in msg
def test_unknown_error_is_not_labelled_oom():
# #880 (the class bug): the OOM hint was the catch-all fallback, so ANY
# unrecognized error claimed "ran out of memory" + Flush. A genuinely
# unknown error must surface as unknown, detail intact.
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(RuntimeError("segfault in frobnicator: code 7"))
msg = str(ei.value)
assert "segfault in frobnicator: code 7" in msg
assert "ran out of memory" not in msg
assert "Try the Flush button" not in msg
@pytest.mark.parametrize("reason", [
"CUDA out of memory. Tried to allocate 20.00 MiB",
"MPS backend out of memory (MPS allocated: 8.00 GB)",
"DefaultCPUAllocator: not enough memory: you tried to allocate 1073741824 bytes",
"[enforce fail at alloc_cpu.cpp] posix_memalign. Cannot allocate memory",
"[WinError 1455] The paging file is too small for this operation to complete",
])
def test_real_oom_signatures_still_classify_as_oom(reason):
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(RuntimeError(reason))
assert "ran out of memory" in str(ei.value)
assert "Try the Flush button" in str(ei.value)
def test_typed_oom_without_oom_message_still_classifies_as_oom():
# torch.cuda.OutOfMemoryError can carry an opaque allocator message; the
# tightened OOM branch must also match the exception type name.
fake_torch_oom = type("OutOfMemoryError", (RuntimeError,), {})
with pytest.raises(RuntimeError) as ei:
_oom_friendly_reraise(fake_torch_oom("CUBLAS workspace reservation failed"))
assert "ran out of memory" in str(ei.value)
def test_unsupported_instruct_is_a_validation_error_not_oom():
# #664: free-form prose in the instruct field must surface as a 400-mapped
# ValueError with the instruct guidance — NOT a 500 "ran out of memory".