odysseus-dev/odysseus · info · HTTPException

Already voted

Error message

Already voted

What it means

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.

Source

Thrown at routes/compare/compare_routes.py:255

    @router.post("/{comp_id}/vote")
    def vote_comparison(
        request: Request,
        comp_id: str,
        winner: str = Form(...),  # "left", "right", or "tie"
    ):
        """Record the user's vote and reveal model names if blind."""
        user = get_current_user(request)
        db = SessionLocal()
        try:
            comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
            if not comp:
                raise HTTPException(404, "Comparison not found")
            # SECURITY: strict ownership — null-owner Comparisons were
            # accessible to every user.
            if user and comp.owner != user:
                raise HTTPException(404, "Comparison not found")
            if comp.winner:
                raise HTTPException(400, "Already voted")

            mapping = json.loads(comp.blind_mapping) if comp.blind_mapping else {"left": "a", "right": "b"}

            if winner == "tie":
                comp.winner = "tie"
            elif winner == "left":
                comp.winner = mapping["left"]
            elif winner == "right":
                comp.winner = mapping["right"]
            else:
                raise HTTPException(400, "winner must be 'left', 'right', or 'tie'")

            comp.voted_at = datetime.utcnow()
            db.commit()

            return {
                "winner": comp.winner,
                "model_a": comp.model_a,

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Treat 400 'Already voted' as success-equivalent in the client: fetch the comparison to read the revealed winner.
  2. Disable the vote buttons after the first successful response or once comp.winner is shown.
  3. Make the vote button idempotent client-side (lock during in-flight request).

Example fix

// before
if (err.status === 400) throw err  // double vote crashes UI

// after
try { await vote(id, w) } catch (e) {
  if (e.status === 400 && /Already voted/.test(e.message)) { /* refetch reveal */ }
  else throw e
}
Defensive patterns

Strategy: fallback

Validate before calling

comp = get_comparison(comp_id)
if comp.get('winner'): return comp  # already voted — read reveal, skip POST

Type guard

const alreadyVoted = (c: {winner?: string|null}) => !!c.winner

Try / catch

try: vote(comp_id, side)
except HTTPException as e:
    if e.status_code == 400 and 'Already voted' in str(e.detail):
        return get_comparison(comp_id)  # converge on revealed state
    raise

Prevention

When it happens

Trigger: POST /{comp_id}/vote a second time, from the same or another tab/session, after a successful vote recorded comp.winner and voted_at.

Common situations: 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.

Related errors


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/0c86b87781731987. Report an issue: GitHub.