{"record":{"id":"69c3d92f668f42bf","repo":"odysseus-dev/odysseus","slug":"too-many-redirects-while-fetching-skill-bundle","errorCode":null,"errorMessage":"too many redirects while fetching skill bundle","messagePattern":"too many redirects while fetching skill bundle","errorType":"validation","errorClass":"SkillImportError","httpStatus":null,"severity":"error","filePath":"services/memory/skill_importer.py","lineNumber":262,"sourceCode":"    \"\"\"\n    current = url\n    for _ in range(_MAX_FETCH_REDIRECTS + 1):\n        pinned_ips = _resolve_and_check_url(current)\n        with httpx.Client(\n            transport=_PinnedTransport(pinned_ips),\n            follow_redirects=False,\n            timeout=timeout,\n        ) as client:\n            r = client.get(current, headers=headers)\n\n        if r.status_code in (301, 302, 303, 307, 308):\n            location = r.headers.get(\"location\")\n            if not location:\n                return r\n            current = urljoin(str(r.url), location)\n            continue\n        return r\n    raise SkillImportError(\"too many redirects while fetching skill bundle\")\n\n\ndef parse_skill_source(url: str) -> ResolvedSource:\n    \"\"\"Normalize skills.sh / GitHub web URLs into owner/repo/ref/path.\"\"\"\n    url = (url or \"\").strip()\n    if not url:\n        raise SkillImportError(\"URL is required\")\n\n    # ``urlparse`` only reports an unambiguous scheme when the URL carries the\n    # ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a\n    # schemeless ``host:port`` both parse a \"scheme\" that is not one, so they\n    # fall through to the host check below and are rejected on the host instead.\n    scheme = urlparse(url).scheme.lower()\n    if scheme not in (\"http\", \"https\"):\n        if scheme and url.lower().startswith(f\"{scheme}://\"):\n            raise SkillImportError(f\"unsupported URL scheme: {scheme}\")\n        # Schemeless \"github.com/owner/repo\" — accept only a supported host.\n        rough_host = (urlparse(\"//\" + url).hostname or \"\").lower()","sourceCodeStart":244,"sourceCodeEnd":280,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/services/memory/skill_importer.py#L244-L280","documentation":"Raised by the manual redirect loop in _get_checked (services/memory/skill_importer.py) after _MAX_FETCH_REDIRECTS (5) hops: each 301/302/303/307/308 response with a Location header advances 'current' and every hop is re-validated (SSRF check per hop), and exceeding the budget aborts with this error. A redirect without Location is returned as the final response instead of erroring. The cap prevents both redirect loops and validation-bypass chains.","triggerScenarios":"A skills.sh URL whose redirect chain is longer than 5 hops (tracker → CDN → github.com); a redirect loop (A→B→A) that would otherwise spin forever; skills.sh or an intermediary adding sequential analytics redirects; chains that never reach a terminal 2xx within the budget.","commonSituations":"skills.sh changing its redirect architecture and inserting extra hops; URL shorteners stacked on shorteners in shared skill links; a misconfigured repo redirect on GitHub's side chaining through several moved repos.","solutions":["Trace the chain manually to count hops: curl -sIL <url> | grep -i '^location' — if it exceeds 5 before landing on github.com, use the final GitHub URL directly for import.","Copy the destination URL from the last Location and paste the canonical https://github.com/.../tree/<ref>/<path> form into the importer.","If the chain loops (same Location repeating), the linking site is broken — report it and import from GitHub directly.","Only raise _MAX_FETCH_REDIRECTS if you have audited why legitimate chains are longer; the cap is a safety bound."],"exampleFix":"# before\nimport_skill(\"https://skills.sh/owner/repo/skill\")\nSkillImportError: too many redirects while fetching skill bundle\n\n# after\n$ curl -sIL https://skills.sh/owner/repo/skill | grep -i '^location' | tail -1\nhttps://github.com/owner/repo/tree/main/skills/skill\n>>> import_skill(\"https://github.com/owner/repo/tree/main/skills/skill\")","handlingStrategy":"validation","validationCode":"import httpx\n\ndef final_url_after_redirects(url: str, max_hops: int = 5) -> str | None:\n    seen = 0\n    with httpx.Client(follow_redirects=False, timeout=20) as c:\n        while seen < max_hops:\n            r = c.get(url)\n            if r.status_code not in (301, 302, 303, 307, 308):\n                return str(r.url)\n            loc = r.headers.get('location')\n            if not loc:\n                return str(r.url)\n            url = str(httpx.URL(url).join(loc))\n            seen += 1\n    return None  # exceeds budget — do not fetch","typeGuard":"def redirect_chain_within_budget(url: str, max_hops: int = 5) -> bool:\n    return final_url_after_redirects(url, max_hops) is not None","tryCatchPattern":"from services.memory.skill_importer import SkillImportError\n\ntry:\n    import_skill(url)\nexcept SkillImportError as e:\n    if 'too many redirects' in str(e):\n        # resolve manually and import the destination directly\n        dest = final_url_after_redirects(url, max_hops=10)\n        import_skill(dest)  # dest must be a github.com URL\n    else:\n        raise","preventionTips":["Prefer canonical github.com URLs over skills.sh/shortened links to sidestep redirect chains entirely.","Keep a bounded hop limit in any redirect-following fetcher you write; loops otherwise hang forever.","Trace chains with curl -sIL before automating imports of new link sources."],"tags":["network","http-redirects","security","skill-import"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}