fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016 (#1312)
* fix(errors): explain a cut TLS connection instead of printing _ssl.c:1016 #1301 surfaced as "500 Internal Server Error: [SSL: UNEXPECTED_EOF_WHILE_ READING] EOF occurred in violation of protocol (_ssl.c:1016)" — meaningless to a user, and unclassified: the existing SSL branch requires "handshake" or "certificate verify failed", so this fell through with no hint at all. Deliberately a SEPARATE class from SSL_HANDSHAKE_FAILURE rather than widening it. That class means a proxy re-signed the certificate with a CA certifi does not trust, and its advice is to set SSL_CERT_FILE or add an antivirus exclusion. Here the handshake never failed on trust — the socket was cut mid-exchange, usually flaky Wi-Fi, a reconnecting VPN, a captive portal, or a server dropping a long transfer. Sending that user to fix their certificate store is sending them to fix something that is not broken. Classified before the handshake branch, because the raw text contains "ssl" and the broader branch would otherwise claim it. Added to _CONTEXT_FREE_HINT_CLASSES since its trigger is an exact OpenSSL string — that matters here, because the raw 500 handler is precisely where the reporter met it. Closes #1301 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(errors): scope the resume guarantee, and require a TLS marker Greptile P1 — the hint promised "OmniVoice resumes partial downloads rather than starting over", unqualified. That is verified for HF model downloads (snapshot_download) and segmented_download, but the hint is static and also reaches media fetches where nothing guarantees it. Shipping an instruction that is not true is the exact class of bug this session has been removing, so the guarantee is now scoped to models. CodeRabbit — the matcher accepted either EOF phrase with no ssl marker. "unexpected EOF" is a phrase a parser or another transport can produce, and those would have been handed VPN/proxy advice. The OpenSSL text always carries the marker, so requiring it costs nothing. Also fixes a tautology: test_not_mistaken_for_a_cert_trust_problem asserted != SSL_HANDSHAKE_FAILURE, which the OLD classifier satisfied by returning "". It now pins the exact class. 2 of the 9 tests fail against the previous commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4f1135ac62
commit
7dd7e89e7b
@@ -51,6 +51,12 @@ _HINTS: dict[str, str] = {
|
||||
"SSL_HANDSHAKE_FAILURE": "A corporate or antivirus proxy is intercepting HTTPS traffic and re-signing certificates with its own CA — your OS trusts that CA, but Python's bundled certifi CA list doesn't, so the TLS handshake fails even though the connection reached the server. Newer OmniVoice builds trust the OS certificate store at startup (the truststore package), which should already fix this — update the app and retry. If you still see this, add an HTTPS-scanning exclusion for OmniVoice/Python in your antivirus, or ask IT for the proxy's CA bundle and set SSL_CERT_FILE to it, then restart.",
|
||||
"UNSUPPORTED_VIDEO_URL": "This link isn't a directly downloadable video. Paste a direct video page (e.g. a youtube.com/watch?v=… or douyin.com/video/<id> link), not a share/profile/feed link — or download the file and drop it in directly.",
|
||||
"VIDEO_DRM_PROTECTED": "The video host only offered OmniVoice a DRM-protected copy, which can't be downloaded. This is often not a property of the video itself — the host serves a different format set to different clients, and OmniVoice already retried through every client it has. Try the link again in a minute, or download the video with a browser extension / the host's own download button and drop the file into Dubbing directly.",
|
||||
# #1301: distinct from SSL_HANDSHAKE_FAILURE. The handshake did not fail on
|
||||
# trust — the connection was CUT while TLS was in progress, so the certifi /
|
||||
# proxy-CA advice above would send the user to fix something that isn't
|
||||
# broken. Raw form is "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in
|
||||
# violation of protocol (_ssl.c:1016)", which means nothing to anyone.
|
||||
"TLS_CONNECTION_DROPPED": "The secure connection was cut off mid-transfer — the other end (or something between you and it) closed the socket during the TLS exchange. This is almost always transient: flaky Wi-Fi, a VPN reconnecting, a captive portal, or a download server dropping a long transfer. Retrying is safe: a partly-downloaded MODEL is picked up where it left off rather than started over. If it repeats every time, a VPN or an HTTPS-inspecting proxy is terminating long-lived connections — try without the VPN, or on another network.",
|
||||
"VIDEO_DOWNLOAD_NETWORK": "The connection to the video server dropped mid-download (often a transient CDN/network blip or a regional rate-limit). Just retry — OmniVoice already cleaned up the partial download. If it keeps failing, check your network/VPN.",
|
||||
"BROKEN_VENV": "The Python backend environment was moved or damaged. OmniVoice rebuilds it automatically on the next launch; if it keeps failing, use Clean & Retry on the setup screen.",
|
||||
"MODEL_CACHE_CORRUPT": "The model cache had broken file links — snapshot entries that no longer point at their downloaded data (interrupted renames or antivirus interference can cause this). OmniVoice repairs this automatically and retries the load once. If the error persists, quit OmniVoice, delete the model's models--<org>--<name> folder inside the Hugging Face cache, and restart — the model re-downloads automatically.",
|
||||
@@ -216,6 +222,9 @@ def append_hf_mirror_hint(text: str) -> str:
|
||||
_CONTEXT_FREE_HINT_CLASSES = frozenset({
|
||||
"SOCKS_PROXY_SUPPORT_MISSING",
|
||||
"SSL_HANDSHAKE_FAILURE",
|
||||
# Its trigger is an exact OpenSSL string, so it cannot be confused with
|
||||
# another failure the way a bare "timed out" could.
|
||||
"TLS_CONNECTION_DROPPED",
|
||||
})
|
||||
|
||||
|
||||
@@ -338,6 +347,18 @@ def classify(reason: str) -> str:
|
||||
# certifi list doesn't (a different failure mode from #984's TCP-level
|
||||
# "can't reach the host at all"). Requires "ssl" plus a handshake/cert-
|
||||
# verify marker so a generic connection error isn't mislabelled.
|
||||
# Check the dropped-connection shape FIRST: its text also contains "ssl",
|
||||
# and the handshake branch below would otherwise claim it and hand out
|
||||
# proxy/certifi advice for a socket that was simply cut (#1301).
|
||||
# Requires an "ssl" marker as well: "unexpected EOF" on its own is a phrase
|
||||
# a parser or an unrelated transport can also produce, and stamping the TLS
|
||||
# taxonomy on those would hand out VPN/proxy advice for something else
|
||||
# entirely. The OpenSSL text always carries the marker.
|
||||
if "ssl" in low and (
|
||||
"unexpected_eof_while_reading" in low
|
||||
or "eof occurred in violation of protocol" in low
|
||||
):
|
||||
return "TLS_CONNECTION_DROPPED"
|
||||
if "ssl" in low and (
|
||||
"handshake" in low
|
||||
or "certificate verify failed" in low
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""#1301: a cut TLS connection surfaced as a raw OpenSSL string in a 500.
|
||||
|
||||
500 Internal Server Error: [SSL: UNEXPECTED_EOF_WHILE_READING]
|
||||
EOF occurred in violation of protocol (_ssl.c:1016)
|
||||
|
||||
Two things wrong with that. It means nothing to a user, and it was
|
||||
unclassified — the existing SSL branch requires "handshake" / "certificate
|
||||
verify failed", so this fell through with no hint at all.
|
||||
|
||||
It also must NOT be classified as SSL_HANDSHAKE_FAILURE. That class means a
|
||||
proxy re-signed the certificate with a CA certifi doesn't trust, and its advice
|
||||
is to set SSL_CERT_FILE or add an antivirus exclusion. Here the handshake never
|
||||
failed on trust — the socket was cut mid-exchange, usually by flaky Wi-Fi, a
|
||||
reconnecting VPN, or a server dropping a long transfer. Sending that user to
|
||||
fix their certificate store is sending them to fix something that isn't broken.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
RAW = (
|
||||
"[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of "
|
||||
"protocol (_ssl.c:1016)"
|
||||
)
|
||||
|
||||
|
||||
def _classify(text):
|
||||
from core.failure import classify
|
||||
|
||||
return classify(text)
|
||||
|
||||
|
||||
def test_dropped_tls_is_classified():
|
||||
assert _classify(RAW) == "TLS_CONNECTION_DROPPED"
|
||||
|
||||
|
||||
def test_not_mistaken_for_a_cert_trust_problem():
|
||||
"""The whole point of the separate class — and asserted so it FAILS against
|
||||
the old classifier rather than passing on a technicality.
|
||||
|
||||
Before this change `classify(RAW)` returned "" (no branch matched), which a
|
||||
bare `!= "SSL_HANDSHAKE_FAILURE"` would have accepted. Pinning the exact
|
||||
class is what makes it a regression test.
|
||||
"""
|
||||
assert _classify(RAW) == "TLS_CONNECTION_DROPPED"
|
||||
assert _classify(RAW) != "SSL_HANDSHAKE_FAILURE"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"SSLError: certificate verify failed: unable to get local issuer certificate",
|
||||
"ssl.SSLError: [SSL: SSLV3_ALERT_HANDSHAKE_FAILURE] sslv3 alert handshake failure",
|
||||
],
|
||||
)
|
||||
def test_real_handshake_failures_still_classify_as_such(text):
|
||||
"""The new branch runs first, so guard that it didn't steal these."""
|
||||
assert _classify(text) == "SSL_HANDSHAKE_FAILURE"
|
||||
|
||||
|
||||
def test_user_facing_hint_is_about_retrying_not_certificates():
|
||||
from core.failure import build_failure
|
||||
|
||||
fields = build_failure(RuntimeError(RAW), stage="download")
|
||||
assert fields["docs_topic"] == "TLS_CONNECTION_DROPPED"
|
||||
hint = fields["hint"].lower()
|
||||
assert "retry" in hint
|
||||
assert "ssl_cert_file" not in hint
|
||||
assert "certifi" not in hint
|
||||
|
||||
|
||||
def test_hint_attaches_on_context_free_surfaces():
|
||||
"""The 500 handler has only a raw string — the class has to be safe there,
|
||||
which is exactly where #1301's reporter met it."""
|
||||
from core.failure import append_hint
|
||||
|
||||
out = append_hint(RAW)
|
||||
assert out != RAW, "no hint was attached on the raw-string surface"
|
||||
assert "cut off" in out.lower() or "transient" in out.lower()
|
||||
|
||||
|
||||
def test_unrelated_errors_are_untouched():
|
||||
assert _classify("ValueError: bad input") != "TLS_CONNECTION_DROPPED"
|
||||
assert _classify("") != "TLS_CONNECTION_DROPPED"
|
||||
|
||||
|
||||
def test_eof_without_a_tls_marker_is_not_claimed():
|
||||
""""unexpected EOF" is a phrase a parser or another transport can produce.
|
||||
Without an ssl marker this must not be given VPN/proxy advice."""
|
||||
assert _classify("json decode error: unexpected EOF while reading") != (
|
||||
"TLS_CONNECTION_DROPPED"
|
||||
)
|
||||
assert _classify("archive truncated: EOF occurred in violation of protocol framing") != (
|
||||
"TLS_CONNECTION_DROPPED"
|
||||
)
|
||||
|
||||
|
||||
def test_hint_does_not_promise_resuming_arbitrary_downloads():
|
||||
"""Greptile P1: the first draft said "OmniVoice resumes partial downloads",
|
||||
unqualified. That is verified for HF model downloads (snapshot_download)
|
||||
and segmented_download, but the hint also reaches media fetches where it is
|
||||
not guaranteed — so the guarantee is scoped to models or it is a promise we
|
||||
cannot keep. Shipping an instruction that is not true is the exact class of
|
||||
bug this session has been removing."""
|
||||
from core.failure import _HINTS
|
||||
|
||||
hint = _HINTS["TLS_CONNECTION_DROPPED"]
|
||||
assert "model" in hint.lower()
|
||||
assert "resumes partial downloads" not in hint.lower()
|
||||
Reference in New Issue
Block a user