odysseus-dev/odysseus · warning · HTTPException

Unknown or expired login session

Error message

Unknown or expired login session

What it means

Raised (HTTP 404) by POST /device/poll (generic device-flow poll endpoint, admin-gated) when store.get_payload(poll_id) returns None — the in-memory session store has no record for that poll_id, or the entry expired (store entries carry expires_in, default 900s from the start response). The device-flow session is created by the start endpoint and identified by the opaque poll_id returned there.

Source

Thrown at routes/device_flow.py:164

    @router.post("/device/start")
    async def device_start(request: Request):
        require_admin(request)
        form = await request.form()
        start = await _maybe_await(start_flow(request, form))
        interval = int(start.interval or 5)
        expires_in = int(start.expires_in or 900)
        poll_id = store.add(start.pending, interval=interval, expires_in=expires_in)
        response = dict(start.response)
        response.update({"poll_id": poll_id, "interval": interval, "expires_in": expires_in})
        return response

    @router.post("/device/poll")
    async def device_poll(request: Request, poll_id: str = Form(...)):
        require_admin(request)
        payload = store.get_payload(poll_id)
        if payload is None:
            raise HTTPException(404, "Unknown or expired login session")
        if store.is_throttled(poll_id):
            return {"status": "pending"}

        try:
            outcome = await _maybe_await(poll_flow(request, payload))
        except Exception:
            store.pop(poll_id)
            raise

        if outcome.status == "authorized":
            store.pop(poll_id)
            return {"status": "authorized", "endpoint": dict(outcome.endpoint or {})}
        if outcome.status == "failed":
            store.pop(poll_id)
            return {"status": "failed", "error": outcome.error or "denied"}
        if outcome.status == "slow_down":
            store.slow_down(poll_id, outcome.interval)
            return _pending_response(outcome.detail)

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. Restart the login flow: call the device start endpoint again and use the fresh poll_id it returns.
  2. Make the client stop polling once it receives a terminal status ('authorized' or 'failed') — the store entry is popped at that moment.
  3. Poll within the expires_in window returned by start (default 900 seconds).
  4. If the server restarted mid-flow, expect all outstanding poll_ids to 404; every user mid-login must restart the flow.
Defensive patterns

Strategy: validation

Validate before calling

// Client-side session liveness: stop polling on terminal states and before expiry
class DevicePoller {
  constructor(startResp) { this.id = startResp.poll_id; this.deadline = Date.now() + startResp.expires_in * 1000; }
  get expired() { return Date.now() > this.deadline; }
  shouldPoll(lastStatus) { return !this.expired && lastStatus !== 'authorized' && lastStatus !== 'failed'; }
}

Try / catch

try { r = await post('/device/poll', { poll_id }); } catch (e) { if (e.status === 404 && /Unknown or expired/.test(e.message)) { return restartDeviceFlow(); } throw e; }

Prevention

When it happens

Trigger: Polling with a poll_id that was never issued (typo/truncated ID); polling after the session expired (device codes themselves expire in ~15 minutes and the store mirrors that); polling after the session was already popped — e.g. a previous poll returned 'authorized'/'failed' and the client sends one more poll; server restart wiped the in-memory store.

Common situations: Frontend keeps polling after success because a race duplicated the poll request; the user leaves the login tab open past expiry and the resume poll 404s; the app process restarted between start and poll, dropping all stored sessions.

Related errors


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