From 5f4a7e99f00de52b120485b4f2a34088bcfcb9a1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:03:45 -0700 Subject: [PATCH] fix: explain provider DNS failures as possible offline state --- run_agent.py | 31 ++++++++++++++++-- tests/run_agent/test_summarize_api_error.py | 35 ++++++++++++++++++++- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/run_agent.py b/run_agent.py index a8bbc515eaaa3..0aa009fae4b7c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2529,11 +2529,38 @@ class AIAgent: """Extract a human-readable one-liner from an API error. Handles Cloudflare HTML error pages (502, 503, etc.) by pulling the - tag instead of dumping raw HTML. Falls back to a truncated - str(error) for everything else. + <title> tag instead of dumping raw HTML. Network/DNS failures are + translated into an offline hint, including when an SDK wraps the + original OS error. Falls back to a truncated str(error) otherwise. """ raw = str(error) + # Linux, macOS, and Windows use different low-level messages when DNS + # cannot resolve the provider while the device is offline. SDKs often + # wrap that OSError in a generic "Connection error", so inspect the + # exception chain before showing the top-level message to the user. + network_resolution_markers = ( + "temporary failure in name resolution", + "name or service not known", + "nodename nor servname provided, or not known", + "getaddrinfo failed", + "no address associated with hostname", + "network is unreachable", + ) + current: Optional[BaseException] = error + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if any( + marker in str(current).lower() + for marker in network_resolution_markers + ): + return ( + "Hermes can't reach the model provider. You may be offline. " + "Check your internet connection and try again." + ) + current = current.__cause__ or current.__context__ + if ( isinstance(error, ValueError) and "expected ident at line" in raw.lower() diff --git a/tests/run_agent/test_summarize_api_error.py b/tests/run_agent/test_summarize_api_error.py index 82d9bcff1f7bd..d82c539a3b211 100644 --- a/tests/run_agent/test_summarize_api_error.py +++ b/tests/run_agent/test_summarize_api_error.py @@ -14,6 +14,7 @@ from types import SimpleNamespace from typing import Any import httpx +import pytest from run_agent import AIAgent @@ -37,9 +38,42 @@ def test_empty_body_falls_back_to_response_json_error_message(): assert "HTTP 400" in summary assert "model `foo` does not exist" in summary +@pytest.mark.parametrize( + "technical_message", + [ + "Temporary failure in name resolution", + "Name or service not known", + "nodename nor servname provided, or not known", + "getaddrinfo failed", + "No address associated with hostname", + "Network is unreachable", + ], +) +def test_network_resolution_failure_explains_that_the_user_may_be_offline( + technical_message, +): + error = OSError(-3, technical_message) + + summary = AIAgent._summarize_api_error(error) + + assert summary == ( + "Hermes can't reach the model provider. You may be offline. " + "Check your internet connection and try again." + ) + assert "name resolution" not in summary.lower() +def test_wrapped_dns_resolution_failure_gets_the_same_friendly_message(): + try: + try: + raise OSError(-3, "Temporary failure in name resolution") + except OSError as cause: + raise RuntimeError("Connection error.") from cause + except RuntimeError as error: + summary = AIAgent._summarize_api_error(error) + assert "You may be offline" in summary + assert "Connection error" not in summary def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_message(): @@ -62,4 +96,3 @@ def test_unread_streaming_response_does_not_crash_and_falls_back_to_exception_me summary = AIAgent._summarize_api_error(err) assert "HTTP 429" in summary assert "Gemini HTTP 429: quota exceeded" in summary -