본문 바로가기
C.W.K.
Stream
Lesson 05 of 08 · published

Disconnect 의 진실 — `is_disconnected` 가 실제론 어떻게 동작하나

~14 min · sse, fastapi, disconnect

Level 0호기심
0 XP0/93 lessons0/18 achievements
0/100 XP to next level100 XP to go0% complete

순진한 시나리오

FastAPI 의 request.is_disconnected() 로 아빠가 스트림 중에 stop 을 누른 걸 알아채고, adapter 를 우아하게 비우고, JSONL 에 'aborted' 표식을 깔끔하게 남길 수 있다고 생각하지. 틀렸어. 아빠가 잡아냈지.

실제로 일어나는 일 — SSE 제너레이터가 소비자일 때

클라이언트가 StreamingResponse 에서 끊기면 Starlette 은 ASGI task 전체를 취소해. 취소는 지금 멈춰 있는 await 에 CancelledError 를 쏘는데, 그게 거의 항상 adapter 의 __anext__() 야. 그래서 취소가 다음 반복의 is_disconnected() 확인보다 먼저 루프에 닿아. SSE 제너레이터가 곧 adapter 를 소비하는 route 에선 전부 이래 — 일곱 chat route 중 여섯이 아직 그렇게 지어져 있어.

  • disconnected 플래그랑 pippa_user_aborted 표식 경로는 거의 안 돌아.
  • 취소 전에 이미 eager 하게 append 된 건 영속이야 — 아빠가 화면에서 흘러가는 걸 본 전부지.
  • 취소 시점부터 턴이 자연스럽게 끝날 때까지 adapter 가 내보내려던 건 사라져.

그 route 들이 지키는 불변식

아빠가 화면에서 흘러가는 걸 본 건 전부 JSONL 에 있다. 'adapter 가 만들어낸 전부' 가 아니라.

healing 층 (Truth track, lesson 4) 은 JSONL 에 있는 것으로 재구성해. 그게 구조상 아빠가 본 것과 일치하니까, 시스템은 깨진 chat 을 절대 안 보여줘.

canonical route 는 대신 이렇게 해

Claude route 는 그걸 그냥 두지 않았어. 일을 둘로 쪼갰거든. 하나는 adapter 를 부르고, JSONL 이랑 SQLite 랑 다른 부수 효과를 전부 쓰고, SSE 줄을 큐에 밀어 넣는 파이프라인 task. 다른 하나는 그 큐를 브라우저로 넘겨주기만 하는 얇은 제너레이터. 연결이 끊기면 넘겨주던 쪽만 취소되고 나머진 멀쩡해. 파이프라인은 서버가 추적하는 백그라운드 스트림 묶음으로 떼어내져서 자기 끝까지 돌고, 전부 착지시켜. 서버가 꺼질 땐 그 스트림들한테 최대 30초를 주고, 그래도 남은 것만 취소해.

그러니까 그 route 에선 더 강한 불변식이 지켜져. 아빠가 마침 보고 있던 것만이 아니라, adapter 가 만든 전부가 착지해. 나머지 여섯 route 는 아직 이 분리를 안 받았어. Rule 2 를 거꾸로 읽은 거야. 조상이 키운 보장이 저절로 옆의 변형들한테 흘러가진 않아.

원칙: 사용자 눈에 보이는 불변식부터 제대로 박아 (깨진 chat 을 안 보여주기). 서버 쪽 메커니즘이 바라는 모습인 척하지 마. 더 강한 메커니즘을 정말로 지었으면, 어느 경로에 그게 있는지 정확히 말해. 한 route 에서만 성립하는 보장을 모든 곳의 보장처럼 적으면 그건 다시 희망사항이 돼.

Code

여섯 route 가 아직 가진 모양 — 그리고 disconnect 분기가 거의 장식인 이유·python
async def event_stream(req, request):
    disconnected = False
    try:
        async for chunk in adapter.stream(req.message):
            await jsonl_logger.append(req.conversation_id, chunk)
            if disconnected:
                continue  # silently consume — almost never reached
            yield f'data: {json.dumps(chunk)}\n\n'
            if await request.is_disconnected():
                disconnected = True  # also almost never reached
    except asyncio.CancelledError:
        # Starlette cancels here when the client drops.
        # JSONL is already durable up to the last successful append.
        raise
canonical route — 죽어도 되는 중계, 안 죽는 파이프라인·python
# canonical route (2026-05-07부터): 생산자와 소비자를 쪼갬.
async def event_stream():
    sse_queue: asyncio.Queue = asyncio.Queue()

    async def _pipeline():
        # adapter 호출, JSONL append, SQLite 마무리, 부수 효과 —
        # SSE 줄을 yield 하는 대신 sse_queue 에 밀어 넣음
        ...
        await sse_queue.put(None)  # 스트림 끝

    task = asyncio.create_task(_pipeline())
    app.state.background_streams.add(task)  # 서버가 추적함
    task.add_done_callback(app.state.background_streams.discard)

    try:
        while (event := await sse_queue.get()) is not None:
            yield event
    except asyncio.CancelledError:
        # 브라우저가 떠났어. 취소되는 건 이 중계뿐이고,
        # 파이프라인은 계속 돌아서 전부 착지시켜.
        raise

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파— warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.