Skip to content

Commit 6c74062

Browse files
committed
fix(api): break the import cycle, stop pinning DB connections, revive retries
The module packages each re-exported their router while core.dependencies imports identity.models, so importing any submodule dragged the router graph back through a half-initialised core.dependencies. `import helprs.core.dependencies` failed on its own; the app booted only because main.py happens to import admin.views -- which imports a model -- first, so re-sorting imports would have broken startup. That cycle was also the reason two services carried function-level imports. The packages are bare now, both workarounds are gone, and a test imports each module in a fresh interpreter so the cycle cannot grow back. FastAPI tears yield-dependencies down only after a streaming body ends, so the SSE route's DbSession -- and the one behind its auth dependency -- kept a pooled connection and an open transaction for the life of the stream, up to CONTAINER_TTL_SECONDS. With 15 connections per worker, 15 viewers starved the worker, and each idle-in-transaction backend blocked VACUUM. finalize_session did the same across wait_container. Both now do their database work in short transactions either side of the wait. On client disconnect only finalization was detached, not the event drain. The drain stopped with the generator, so finalize_session then built its scorecard from a truncated history and posted that to the PR -- defeating the thing detaching was meant to protect. Both are detached together. Also: - container status persisted the enum member NAME ("RUNNING") while the column default is a value ("pending"), so a DB-defaulted row raised LookupError through the ORM. values_callable plus a data migration. - per_page=0 divided by zero and per_page=-5 reached Postgres as a negative LIMIT; ?status=bogus raised ValueError mid-handler. All three were 500s and are now 422s, constrained in the signature. - a failed container start rolled back the FAILED status *and* the session row, so the user got a 502 and a dashboard showing nothing happened. - mark_failed wrote "failed" and the replay query never selected it, so one handler exception dropped a delivery for good and MAX_RETRY_COUNT was unreachable. Failed events are replayable and re-claimable, spaced by updated_at, and abandonment is now a state that can actually occur. - core.dependencies went through identity's repository instead of running its own SELECT over another module's table.
1 parent 49b0258 commit 6c74062

18 files changed

Lines changed: 491 additions & 140 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Normalize container_sessions.status to enum values, not member names.
2+
3+
Without ``values_callable`` on the Enum type, SQLAlchemy persisted the member
4+
NAME ("RUNNING") while the column's server_default is the value ("pending")
5+
and the API returns the value. Rows written by the ORM and rows written by the
6+
database default therefore disagreed, and reading a value-cased row through
7+
the ORM raised ``LookupError``.
8+
9+
The model now maps by value; this migration brings existing rows in line. Only
10+
rows written by the ORM need changing, and every enum member is lower-case
11+
with no internal case distinctions, so ``lower()`` is exactly the mapping.
12+
13+
Revision ID: c4d5e6f7a0b1
14+
Revises: b3c4d5e6f9a0
15+
Create Date: 2026-08-01
16+
17+
"""
18+
19+
from collections.abc import Sequence
20+
21+
from alembic import op
22+
23+
revision: str = "c4d5e6f7a0b1"
24+
down_revision: str | None = "b3c4d5e6f9a0"
25+
branch_labels: str | Sequence[str] | None = None
26+
depends_on: str | Sequence[str] | None = None
27+
28+
29+
def upgrade() -> None:
30+
op.execute("UPDATE container_sessions SET status = lower(status) WHERE status <> lower(status)")
31+
32+
33+
def downgrade() -> None:
34+
op.execute("UPDATE container_sessions SET status = upper(status) WHERE status <> upper(status)")

apps/api/src/helprs/core/dependencies.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66

77
from fastapi import Depends, Request
88
from jose import JWTError
9-
from sqlalchemy import select
109
from sqlalchemy.ext.asyncio import AsyncSession
1110

1211
from helprs.core.config import Settings, get_settings
1312
from helprs.core.exceptions import UnauthorizedError
1413
from helprs.core.security import decode_access_token
14+
from helprs.modules.identity import repository as identity_repository
1515
from helprs.modules.identity.models import GitHubUser
1616

1717
# Re-export get_settings as a dependency
@@ -33,8 +33,14 @@ async def get_db(request: Request) -> AsyncGenerator[AsyncSession, None]:
3333
DbSession = Annotated[AsyncSession, Depends(get_db)]
3434

3535

36-
async def _authenticate(request: Request, session: AsyncSession, settings: Settings, token: str) -> GitHubUser:
37-
"""Resolve a bearer token to the user it belongs to."""
36+
async def authenticate_token(session: AsyncSession, settings: Settings, token: str) -> GitHubUser:
37+
"""Resolve a bearer token to the user it belongs to.
38+
39+
Public because the SSE endpoint cannot authenticate through the dependency
40+
graph: FastAPI tears yield-dependencies down only after the streaming body
41+
finishes, so a ``Depends(get_db)`` session would stay checked out for the
42+
life of the stream. That route calls this inside its own short session.
43+
"""
3844
try:
3945
payload = decode_access_token(token, settings.SECRET_KEY.get_secret_value())
4046
except JWTError as e:
@@ -43,17 +49,12 @@ async def _authenticate(request: Request, session: AsyncSession, settings: Setti
4349
if payload.get("type") == "refresh":
4450
raise UnauthorizedError("Cannot use refresh token as access token")
4551

46-
user_id = payload.get("sub")
47-
if not user_id:
48-
raise UnauthorizedError("Invalid token payload")
49-
5052
try:
51-
uuid.UUID(user_id)
52-
except (ValueError, AttributeError) as e:
53+
user_id = uuid.UUID(payload.get("sub", ""))
54+
except (ValueError, AttributeError, TypeError) as e:
5355
raise UnauthorizedError("Invalid token payload") from e
5456

55-
result = await session.execute(select(GitHubUser).where(GitHubUser.id == user_id))
56-
user = result.scalar_one_or_none()
57+
user = await identity_repository.get_by_id(session, user_id)
5758
if not user:
5859
raise UnauthorizedError("User not found")
5960

@@ -67,28 +68,28 @@ def _bearer_token(request: Request) -> str:
6768
return auth_header.removeprefix("Bearer ")
6869

6970

70-
async def get_current_user(request: Request, session: DbSession, settings: GetSettings) -> GitHubUser:
71-
"""Authenticate from the ``Authorization`` header."""
72-
return await _authenticate(request, session, settings, _bearer_token(request))
73-
74-
75-
async def get_current_user_for_stream(request: Request, session: DbSession, settings: GetSettings) -> GitHubUser:
76-
"""Authenticate an SSE request, allowing the token in the query string.
71+
def stream_token(request: Request) -> str:
72+
"""The JWT for an SSE request, which may carry it in the query string.
7773
7874
``EventSource`` cannot set request headers, so a browser opening a stream
79-
has nowhere else to put the JWT. The cost is real — query strings land in
80-
proxy logs, browser history and ``Referer`` — so this is granted to the
81-
streaming endpoints only, never to the whole API.
75+
has nowhere else to put the token. The cost is real -- query strings land
76+
in proxy logs, browser history and ``Referer`` -- so this is granted to
77+
the streaming endpoints only, never to the whole API. The header form
78+
still has to be a well-formed ``Bearer`` value.
8279
"""
83-
token = request.headers.get("Authorization", "").removeprefix("Bearer ") or request.query_params.get(
84-
"access_token", ""
85-
)
80+
if request.headers.get("Authorization"):
81+
return _bearer_token(request)
82+
token = request.query_params.get("access_token", "")
8683
if not token:
8784
raise UnauthorizedError("Missing or invalid Authorization header")
88-
return await _authenticate(request, session, settings, token)
85+
return token
86+
87+
88+
async def get_current_user(request: Request, session: DbSession, settings: GetSettings) -> GitHubUser:
89+
"""Authenticate from the ``Authorization`` header."""
90+
return await authenticate_token(session, settings, _bearer_token(request))
8991

9092

9193
# Routes take `user: CurrentUser` rather than `user=Depends(get_current_user)`:
9294
# the Annotated form is a real type for the checker and does not trip B008.
9395
CurrentUser = Annotated[GitHubUser, Depends(get_current_user)]
94-
StreamUser = Annotated[GitHubUser, Depends(get_current_user_for_stream)]
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""Container module -- ephemeral Docker container lifecycle management."""
22

3-
from helprs.modules.container.router import router
4-
5-
__all__ = ["router"]
3+
# Deliberately no re-exports. Importing a router here made every
4+
# submodule import pull the whole router graph back through
5+
# core.dependencies, which imports identity.models -- a cycle that left
6+
# `import helprs.core.dependencies` broken on its own and made startup
7+
# depend on main.py happening to import admin.views first. Import
8+
# submodules by their full path instead.

apps/api/src/helprs/modules/container/models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,19 @@ class ContainerSession(Base):
4545
skill_name: Mapped[str] = mapped_column(String(100), nullable=False)
4646
container_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
4747
status: Mapped[ContainerStatus] = mapped_column(
48-
Enum(ContainerStatus, name="container_status", native_enum=False, length=20),
48+
# values_callable is load-bearing: without it SQLAlchemy persists the
49+
# member NAME ("RUNNING"), while this column's server_default is the
50+
# value ("pending") and the API returns the value too. Any row created
51+
# without an explicit status -- the DB default, raw SQL, a bulk import
52+
# -- was then unreadable through the ORM:
53+
# LookupError: 'running' is not among the defined enum values
54+
Enum(
55+
ContainerStatus,
56+
name="container_status",
57+
native_enum=False,
58+
length=20,
59+
values_callable=lambda enum: [member.value for member in enum],
60+
),
4961
nullable=False,
5062
default=ContainerStatus.PENDING,
5163
index=True,

apps/api/src/helprs/modules/container/router.py

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from fastapi import APIRouter, Request, Response
1313
from fastapi.responses import StreamingResponse
1414

15-
from helprs.core.dependencies import CurrentUser, DbSession, GetSettings, StreamUser
15+
from helprs.core.database import get_db_context
16+
from helprs.core.dependencies import CurrentUser, DbSession, GetSettings, authenticate_token, stream_token
1617
from helprs.core.exceptions import ConflictError, NotFoundError
1718
from helprs.core.middleware import limiter
1819
from helprs.core.security import fernet_decrypt
@@ -101,6 +102,12 @@ async def create_container_session(
101102
skill_name=body.skill_name,
102103
user_id=user.id,
103104
)
105+
# Committed before the container is touched. start_container marks the row
106+
# FAILED on error and then raises, but that exception unwinds through
107+
# get_db, which rolls back -- discarding the FAILED status *and* the row
108+
# itself. A failed start left the user with a 502 and a dashboard showing
109+
# that nothing had ever happened.
110+
await db.commit()
104111

105112
docker = _get_docker_client()
106113
try:
@@ -151,19 +158,31 @@ def _resume_offset(request: Request, offset: int) -> int:
151158
async def stream_container_output(
152159
session_id: UUID,
153160
request: Request,
154-
db: DbSession,
155161
settings: GetSettings,
156-
user: StreamUser,
157162
offset: int = 0,
158163
) -> StreamingResponse:
159-
"""Relay the container's output as Server-Sent Events."""
160-
cs = await get_session_or_404(db, session_id)
161-
await verify_session_access(user, cs, db, settings)
164+
"""Relay the container's output as Server-Sent Events.
165+
166+
Deliberately takes no ``DbSession`` and no user dependency. FastAPI tears
167+
yield-dependencies down only once the streaming body completes, so any
168+
``Depends(get_db)`` here -- including the one behind an authentication
169+
dependency -- would keep a pooled connection, and an open transaction,
170+
checked out for the life of the stream: up to CONTAINER_TTL_SECONDS. The
171+
pool is DB_POOL_SIZE + DB_MAX_OVERFLOW per worker, so a handful of
172+
viewers would starve every other request on that worker, and each
173+
idle-in-transaction backend blocks VACUUM. Authentication and
174+
authorization run in a short session that closes before streaming starts.
175+
"""
176+
async with get_db_context() as db:
177+
user = await authenticate_token(db, settings, stream_token(request))
178+
cs = await get_session_or_404(db, session_id)
179+
await verify_session_access(user, cs, db, settings)
180+
181+
if cs.status != ContainerStatus.RUNNING or not cs.container_id:
182+
raise ConflictError("Container is not running")
162183

163-
if cs.status != ContainerStatus.RUNNING or not cs.container_id:
164-
raise ConflictError("Container is not running")
184+
container_id = cs.container_id
165185

166-
container_id = cs.container_id
167186
resume_from = _resume_offset(request, offset)
168187
docker = _get_docker_client()
169188

@@ -177,13 +196,23 @@ async def _event_stream():
177196
status = await finalize_session(session_id, docker)
178197
finalized = True
179198
yield _done_event(status)
199+
except Exception:
200+
# The response has already started, so there is no status code
201+
# left to change. Without a frame here the client just sees a
202+
# truncated body, and native EventSource silently reconnects
203+
# into the same failure.
204+
logger.exception("sse_stream_failed", session_id=str(session_id))
205+
yield _error_event("Streaming failed. The session will be finalized in the background.")
180206
finally:
181207
if not finalized:
182-
# The client hung up mid-stream. Finalization must still run,
183-
# detached from this request — otherwise the session stays
184-
# RUNNING until the reaper mislabels it TIMEOUT, with its
185-
# scorecard unextracted and its PR comment never posted.
186-
spawn_detached(_finalize_and_close(session_id, docker))
208+
# The client hung up, or the stream broke, mid-session. Both
209+
# the drain and the finalization have to continue detached
210+
# from this request. Detaching only the finalization -- as
211+
# this did before -- stops the drain the moment the tab
212+
# closes, so finalize_session then builds its scorecard from
213+
# a truncated event history and posts that to the PR, quietly
214+
# defeating the thing detaching was meant to protect.
215+
spawn_detached(_drain_and_finalize(session_id, container_id, docker))
187216
else:
188217
await docker.close()
189218

@@ -208,9 +237,24 @@ def _done_event(status: ContainerStatus) -> str:
208237
return f"event: done\ndata: {json.dumps({'message': message, 'status': status.value})}\n\n"
209238

210239

211-
async def _finalize_and_close(session_id: UUID, docker: DockerClient) -> None:
240+
def _error_event(message: str) -> str:
241+
return f"event: error\ndata: {json.dumps({'message': message})}\n\n"
242+
243+
244+
async def _drain_and_finalize(session_id: UUID, container_id: str, docker: DockerClient) -> None:
245+
"""Finish the session with no client attached.
246+
247+
Keeps consuming the container's output until it exits, then finalizes.
248+
Re-reading the log from the start is deliberate and cheap: ``_persist``
249+
is idempotent through ``ON CONFLICT DO NOTHING``, and nothing is being
250+
yielded to anyone, so the offset does not matter here.
251+
"""
212252
try:
253+
async for _ in stream_and_persist(docker, container_id, session_id=session_id):
254+
pass
213255
await finalize_session(session_id, docker)
256+
except Exception:
257+
await logger.aexception("detached_finalize_failed", session_id=str(session_id))
214258
finally:
215259
await docker.close()
216260

0 commit comments

Comments
 (0)