diff --git a/cron/scheduler.py b/cron/scheduler.py index 1848cb29a..34bf76b34 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -626,6 +626,15 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: except Exception as e: logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e) + # Apply IPv4 preference if configured. + try: + from hermes_constants import apply_ipv4_preference + _net_cfg = _cfg.get("network", {}) + if isinstance(_net_cfg, dict) and _net_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) + except Exception: + pass + # Reasoning config from config.yaml from hermes_constants import parse_reasoning_effort effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip() diff --git a/gateway/run.py b/gateway/run.py index b4924f8f3..1dcb87d85 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -206,6 +206,15 @@ if _config_path.exists(): except Exception: pass # Non-fatal; gateway can still run with .env values +# Apply IPv4 preference if configured (before any HTTP clients are created). +try: + from hermes_constants import apply_ipv4_preference + _network_cfg = (_cfg if '_cfg' in dir() else {}).get("network", {}) + if isinstance(_network_cfg, dict) and _network_cfg.get("force_ipv4"): + apply_ipv4_preference(force=True) +except Exception: + pass + # Validate config structure early — log warnings so gateway operators see problems try: from hermes_cli.config import print_config_warnings diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 5faa767a3..3d8004cd3 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -706,6 +706,14 @@ DEFAULT_CONFIG = { "backup_count": 3, # Number of rotated backup files to keep }, + # Network settings — workarounds for connectivity issues. + "network": { + # Force IPv4 connections. On servers with broken or unreachable IPv6, + # Python tries AAAA records first and hangs for the full TCP timeout + # before falling back to IPv4. Set to true to skip IPv6 entirely. + "force_ipv4": False, + }, + # Config schema version - bump this when adding new required fields "_config_version": 16, } diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 037c0a72f..9f73c07a5 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -151,6 +151,18 @@ try: except Exception: pass # best-effort — don't crash the CLI if logging setup fails +# Apply IPv4 preference early, before any HTTP clients are created. +try: + from hermes_cli.config import load_config as _load_config_early + from hermes_constants import apply_ipv4_preference as _apply_ipv4 + _early_cfg = _load_config_early() + _net = _early_cfg.get("network", {}) + if isinstance(_net, dict) and _net.get("force_ipv4"): + _apply_ipv4(force=True) + del _early_cfg, _net +except Exception: + pass # best-effort — don't crash if config isn't available yet + import logging import time as _time from datetime import datetime diff --git a/hermes_constants.py b/hermes_constants.py index 85955d548..40b4da569 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -216,6 +216,51 @@ def get_env_path() -> Path: return get_hermes_home() / ".env" +# ─── Network Preferences ───────────────────────────────────────────────────── + + +def apply_ipv4_preference(force: bool = False) -> None: + """Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections. + + On servers with broken or unreachable IPv6, Python tries AAAA records + first and hangs for the full TCP timeout before falling back to IPv4. + This affects httpx, requests, urllib, the OpenAI SDK — everything that + uses ``socket.getaddrinfo``. + + When *force* is True, patches ``getaddrinfo`` so that calls with + ``family=AF_UNSPEC`` (the default) resolve as ``AF_INET`` instead, + skipping IPv6 entirely. If no A record exists, falls back to the + original unfiltered resolution so pure-IPv6 hosts still work. + + Safe to call multiple times — only patches once. + Set ``network.force_ipv4: true`` in ``config.yaml`` to enable. + """ + if not force: + return + + import socket + + # Guard against double-patching + if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False): + return + + _original_getaddrinfo = socket.getaddrinfo + + def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + if family == 0: # AF_UNSPEC — caller didn't request a specific family + try: + return _original_getaddrinfo( + host, port, socket.AF_INET, type, proto, flags + ) + except socket.gaierror: + # No A record — fall back to full resolution (pure-IPv6 hosts) + return _original_getaddrinfo(host, port, family, type, proto, flags) + return _original_getaddrinfo(host, port, family, type, proto, flags) + + _ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined] + socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment] + + OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" diff --git a/tests/test_ipv4_preference.py b/tests/test_ipv4_preference.py new file mode 100644 index 000000000..c57016e22 --- /dev/null +++ b/tests/test_ipv4_preference.py @@ -0,0 +1,114 @@ +"""Tests for network.force_ipv4 — the socket.getaddrinfo monkey-patch.""" + +import importlib +import socket +from unittest.mock import patch, MagicMock + +import pytest + + +def _reload_constants(): + """Reload hermes_constants to get a fresh apply_ipv4_preference.""" + import hermes_constants + importlib.reload(hermes_constants) + return hermes_constants + + +class TestApplyIPv4Preference: + """Tests for apply_ipv4_preference().""" + + def setup_method(self): + """Save the original getaddrinfo before each test.""" + self._original = socket.getaddrinfo + + def teardown_method(self): + """Restore the original getaddrinfo after each test.""" + socket.getaddrinfo = self._original + + def test_noop_when_force_false(self): + """No patch when force=False.""" + from hermes_constants import apply_ipv4_preference + original = socket.getaddrinfo + apply_ipv4_preference(force=False) + assert socket.getaddrinfo is original + + def test_patches_getaddrinfo_when_forced(self): + """Patches socket.getaddrinfo when force=True.""" + from hermes_constants import apply_ipv4_preference + original = socket.getaddrinfo + apply_ipv4_preference(force=True) + assert socket.getaddrinfo is not original + assert getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False) is True + + def test_double_patch_is_safe(self): + """Calling apply twice doesn't double-wrap.""" + from hermes_constants import apply_ipv4_preference + apply_ipv4_preference(force=True) + first_patch = socket.getaddrinfo + apply_ipv4_preference(force=True) + assert socket.getaddrinfo is first_patch + + def test_af_unspec_becomes_af_inet(self): + """AF_UNSPEC (default) calls get rewritten to AF_INET.""" + from hermes_constants import apply_ipv4_preference + + calls = [] + original = socket.getaddrinfo + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + calls.append(family) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + # Call with default family (AF_UNSPEC = 0) + socket.getaddrinfo("example.com", 80) + assert calls[-1] == socket.AF_INET, "AF_UNSPEC should be rewritten to AF_INET" + + def test_explicit_family_preserved(self): + """Explicit AF_INET6 requests are not intercepted.""" + from hermes_constants import apply_ipv4_preference + + calls = [] + original = socket.getaddrinfo + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + calls.append(family) + return [(family, socket.SOCK_STREAM, 6, "", ("::1", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + socket.getaddrinfo("example.com", 80, family=socket.AF_INET6) + assert calls[-1] == socket.AF_INET6, "Explicit AF_INET6 should pass through" + + def test_fallback_on_gaierror(self): + """Falls back to AF_UNSPEC if AF_INET resolution fails.""" + from hermes_constants import apply_ipv4_preference + + call_families = [] + + def mock_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): + call_families.append(family) + if family == socket.AF_INET: + raise socket.gaierror("No A record") + # AF_UNSPEC fallback returns IPv6 + return [(socket.AF_INET6, socket.SOCK_STREAM, 6, "", ("::1", 80))] + + socket.getaddrinfo = mock_getaddrinfo + apply_ipv4_preference(force=True) + + result = socket.getaddrinfo("ipv6only.example.com", 80) + # Should have tried AF_INET first, then fallen back to AF_UNSPEC + assert call_families == [socket.AF_INET, 0] + assert result[0][0] == socket.AF_INET6 + + +class TestConfigDefault: + """Verify network section exists in DEFAULT_CONFIG.""" + + def test_network_section_in_default_config(self): + from hermes_cli.config import DEFAULT_CONFIG + assert "network" in DEFAULT_CONFIG + assert DEFAULT_CONFIG["network"]["force_ipv4"] is False