Skip to content

report_exercise_outcome: record the outcome when session_id is empty - #891

Merged
mircealungu merged 1 commit into
masterfrom
fix/exercise-outcome-empty-session-id
Oct 25, 2026
Merged

mircealungu merged 1 commit into
masterfrom
fix/exercise-outcome-empty-session-id

Conversation

@mircealungu

Copy link
Copy Markdown
Member

1. What happened

FMD alert, 20 Oct 2026, 14:58:59 (server time, 12:58:59 UTC):

ValueError: invalid literal for int() with base 10: ''
  zeeguu/api/endpoints/exercises.py, line 210, in report_exercise_outcome
    session_id = int(request.form.get("session_id"))

A learner on the web (Safari on a Mac, zeeguu.org) answered exercises. The web app sent each answer to POST /report_exercise_outcome with an empty session_id. The endpoint crashed with a 500 before it saved anything, so these answers were never recorded. No Exercise row was written, and the word's schedule (the next review date) was not updated. Two answers were lost (12:58:59 and 12:59:00 UTC). The answers this learner sent after that (from 12:59:17 on) went through.

2. Why it happened

Step 1: what the client sends. web/src/api/exercises.js builds the form body with qs:

let payload = { ..., session_id: exerciseSessionId };
this._post(`report_exercise_outcome`, qs.stringify(payload));

When exerciseSessionId is null, qs.stringify writes the key with no value:

> qs.stringify({session_id: null, c: 1})
'session_id=&c=1'

So the server receives session_id= (an empty string), not a missing field.

Step 2: the server parses it. In zeeguu/api/endpoints/exercises.py:210:

session_id = int(request.form.get("session_id"))

request.form.get("session_id") returns '' and int('') raises ValueError. This line runs before the try: block, so route_wrappers.py:134 sees the exception, Flask answers with a 500, and user_word.report_exercise_outcome(...) is never called. (A request with no session_id at all would crash the same way: int(None) raises TypeError.)

Step 3: the database never needed a session. zeeguu/core/model/exercise.py:26:

session_id = db.Column(
    db.Integer, db.ForeignKey(UserExerciseSession.id), nullable=True
)

The session id only says which practice session an answer belongs to (for session duration and the teacher and activity views). The answer itself, and the scheduling that follows from it, do not need it. So the crash threw away data the DB could have stored without problems.

Why the client had no session id (also see the companion note below): the session id comes from POST /exercise_session_start, which useExerciseSession / useSession (web/src/hooks/useSession.js:40-47) fire when the exercises load. Until that response arrives, getExerciseSessionId() returns null. In the prod logs for this learner, exercise_session_start returned 200 at 12:58:47 UTC, 12 seconds before the failing answer, and the later answers in the same session did carry the id. I could not find the code path that left the id null at 12:58:59, so there is no web PR. See "What I could not explain" below.

3. The fix

One line in zeeguu/api/endpoints/exercises.py:

session_id = request.form.get("session_id", type=int)

Werkzeug's MultiDict.get(key, type=int) returns the default (None) when the conversion raises ValueError, and also when the key is missing. So:

client sends before after
session_id=4711 4711 4711
session_id= 500, answer lost None, answer saved
no session_id 500 (TypeError) None, answer saved
session_id=abc 500 None, answer saved

None goes to Exercise(session_id=None), which the nullable column accepts. The same type=int pattern is already used for optional form ints in audio_lessons.py:126 (timezone_offset).

A test was added: test_outcome_without_session_id_is_still_recorded in zeeguu/api/test/test_exercise_session.py. It posts one outcome with session_id= and one with no session_id, then checks that both get OK and that both Exercise rows exist with session_id IS NULL.

4. Why this is safe

  • Clients that send a valid id: same result as before ("4711" → 4711).
  • Older app versions in the wild: the wire format did not change. The endpoint accepts everything it accepted before, plus the inputs that used to 500. Native builds that sometimes send an empty id now get their answers recorded too.
  • DB: exercise.session_id is nullable=True with a FK to user_exercise_session. NULL does not violate the FK.
  • Readers of exercise.session_id (user_exercise_session.py:79, user_stats.py:148/500/1095, session_history.py:402) all filter Exercise.session_id == <some session>. A NULL row is not attached to any session, so it does not appear in session summaries or session durations. That is the only cost: the answer is recorded and scheduled, but not shown as part of a session.
  • Other caller: the verbal flashcards endpoint does not use this code path (it has its own _parse_optional_session_id).
  • Trade-off: a malformed id like abc is now dropped silently instead of returning a 500. I chose this because losing the learner's answer is worse than losing the session link.

5. How it was tested

  • pytest zeeguu/api/test/test_exercise_session.py zeeguu/api/test/test_user_data_ownership.py: 39 passed.
  • Before the fix, the new test fails with exactly the prod error: ValueError: invalid literal for int() with base 10: ''.

How to verify in prod (after deploy)

  1. No more 500s on this endpoint:
    ssh zeeguu@$ZORGIP "sudo journalctl -t zeeguu-api --since='7 days ago' | grep 'report_exercise_outcome' | grep -c '\" 500 '"
    This should stay at 0. Also check that no FMD alert email arrives with invalid literal for int() from exercises.py.
  2. See how often clients really send no session id (rows saved with NULL since the deploy):
    SELECT COUNT(*), COUNT(DISTINCT user_word_id)
    FROM exercise WHERE session_id IS NULL AND time > '<deploy time>';
    If this count grows by more than a handful a week, the client has a real bug that is worth hunting down.

What I could not explain (web side)

The 14-day journal has exactly 2 of these 500s, both from this one learner, one second apart. For that learner, exercise_session_start succeeded at 12:58:47 UTC and later answers in the same session carried the id. In the web code, getExerciseSessionId() reads a ref that is set in the start callback (useSession.js:44) and is only cleared on unmount or a learned_language change. A language change remounts the page anyway, because AppLayout.js:111 keys on learned_language. No unmount happened (there was no second exercise_session_start and no exercise_session_end until 13:00:40). So I could not find a concrete client cause, and per the agreed rule I did not change the client. The NULL count in step 2 above will show whether this happens often enough to need a client fix.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

ArchLens - No architecturally relevant changes to the existing views

int(request.form.get("session_id")) raised ValueError on "session_id="
and the answer was lost with a 500. exercise.session_id is nullable, so
parse it with type=int, which gives None for a missing, empty or
non-numeric value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mircealungu
mircealungu force-pushed the fix/exercise-outcome-empty-session-id branch from 5fda5e7 to 02552c1 Compare October 25, 2026 17:12
@mircealungu
mircealungu merged commit c1289fd into master Oct 25, 2026
2 checks passed
@mircealungu
mircealungu deleted the fix/exercise-outcome-empty-session-id branch October 25, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant