{"record":{"id":"2fe94988a33294f6","repo":"ankitects/anki","slug":"unhandled-op-changes-level-op-changes-type","errorCode":null,"errorMessage":"unhandled op changes level: {op_changes_type}","messagePattern":"unhandled op changes level: (.+?)","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"qt/aqt/mediasrv.py","lineNumber":1238,"sourceCode":"def raw_backend_request(endpoint: str) -> Callable[[], bytes]:\n    # check for key at startup\n    from anki._backend import RustBackend\n\n    assert hasattr(RustBackend, f\"{endpoint}_raw\")\n\n    def wrapped() -> bytes:\n        output = getattr(aqt.mw.col._backend, f\"{endpoint}_raw\")(request.data)\n        op_changes_type = int(request.headers.get(\"Anki-Op-Changes\", \"0\"))\n        if op_changes_type:\n            op_message_types = (OpChanges, OpChangesOnly, NestedOpChanges)\n            try:\n                response = op_message_types[op_changes_type - 1]()\n                response.ParseFromString(output)\n                changes: Any = response\n                for _ in range(op_changes_type - 1):\n                    changes = changes.changes\n            except IndexError:\n                raise ValueError(f\"unhandled op changes level: {op_changes_type}\")\n\n            def handle_on_main() -> None:\n                handler = active_window_or_main()\n                on_op_finished(aqt.mw, changes, handler)\n\n            aqt.mw.taskman.run_on_main(handle_on_main)\n\n        return output\n\n    return wrapped\n\n\n# all methods in here require a collection\npost_handlers = {\n    stringcase.camelcase(handler.__name__): handler for handler in post_handler_list\n} | {\n    stringcase.camelcase(handler): raw_backend_request(handler)\n    for handler in exposed_backend_list","sourceCodeStart":1220,"sourceCodeEnd":1256,"githubUrl":"https://github.com/ankitects/anki/blob/2fae55543cfaa82880b84787b09b0ebf06ac9e29/qt/aqt/mediasrv.py#L1220-L1256","documentation":"raw_backend_request() in qt/aqt/mediasrv.py forwards a raw protobuf request to the Rust backend for an exposed backend endpoint. If the client sends an `Anki-Op-Changes` header, the server maps its integer value (1, 2, 3) to the protobuf message types (OpChanges, OpChangesOnly, NestedOpChanges) via tuple indexing. An integer outside 1..3 raises IndexError, which is converted to `ValueError(f\"unhandled op changes level: {op_changes_type}\")` — an internal invariant check that the declared op-changes nesting level is one the server knows how to decode.","triggerScenarios":"An HTTP POST to a /_anki backend endpoint carrying an `Anki-Op-Changes` header with a value other than 1, 2, or 3 (e.g. 0 is skipped, but 4+, negative, or a non-numeric string would also break the tuple lookup), sent by mismatched frontend JS (ts/lib/generated) that is newer or older than the installed mediasrv.py backend code.","commonSituations":"Version skew between the web frontend bundle and the Python backend after a partial upgrade of Anki or an add-in injecting custom requests; a developer experimenting with the internal mediasrv API using a new Anki-Op-Changes level added in the generated client but not yet in the (OpChanges, OpChangesOnly, NestedOpChanges) tuple; custom tooling/scripting against the local media server.","solutions":["Ensure frontend and backend are in sync — reinstall/upgrade Anki completely so ts/lib generated code and qt/aqt/mediasrv.py come from the same version.","Remove or correct the `Anki-Op-Changes` header on any custom requests; only send values 1 (OpChanges), 2 (OpChangesOnly), or 3 (NestedOpChanges).","If you maintain a fork, add the new message type to the op_message_types tuple in mediasrv.py and extend the range check.","Report to the Anki developers if a stock client triggers it, since this is an internal invariant violation."],"exampleFix":"// before\nop_message_types = (OpChanges, OpChangesOnly, NestedOpChanges)\ntry:\n    response = op_message_types[op_changes_type - 1]()\n    ...\nexcept IndexError:\n    raise ValueError(f\"unhandled op changes level: {op_changes_type}\")\n\n// after\nop_message_types = (OpChanges, OpChangesOnly, NestedOpChanges)\nif not 1 <= op_changes_type <= len(op_message_types):\n    raise ValueError(f\"unhandled op changes level: {op_changes_type}\")\nresponse = op_message_types[op_changes_type - 1]()\nresponse.ParseFromString(output)","handlingStrategy":"validation","validationCode":"level = int(request.headers.get(\"Anki-Op-Changes\", \"0\"))\nif level and not 1 <= level <= 3:\n    raise ValueError(f\"Anki-Op-Changes must be 1..3, got {level}\")","typeGuard":"def is_valid_op_changes_level(value: object) -> bool:\n    return isinstance(value, int) and 1 <= value <= 3","tryCatchPattern":"try:\n    data = await postJson(endpoint, payload)\nexcept Exception as exc:\n    print(f\"backend request failed (check Anki-Op-Changes level and version sync): {exc}\")","preventionTips":["Only send Anki-Op-Changes values 1, 2, or 3 (OpChanges, OpChangesOnly, NestedOpChanges).","Keep the generated frontend client and the Python backend from the same Anki version.","Use the official ts/lib generated backend module rather than hand-crafting requests to raw backend endpoints.","Treat this ValueError as an internal invariant: it signals version skew, not bad user input."],"tags":["protobuf","internal-api","version-skew","http"],"backgroundTag":"invalid-enum-value","analyzedSha":"2fae55543cfaa82880b84787b09b0ebf06ac9e29","analyzedAt":"2026-09-12T12:03:32.653Z","contentChangedAt":"2026-09-12T12:03:32.653Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}