{"record":{"id":"0c86b87781731987","repo":"odysseus-dev/odysseus","slug":"already-voted","errorCode":null,"errorMessage":"Already voted","messagePattern":"Already voted","errorType":"http","errorClass":"HTTPException","httpStatus":400,"severity":"info","filePath":"routes/compare/compare_routes.py","lineNumber":255,"sourceCode":"    @router.post(\"/{comp_id}/vote\")\n    def vote_comparison(\n        request: Request,\n        comp_id: str,\n        winner: str = Form(...),  # \"left\", \"right\", or \"tie\"\n    ):\n        \"\"\"Record the user's vote and reveal model names if blind.\"\"\"\n        user = get_current_user(request)\n        db = SessionLocal()\n        try:\n            comp = db.query(Comparison).filter(Comparison.id == comp_id).first()\n            if not comp:\n                raise HTTPException(404, \"Comparison not found\")\n            # SECURITY: strict ownership — null-owner Comparisons were\n            # accessible to every user.\n            if user and comp.owner != user:\n                raise HTTPException(404, \"Comparison not found\")\n            if comp.winner:\n                raise HTTPException(400, \"Already voted\")\n\n            mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {\"left\": \"a\", \"right\": \"b\"}\n\n            if winner == \"tie\":\n                comp.winner = \"tie\"\n            elif winner == \"left\":\n                comp.winner = mapping[\"left\"]\n            elif winner == \"right\":\n                comp.winner = mapping[\"right\"]\n            else:\n                raise HTTPException(400, \"winner must be 'left', 'right', or 'tie'\")\n\n            comp.voted_at = datetime.utcnow()\n            db.commit()\n\n            return {\n                \"winner\": comp.winner,\n                \"model_a\": comp.model_a,","sourceCodeStart":237,"sourceCodeEnd":273,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/compare/compare_routes.py#L237-L273","documentation":"Raised by the vote endpoint when comp.winner is already set — each comparison accepts exactly one vote. Voting is terminal state; the reveal (model names) has already happened for that comparison.","triggerScenarios":"POST /{comp_id}/vote a second time, from the same or another tab/session, after a successful vote recorded comp.winner and voted_at.","commonSituations":"Double-click on the vote button firing two POSTs; page reload resubmitting the form; retrying after a network error where the first vote actually landed.","solutions":["Treat 400 'Already voted' as success-equivalent in the client: fetch the comparison to read the revealed winner.","Disable the vote buttons after the first successful response or once comp.winner is shown.","Make the vote button idempotent client-side (lock during in-flight request)."],"exampleFix":"// before\nif (err.status === 400) throw err  // double vote crashes UI\n\n// after\ntry { await vote(id, w) } catch (e) {\n  if (e.status === 400 && /Already voted/.test(e.message)) { /* refetch reveal */ }\n  else throw e\n}","handlingStrategy":"fallback","validationCode":"comp = get_comparison(comp_id)\nif comp.get('winner'): return comp  # already voted — read reveal, skip POST","typeGuard":"const alreadyVoted = (c: {winner?: string|null}) => !!c.winner","tryCatchPattern":"try: vote(comp_id, side)\nexcept HTTPException as e:\n    if e.status_code == 400 and 'Already voted' in str(e.detail):\n        return get_comparison(comp_id)  # converge on revealed state\n    raise","preventionTips":["Disable vote controls the moment a winner is displayed.","Lock the vote button while the POST is in flight (double-click guard).","On network-error retry, first GET the comparison to check winner."],"tags":["idempotency","http-400","voting"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}