Skip to content

Keep the SSE endpoint's blocking work off the event loop - #9

Merged
ethanzhoucool merged 2 commits into
mainfrom
claude/stream-off-event-loop
Nov 3, 2026
Merged

ethanzhoucool merged 2 commits into
mainfrom
claude/stream-off-event-loop

Conversation

@ethanzhoucool

Copy link
Copy Markdown
Owner

Summary

POST /v1/explain/stream is an async def handler, but it ran two blocking things directly on the event loop:

  1. explain(...), the CPU-bound static pass, which can take seconds on a large paste.
  2. for chunk in provider.stream(...). Both providers stream over blocking HTTP: the Anthropic SDK's sync messages.stream, and httpx.stream for Gemini with a 120 s timeout.

While an enrichment streamed, the event loop could not run anything else. Every other request waited until the model finished, including /healthz, page loads and plain /v1/explain/all-levels calls from other users. The Procfile runs a single uvicorn worker, so that is the whole service. Static-only streams were affected too, for the length of the analysis.

Changes

  • api/main.py: the analysis runs via starlette.concurrency.run_in_threadpool, and the provider's sync generator is consumed via iterate_in_threadpool. These are the helpers Starlette provides for calling blocking code from async handlers. The SSE frames, their order and the error handling are unchanged.

Testing

  • pytest on Python 3.13, 3.12 and 3.11: 148 passed, 3 skipped. ruff is clean, and mypy reports the same 26 errors as before.
  • New test_streaming_enrichment_does_not_block_other_requests. It uses a fake provider (no network, no API key) whose stream() blocks for 0.3 s before each of 3 chunks, the way a real SDK read does. The test sends GET /healthz 0.1 s into the stream, through httpx.ASGITransport on the same event loop.
    • On main: /healthz waited 0.91s behind the stream, so the test fails.
    • On this branch: /healthz returns right away while the stream is still running. I ran it 5 times with no flakes.
  • The existing streaming tests still pass: the static frame comes first, and a missing provider is reported without failing the request.

Notes

I couldn't exercise a real provider here (no keys, and none should be used). The change only alters where the generator's next() runs, not what it yields.


Generated by Claude Code

claude and others added 2 commits November 2, 2026 07:08
/v1/explain/stream is an async handler, but it ran the CPU-bound
analysis and iterated the provider's synchronous stream inline. The
Anthropic SDK and the Gemini client both stream over blocking HTTP, so
for the whole enrichment (Gemini's timeout is 120s) the event loop was
stuck: every other request, /healthz included, waited behind it. The
Procfile runs one worker, so that is the whole service.

The analysis now runs via run_in_threadpool and the provider stream
via iterate_in_threadpool, the helpers Starlette provides for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UigC7feaY8NkAy9oay3GDW
@ethanzhoucool
ethanzhoucool marked this pull request as ready for review November 3, 2026 17:20
@ethanzhoucool
ethanzhoucool merged commit 36d3cbb into main Nov 3, 2026
6 checks passed
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.

2 participants