Nobody has started this yet — be first.
Business impact
When this instance is asked to shut down -- a rolling deploy, docker compose down, a Kubernetes pod eviction -- every currently-open notification stream connection just keeps running exactly as before, right up until the ASGI server forcibly tears the connection down out from under it, which either happens abruptly after a grace period or not at all within a reasonable window. Every rolling deploy leaves connected clients hanging instead of reconnecting promptly against whichever instance takes over next.
Problem
app/main.py's lifespan already creates app.state.shutdown_event as an asyncio.Event on startup, and app/routers/stream.py already passes it through to event_stream() as the shutdown argument -- the plumbing is there -- but the lifespan's shutdown path never calls .set() on it, and event_stream()'s main loop never reads it even though it's sitting right there as a parameter.
Current behavior
Setting a shutdown asyncio.Event and passing it into a live event_stream() generator already sitting in its main loop, then reading further, the generator keeps producing ordinary heartbeats indefinitely and never ends; separately, exiting a real lifespan (simulating instance shutdown) leaves app.state.shutdown_event unset.
Expected behavior
When shutdown begins, every open event_stream() generator on this instance should notice on its very next loop iteration and return cleanly, closing its side of the connection so the browser's EventSource sees a clean disconnect and reconnects immediately against whatever instance takes over next. This can't interrupt a single get_message() call already in flight -- it stops the loop from starting another wait once the signal is set, bounding the delay to roughly one heartbeat interval instead of leaving the connection running indefinitely.
Steps to reproduce
pytest practice_tickets/tests/test_ticket_15_graceful_shutdown_signal.py -vWhy this matters
This is the shutdown-time counterpart to the disconnect check event_stream()'s main loop already does for the client going away -- the same loop needs an equivalent check for the server going away. Both need to be checked on every iteration, not just once at the top of the function, since either one can happen at any point during a long-lived connection's life. And the shutdown side needs to happen exactly once, cleanly, regardless of how many connections are currently open -- one asyncio.Event, set once, is what every open generator's loop should be watching.
Suggested approach
app/main.py's lifespan finally block is where shutdown begins -- that's where app.state.shutdown_event.set() needs to happen, before or as Redis gets closed. event_stream()'s while True loop already checks request.is_disconnected() on every iteration; the shutdown parameter it already accepts needs the equivalent check sitting next to it, returning cleanly when it's set.
Acceptance criteria
Verification
pytest practice_tickets/tests/test_ticket_15_graceful_shutdown_signal.py -v
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b feat/graceful-shutdown-does-not-signal-streamsFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin feat/graceful-shutdown-does-not-signal-streamsSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign inhttp://localhost:8000http://localhost:8001Authorization: Bearer <user_id>X-User-Id: <user_id>curl localhost:8000/healthz
curl -N -H "Authorization: Bearer alice" localhost:8000/notifications/stream
# watch `: heartbeat` lines arrive roughly every 15s with no other traffic
Work the tickets in practice_tickets/tickets/ (ordered easy → hard); each names one dedicated pytest test in practice_tickets/tests/:
pytest practice_tickets/tests/test_ticket_01_heartbeat_default_margin.py -v # a single ticket
./practice_tickets/run_tickets.sh # all 15, clean pass/fail summary
Tickets 14 and 15 need genuinely concurrent, long-lived streams, which the ASGI test transport used everywhere else in this project can't represent -- their tests spin up a real (short-lived) uvicorn server on a loopback socket instead (LiveServer in practice_tickets/tests/conftest.py).
Level 2
Implement a feature
Extend the system within its own patterns.