Skip to content

Commit 671395d

Browse files
committed
fix(db): commit writes before response to close register->login race
FastAPI (>=0.106) runs yield-dependency teardown after the response is sent, so get_db's teardown commit made /auth/register return 201 before the INSERT was durable; an immediate follow-up /auth/login read the pre-commit snapshot and got 401. This is the intermittent 'registered but cannot log in' behavior. - add db.commit_now() and call it in mutating routes (register, conversation create/delete, secret put/delete) so writes are durable before the response is sent - start_run already committed early (worker thread dependency) Verified live: 3x login(401)->register(201)->immediate-login(200), single-label/case-insensitive emails 201/200. 96 tests, ruff, pyright green.
1 parent 5f80e8a commit 671395d

4 files changed

Lines changed: 24 additions & 4 deletions

File tree

‎app/db.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,14 @@ def get_session_factory() -> sessionmaker[Session]:
5757

5858

5959
def get_db() -> Iterator[Session]:
60-
"""FastAPI dependency yielding a request-scoped session."""
60+
"""FastAPI dependency yielding a request-scoped session.
61+
62+
The commit happens BEFORE the response is returned: since
63+
FastAPI 0.106, teardown of yield-dependencies runs after the
64+
response has been sent, so committing in teardown would let a
65+
client that fires a follow-up request immediately (register ->
66+
login) read against the pre-commit snapshot and fail.
67+
"""
6168
factory = get_session_factory()
6269
db = factory()
6370
try:
@@ -70,6 +77,13 @@ def get_db() -> Iterator[Session]:
7077
db.close()
7178

7279

80+
def commit_now(db: Session) -> None:
81+
"""Commit immediately for mutating routes, so the write is durable
82+
before the response is sent (see get_db docstring). Idempotent: a
83+
later commit of a clean session is a no-op."""
84+
db.commit()
85+
86+
7387
def reset_engine_for_tests(db_url: str) -> Engine:
7488
"""Point the module-level singletons at a fresh (test) database."""
7589
global _engine, _session_factory

‎app/routes/auth.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
hash_password,
1616
verify_password,
1717
)
18-
from ..db import get_db
18+
from ..db import commit_now, get_db
1919
from ..models import User, new_id
2020
from ..schemas import RefreshIn, RegisterIn, TokenPair, UserOut
2121

@@ -71,6 +71,7 @@ def register(body: RegisterIn, db: Session = Depends(get_db)) -> TokenPair:
7171
)
7272
db.add(user)
7373
db.flush()
74+
commit_now(db)
7475
return _token_pair(user)
7576

7677

‎app/routes/conversations.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sqlalchemy.orm import Session
1111

1212
from ..controller.manager import Controller, get_controller
13-
from ..db import get_db
13+
from ..db import commit_now, get_db
1414
from ..models import Conversation, Run, User, new_id
1515
from ..routes.auth import authenticate_user
1616
from ..schemas import (
@@ -55,6 +55,7 @@ def create_conversation(
5555
conversation = Conversation(id=new_id("cnv"), user_id=user.id, title=body.title)
5656
db.add(conversation)
5757
db.flush()
58+
commit_now(db)
5859
return ConversationOut(
5960
id=conversation.id,
6061
title=conversation.title,
@@ -103,6 +104,7 @@ def delete_conversation(
103104
) -> None:
104105
conversation = _own_conversation(db, user, conversation_id)
105106
db.delete(conversation)
107+
commit_now(db)
106108

107109

108110
@router.post("/{conversation_id}/runs", response_model=RunOut, status_code=status.HTTP_202_ACCEPTED)

‎app/routes/secrets.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from sqlalchemy.orm import Session
1313

1414
from ..core import secrets_store
15-
from ..db import get_db
15+
from ..db import commit_now, get_db
1616
from ..models import Secret, User, new_id
1717
from ..routes.auth import authenticate_user
1818
from ..schemas import SecretIn, SecretOut
@@ -33,6 +33,7 @@ def put_secret(
3333
if existing is not None:
3434
existing.value_encrypted = secrets_store.encrypt(body.value)
3535
db.flush()
36+
commit_now(db)
3637
return SecretOut(name=name, created_at=existing.created_at)
3738
secret = Secret(
3839
id=new_id("sec"),
@@ -42,6 +43,7 @@ def put_secret(
4243
)
4344
db.add(secret)
4445
db.flush()
46+
commit_now(db)
4547
return SecretOut(name=name, created_at=secret.created_at)
4648

4749

@@ -63,3 +65,4 @@ def delete_secret(
6365
if secret is None:
6466
raise HTTPException(status.HTTP_404_NOT_FOUND, "secret not found")
6567
db.delete(secret)
68+
commit_now(db)

0 commit comments

Comments
 (0)