{"record":{"id":"fbfc4f3c7c31051b","repo":"ankitects/anki","slug":"unexpected-content-disposition-header-resp-headers-get","errorCode":null,"errorMessage":"Unexpected content-disposition header: {resp.headers.get('content-disposition')}","messagePattern":"Unexpected content-disposition header: (.+?)","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"qt/aqt/addons.py","lineNumber":1122,"sourceCode":"\n# Downloading\n######################################################################\n\n\ndef download_addon(client: HttpClient, id: int) -> DownloadOk | DownloadError:\n    \"Fetch a single add-on from AnkiWeb.\"\n    try:\n        resp = client.get(f\"{aqt.appShared}download/{id}?v=2.1&p={_current_version}\")\n        if resp.status_code != 200:\n            return DownloadError(status_code=resp.status_code)\n\n        data = client.stream_content(resp)\n\n        match = re.match(\n            \"attachment; filename=(.+)\", resp.headers[\"content-disposition\"]\n        )\n        if match is None:\n            raise ValueError(\n                f\"Unexpected content-disposition header: {resp.headers.get('content-disposition')}\"\n            )\n        fname = match.group(1)\n\n        meta = extract_meta_from_download_url(resp.url)\n\n        return DownloadOk(\n            data=data,\n            filename=fname,\n            mod_time=meta.mod_time,\n            min_point_version=meta.min_point_version,\n            max_point_version=meta.max_point_version,\n            branch_index=meta.branch_index,\n        )\n    except Exception as e:\n        return DownloadError(exception=e)\n\n","sourceCodeStart":1104,"sourceCodeEnd":1140,"githubUrl":"https://github.com/ankitects/anki/blob/2fae55543cfaa82880b84787b09b0ebf06ac9e29/qt/aqt/addons.py#L1104-L1140","documentation":"download_addon() in qt/aqt/addons.py fetches an add-on package from AnkiWeb and derives the package filename from the HTTP `content-disposition` response header, expecting the exact form `attachment; filename=<name>`. When the header is missing entirely (KeyError on direct dict access) or does not match the regex `attachment; filename=(.+)`, a ValueError with the unexpected header value is raised. The exception is caught by download_addon's own `except Exception` and wrapped in a DownloadError, so callers see a failed download rather than a crash.","triggerScenarios":"Calling download_addon (or download_and_install_addon) when the AnkiWeb response's content-disposition header is absent, empty, or not of the form `attachment; filename=...` — e.g. a proxy/CDN stripping the header, a redirect/interstitial HTML page served instead of the .ankiaddon binary, or a server change to header formatting (RFC 5987 `filename*=UTF-8''...` form, quoted filename with semicolons, or `inline` disposition).","commonSituations":"Corporate proxies, antivirus gateways, or captive portals intercepting the AnkiWeb download and returning their own response without the header; AnkiWeb server-side changes or CDN misconfiguration; mirror/hosts-file overrides pointing appShared at a different server; heavily encoded or quoted filenames the naive regex can't parse.","solutions":["Retry the download later / check network setup (proxy, VPN, hosts file) to confirm you are actually reaching AnkiWeb and receiving the real add-on response.","Upgrade Anki to the latest version so any server-side header-format changes are handled by updated parsing code in addons.py.","Install the add-on manually: download the .ankiaddon file in a browser and use Tools > Add-ons > Install from file.","Patch/wrap download_addon to parse the header more tolerantly (accept filename*=/quoted forms) or fall back to a constructed filename like `<id>.ankiaddon`."],"exampleFix":"// before\nmatch = re.match(\n    \"attachment; filename=(.+)\", resp.headers[\"content-disposition\"]\n)\nif match is None:\n    raise ValueError(\n        f\"Unexpected content-disposition header: {resp.headers.get('content-disposition')}\"\n    )\nfname = match.group(1)\n\n// after\ndisposition = resp.headers.get(\"content-disposition\", \"\")\nmatch = re.match(\"attachment; filename\\*?=(?:UTF-8''|\\\"?)([^\\\";]+)\", disposition)\nif match is None:\n    # tolerate missing header by deriving filename from the add-on id\n    fname = f\"{id}.ankiaddon\"\nelse:\n    fname = match.group(1)","handlingStrategy":"validation","validationCode":"disposition = resp.headers.get(\"content-disposition\", \"\")\nif not re.match(\"attachment; filename=.+\", disposition):\n    print(f\"Skipping download: bad content-disposition {disposition!r}\")\n    return DownloadError(exception=ValueError(disposition))","typeGuard":"def is_attachment_disposition(headers) -> bool:\n    return bool(re.match(\"attachment; filename=.+\", headers.get(\"content-disposition\", \"\")))","tryCatchPattern":"result = download_addon(client, addon_id)\nif isinstance(result, DownloadError):\n    showWarning(f\"Add-on download failed: {result.exception or result.status_code}\")\n    # note: download_addon already wraps the ValueError in DownloadError","preventionTips":["Always check the DownloadError/DownloadOk union result instead of assuming success — download_addon swallows all exceptions into DownloadError.","Verify you reach the real AnkiWeb host (no proxy/captive-portal HTML) before downloading add-ons.","When reimplementing the download, handle the missing header and RFC 5987 filename*= variants defensively.","Keep Anki updated so server-side header changes are matched by parsing fixes."],"tags":["http","header-parsing","addon-download","network"],"backgroundTag":"unexpected-response-shape","analyzedSha":"2fae55543cfaa82880b84787b09b0ebf06ac9e29","analyzedAt":"2026-09-12T12:03:32.653Z","contentChangedAt":"2026-09-12T12:03:32.653Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}