Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions explain_my_code/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from starlette.concurrency import iterate_in_threadpool, run_in_threadpool

from explain_my_code import __version__
from explain_my_code.api.schemas import (
Expand Down Expand Up @@ -185,14 +186,19 @@ async def explain_stream(payload: ExplainRequest) -> StreamingResponse:

The static pass takes about a millisecond, so the page can render fully before the
model has produced a token. The LLM is a progressive enhancement, not a dependency.

This handler is async, so everything blocking runs in the threadpool: the analysis
is CPU-bound and the provider SDKs stream over blocking HTTP. Run inline, a single
enrichment stalled every other request on the event loop until the model finished.
"""

async def events() -> AsyncIterator[str]:
def sse(event: str, data: Any) -> str:
return f"event: {event}\ndata: {json.dumps(data)}\n\n"

try:
result = explain(
result = await run_in_threadpool(
explain,
payload.code,
language=payload.language,
level=payload.level,
Expand All @@ -217,7 +223,7 @@ def sse(event: str, data: Any) -> str:
yield sse("enrich_start", {"provider": provider.name, "model": provider.model})
prompt = build_context(result.analysis, result.level, result.source)
try:
for chunk in provider.stream(SYSTEM_PROMPT, prompt):
async for chunk in iterate_in_threadpool(provider.stream(SYSTEM_PROMPT, prompt)):
yield sse("delta", {"text": chunk})
except Exception as exc: # provider SDKs raise their own error types
yield sse("error", {"error": "enrichment_failed", "detail": str(exc)})
Expand Down
44 changes: 44 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,50 @@ def test_streaming_reports_a_missing_provider_without_failing_the_request(monkey
assert "no_provider" in body


class _SlowProvider:
"""Streams like a real SDK: blocking network reads between chunks."""

name = "fake"
model = "fake-model"

def stream(self, system, prompt):
import time

for chunk in ('{"summary": "', "slow", '"}'):
time.sleep(0.3)
yield chunk


async def test_streaming_enrichment_does_not_block_other_requests(monkeypatch):
"""A blocking provider stream used to run on the event loop itself, so every other
request (here /healthz) waited for the model to finish."""
import asyncio
import time

import httpx

monkeypatch.setattr("explain_my_code.api.main.get_provider", lambda name=None: _SlowProvider())
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as ac:
started = time.perf_counter()

async def stream() -> tuple[str, float]:
response = await ac.post("/v1/explain/stream", json={"code": PY, "enrich": True})
return response.text, time.perf_counter() - started

async def health() -> float:
await asyncio.sleep(0.1) # let the stream start first
response = await ac.get("/healthz")
assert response.status_code == 200
return time.perf_counter() - started

(body, stream_done), health_done = await asyncio.gather(stream(), health())

assert "event: delta" in body and "slow" in body
assert stream_done >= 0.9
assert health_done < 0.6, f"/healthz waited {health_done:.2f}s behind the stream"


# -- cache -------------------------------------------------------------------------


Expand Down
Loading