{"record":{"id":"b632043b0b838175","repo":"odysseus-dev/odysseus","slug":"outbound-url-blocked-host-did-not-resolve-to-a-us","errorCode":null,"errorMessage":"outbound URL blocked: host did not resolve to a usable address","messagePattern":"outbound URL blocked: host did not resolve to a usable address","errorType":"validation","errorClass":"SkillImportError","httpStatus":null,"severity":"error","filePath":"services/memory/skill_importer.py","lineNumber":115,"sourceCode":"    \"\"\"Return the exact address snapshot approved for one fetch hop.\"\"\"\n    resolved_ips: List[str] = []\n\n    def _recording_resolver(host: str) -> List[str]:\n        answers = list(_default_resolver(host))\n        resolved_ips[:] = answers\n        return answers\n\n    ok, reason = check_outbound_url(\n        url,\n        block_private=True,\n        resolver=_recording_resolver,\n    )\n    if not ok:\n        raise SkillImportError(f\"outbound URL blocked: {reason}\")\n\n    pinned_ips = _validated_ips(resolved_ips)\n    if not pinned_ips:\n        raise SkillImportError(\"outbound URL blocked: host did not resolve to a usable address\")\n    return pinned_ips\n\n\n# Backward compatibility alias for tests importing _check_fetch_url directly\n_check_fetch_url = _resolve_and_check_url\n\n\nclass _PinnedBackend(httpcore.NetworkBackend):\n    \"\"\"Connect only to addresses from one validated DNS snapshot.\"\"\"\n\n    def __init__(self, ips: List[ipaddress._BaseAddress]):\n        self._ips = [str(ip) for ip in ips]\n        self._real = httpcore.SyncBackend()\n\n    def connect_tcp(\n        self,\n        host: str,\n        port: int,","sourceCodeStart":97,"sourceCodeEnd":133,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/services/memory/skill_importer.py#L97-L133","documentation":"Raised in _resolve_and_check_url (services/memory/skill_importer.py) when check_outbound_url passed but none of the recorded DNS answers could be parsed into a usable ipaddress object (_validated_ips returned nothing). The importer pins every connection to the validated IP snapshot (via _PinnedBackend), so an empty/non-parseable answer set is fatal even though the URL itself looked acceptable.","triggerScenarios":"A host whose DNS returns only record types the resolver surfaces as non-IP strings (bare CNAME chains, IPv6 addresses that fail ipaddress parsing after the '%' scope-strip, or resolver software returning nonstandard values); a recording resolver that returned an empty list the checker happened to tolerate; DNS64/NAT64 environments returning synthetic addresses in odd formats.","commonSituations":"Exotic or broken local resolvers (systemd-resolved in odd modes, VPN DNS plugins); hosts that are CNAME-only at the moment of resolution; transient DNS misconfiguration during import; test environments mocking DNS with non-IP strings.","solutions":["Check what the host actually resolves to: dig +short <host> / python -c 'import socket; print(socket.getaddrinfo(\"<host>\", 443))'.","Fix or bypass the broken resolver (point /etc/resolv.conf at a working resolver, disconnect the VPN DNS).","Retry the import — transient partial DNS answers can produce an empty validated set once and succeed next time.","If the host genuinely has no A/AAAA record, the URL is dead; find the correct GitHub URL for the skill."],"exampleFix":"# before\n$ python -c \"import socket; print(socket.getaddrinfo('raw.githubusercontent.com', 443))\"\n[]   # resolver returns nothing parseable\nSkillImportError: outbound URL blocked: host did not resolve to a usable address\n\n# after\n$ sudo resolvectl flush-caches && dig +short raw.githubusercontent.com   # returns real A records\n# retry the import","handlingStrategy":"retry","validationCode":"import socket\n\ndef host_resolves_to_ips(host: str) -> list[str]:\n    try:\n        return list({info[4][0] for info in socket.getaddrinfo(host, 443, proto=socket.IPPROTO_TCP)})\n    except socket.gaierror:\n        return []","typeGuard":"def host_has_usable_address(host: str) -> bool:\n    ips = host_resolves_to_ips(host)\n    return bool(ips) and all(ip.count('.') == 3 or ':' in ip for ip in ips)","tryCatchPattern":"import time\nfrom services.memory.skill_importer import SkillImportError\n\nfor attempt in range(3):\n    try:\n        import_skill(url)\n        break\n    except SkillImportError as e:\n        if 'did not resolve' in str(e) and attempt < 2:\n            time.sleep(2 * (attempt + 1))  # transient DNS; flush caches / check resolver if persistent\n            continue\n        raise","preventionTips":["Preflight DNS with getaddrinfo before starting an import job.","Use a stable resolver (public DNS) in containers/CI to avoid flaky answer sets.","If a VPN's DNS plugin breaks resolution, import outside the VPN or fix the plugin."],"tags":["network","dns","security","skill-import"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}