@@ -53,9 +53,21 @@ def set_diarizer(d) -> None:
5353
5454
5555async def _probe_network_once () -> None :
56- """Single TCP connect → 1.1.1.1:443 — writes result into _network_cache."""
56+ """Deduped probe for the poll loop — skips if another probe is in-flight.
57+
58+ Use for /health (high-frequency, fine to coalesce). For an explicit
59+ re-check that MUST return a fresh value, call _do_probe() directly."""
5760 if _network_cache ["checking" ]:
5861 return
62+ await _do_probe ()
63+
64+
65+ async def _do_probe () -> None :
66+ """Single TCP connect → 1.1.1.1:443 — writes result into _network_cache.
67+
68+ Always runs a real probe (no dedup guard). Concurrent calls are safe: the
69+ cache writes are idempotent and last-write-wins is acceptable for a
70+ reachability flag."""
5971 _network_cache ["checking" ] = True
6072 started = time .monotonic ()
6173 try :
@@ -111,13 +123,54 @@ async def health():
111123
112124@router .get ("/ping-network" )
113125async def ping_network ():
114- """Force a fresh network probe (bypass cache) — used when the frontend
115- explicitly wants to re-check after a suspected outage. NEVER call from
116- polling loops; use /health for that."""
117- await _probe_network_once ()
126+ """Force a fresh network probe (bypass cache AND the in-flight dedup
127+ guard) — used when the frontend explicitly wants to re-check after a
128+ suspected outage (e.g. an upload chunk just failed). NEVER call from
129+ polling loops; use /health for that.
130+
131+ Calls _do_probe() directly so the result is GUARANTEED fresh even if a
132+ /health background probe happens to be running at the same moment —
133+ otherwise _probe_network_once() would early-return on the `checking`
134+ guard and we'd hand back a stale `online: true`."""
135+ await _do_probe ()
118136 return await _get_network_status ()
119137
120138
139+ # ─── Error classification ──────────────────────────────────────────────────
140+ # When the machine is offline, provider SDKs raise raw DNS/socket errors like
141+ # "[Errno 8] nodename nor servname provided, or not known" (macOS),
142+ # "[Errno -2] Name or service not known" (Linux), or "getaddrinfo failed"
143+ # (Windows). Leaking these into the UI is noise — the real cause is "no
144+ # internet". Detect that class so /diagnose can show a clean offline message.
145+ _NETWORK_ERROR_MARKERS = (
146+ "nodename nor servname" , # macOS getaddrinfo EAI_NONAME
147+ "name or service not known" , # Linux EAI_NONAME
148+ "getaddrinfo failed" , # Windows
149+ "temporary failure in name resolution" ,
150+ "errno 8" , # EAI_NONAME numeric (macOS)
151+ "errno -2" , # EAI_NONAME numeric (Linux)
152+ "errno -3" , # EAI_AGAIN (DNS temp fail)
153+ "11001" , # Windows WSAHOST_NOT_FOUND
154+ "failed to establish a new connection" ,
155+ "max retries exceeded" ,
156+ "connection refused" ,
157+ "network is unreachable" ,
158+ "no route to host" ,
159+ "name resolution" ,
160+ )
161+
162+
163+ def _is_network_error (exc : Exception ) -> bool :
164+ """True if `exc` looks like an offline / DNS-resolution / unreachable error
165+ rather than an auth or server-side problem."""
166+ import socket
167+ # socket.gaierror is the canonical DNS resolution failure.
168+ if isinstance (exc , (socket .gaierror , ConnectionError )):
169+ return True
170+ msg = str (exc ).lower ()
171+ return any (marker in msg for marker in _NETWORK_ERROR_MARKERS )
172+
173+
121174@router .get ("/diarizer-status" )
122175async def diarizer_status ():
123176 if _diarizer is None :
@@ -152,6 +205,21 @@ async def diagnose(lang: str = "vi"):
152205
153206 stt_provider = db .get_setting ("stt_provider" ) or "nvidia"
154207
208+ # ── Offline short-circuit ──────────────────────────────────────────────
209+ # Probe the network FIRST (force-fresh via _do_probe, not cached). If the
210+ # machine is offline, every provider check below would just raise a raw
211+ # DNS error. Return one clean "no internet" message for both STT + LLM.
212+ await _do_probe ()
213+ if _network_cache ["online" ] is False :
214+ offline_msg = t ("network_offline" , lang )
215+ log .info ("STATUS: diagnose short-circuit — network offline" )
216+ return {
217+ "stt" : {"status" : "error" , "message" : offline_msg , "offline" : True },
218+ "llm" : {"status" : "error" , "message" : offline_msg , "offline" : True },
219+ "backend" : stt_provider ,
220+ "network" : {"online" : False },
221+ }
222+
155223 if stt_provider == "soniox" :
156224 soniox_key = db .get_setting ("soniox_api_key" ) or os .getenv ("SONIOX_API_KEY" , "" )
157225 if not soniox_key :
@@ -166,7 +234,10 @@ def _test_soniox():
166234 await loop .run_in_executor (None , _test_soniox )
167235 results ["stt" ] = {"status" : "ok" , "message" : t ("soniox_connected" , lang )}
168236 except Exception as e :
169- results ["stt" ] = {"status" : "error" , "message" : f"{ t ('soniox_connect_fail' , lang )} : { str (e )[:80 ]} " }
237+ if _is_network_error (e ):
238+ results ["stt" ] = {"status" : "error" , "message" : t ("network_offline" , lang ), "offline" : True }
239+ else :
240+ results ["stt" ] = {"status" : "error" , "message" : f"{ t ('soniox_connect_fail' , lang )} : { str (e )[:80 ]} " }
170241 else :
171242 nvidia_key = db .get_setting ("nvidia_api_key" ) or os .getenv ("NVIDIA_API_KEY" , "" )
172243 if not nvidia_key :
@@ -192,7 +263,9 @@ def _test_riva():
192263 results ["stt" ] = {"status" : "ok" , "message" : t ("nvidia_connected" , lang )}
193264 except Exception as e :
194265 err_str = str (e )
195- if "TimeoutError" in type (e ).__name__ or "timed out" in err_str .lower ():
266+ if _is_network_error (e ):
267+ results ["stt" ] = {"status" : "error" , "message" : t ("network_offline" , lang ), "offline" : True }
268+ elif "TimeoutError" in type (e ).__name__ or "timed out" in err_str .lower ():
196269 results ["stt" ] = {"status" : "error" , "message" : t ("nvidia_connect_fail" , lang ) + ": Connection timed out (10s)" }
197270 else :
198271 results ["stt" ] = {"status" : "error" , "message" : f"{ t ('nvidia_connect_fail' , lang )} : { err_str [:80 ]} " }
@@ -222,8 +295,12 @@ def _test_riva():
222295 results ["llm" ] = {"status" : "ok" , "message" : t ("llm_connected" , lang )}
223296 else :
224297 results ["llm" ] = {"status" : "error" , "message" : t ("llm_key_invalid" , lang )}
225- except Exception :
226- results ["llm" ] = {"status" : "error" , "message" : t ("llm_connect_fail" , lang )}
298+ except Exception as e :
299+ if _is_network_error (e ):
300+ results ["llm" ] = {"status" : "error" , "message" : t ("network_offline" , lang ), "offline" : True }
301+ else :
302+ results ["llm" ] = {"status" : "error" , "message" : t ("llm_connect_fail" , lang )}
227303
228304 results ["backend" ] = stt_provider
305+ results ["network" ] = {"online" : True }
229306 return results
0 commit comments