|
25 | 25 | from fastapi.responses import StreamingResponse |
26 | 26 |
|
27 | 27 | from api.accent.models import AccentResponse, ErrorInfo, Request, WordAccentResult |
28 | | -from api.accent.pipeline import build_chunks, schedule_chunks |
| 28 | +from api.accent.pipeline import build_chunks, cancel_pending, schedule_chunks |
29 | 29 | from api.dependencies import get_http_client |
30 | 30 |
|
31 | 31 | logger = logging.getLogger("api") |
@@ -73,26 +73,32 @@ async def mark_accent( |
73 | 73 | worst_status = 200 |
74 | 74 | first_error: ErrorInfo | None = None |
75 | 75 | first_warning: str | None = None |
76 | | - for (chunk_idx, sub_idx, _text), task in zip(chunks, tasks): |
77 | | - try: |
78 | | - resp = await task |
79 | | - except Exception as exc: |
80 | | - logger.exception(f"Chunk {chunk_idx}.{sub_idx} failed") |
81 | | - detail = str(exc) or repr(exc) or type(exc).__name__ |
82 | | - if first_error is None: |
83 | | - first_error = ErrorInfo(code=500, message=f"Error: {detail}") |
84 | | - worst_status = max(worst_status, 500) |
85 | | - continue |
86 | | - if resp.result: |
87 | | - merged.extend(resp.result) |
88 | | - if resp.status > worst_status: |
89 | | - worst_status = resp.status |
90 | | - if resp.error is not None and first_error is None: |
91 | | - first_error = resp.error |
92 | | - # Like `error`, `warning` keeps the first chunk's value — chunks all |
93 | | - # degrade the same way (e.g. OJAD down), so one message suffices. |
94 | | - if resp.warning is not None and first_warning is None: |
95 | | - first_warning = resp.warning |
| 76 | + # `cancel_pending` in the finally stops in-flight chunks if the client |
| 77 | + # disconnects mid-request (the handler is cancelled) instead of leaving |
| 78 | + # them scraping OJAD; on a normal run every task is already done. |
| 79 | + try: |
| 80 | + for (chunk_idx, sub_idx, _text), task in zip(chunks, tasks): |
| 81 | + try: |
| 82 | + resp = await task |
| 83 | + except Exception as exc: |
| 84 | + logger.exception(f"Chunk {chunk_idx}.{sub_idx} failed") |
| 85 | + detail = str(exc) or repr(exc) or type(exc).__name__ |
| 86 | + if first_error is None: |
| 87 | + first_error = ErrorInfo(code=500, message=f"Error: {detail}") |
| 88 | + worst_status = max(worst_status, 500) |
| 89 | + continue |
| 90 | + if resp.result: |
| 91 | + merged.extend(resp.result) |
| 92 | + if resp.status > worst_status: |
| 93 | + worst_status = resp.status |
| 94 | + if resp.error is not None and first_error is None: |
| 95 | + first_error = resp.error |
| 96 | + # Like `error`, `warning` keeps the first chunk's value — chunks |
| 97 | + # all degrade the same way (e.g. OJAD down), so one suffices. |
| 98 | + if resp.warning is not None and first_warning is None: |
| 99 | + first_warning = resp.warning |
| 100 | + finally: |
| 101 | + await cancel_pending(tasks) |
96 | 102 |
|
97 | 103 | return AccentResponse( |
98 | 104 | status=worst_status, |
@@ -134,24 +140,31 @@ async def generate() -> AsyncIterator[bytes]: |
134 | 140 | render_katakana_furigana=request.render_katakana_furigana, |
135 | 141 | script=request.script, |
136 | 142 | ) |
137 | | - for (chunk_idx, sub_idx, _text), task in zip(chunks, tasks): |
138 | | - try: |
139 | | - resp = await task |
140 | | - payload: dict[str, Any] = { |
141 | | - "chunk": chunk_idx, |
142 | | - "subchunk": sub_idx, |
143 | | - **resp.model_dump(), |
144 | | - } |
145 | | - except Exception as exc: |
146 | | - logger.exception(f"Streaming chunk {chunk_idx}.{sub_idx} failed") |
147 | | - detail = str(exc) or repr(exc) or type(exc).__name__ |
148 | | - payload = { |
149 | | - "chunk": chunk_idx, |
150 | | - "subchunk": sub_idx, |
151 | | - "status": 500, |
152 | | - "result": None, |
153 | | - "error": {"code": 500, "message": f"Error: {detail}"}, |
154 | | - } |
155 | | - yield (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8") |
| 143 | + # If the client disconnects mid-stream, Starlette throws GeneratorExit |
| 144 | + # into the paused `yield`; the finally then cancels every still-pending |
| 145 | + # chunk instead of running them to completion against OJAD for output |
| 146 | + # nobody will read. |
| 147 | + try: |
| 148 | + for (chunk_idx, sub_idx, _text), task in zip(chunks, tasks): |
| 149 | + try: |
| 150 | + resp = await task |
| 151 | + payload: dict[str, Any] = { |
| 152 | + "chunk": chunk_idx, |
| 153 | + "subchunk": sub_idx, |
| 154 | + **resp.model_dump(), |
| 155 | + } |
| 156 | + except Exception as exc: |
| 157 | + logger.exception(f"Streaming chunk {chunk_idx}.{sub_idx} failed") |
| 158 | + detail = str(exc) or repr(exc) or type(exc).__name__ |
| 159 | + payload = { |
| 160 | + "chunk": chunk_idx, |
| 161 | + "subchunk": sub_idx, |
| 162 | + "status": 500, |
| 163 | + "result": None, |
| 164 | + "error": {"code": 500, "message": f"Error: {detail}"}, |
| 165 | + } |
| 166 | + yield (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8") |
| 167 | + finally: |
| 168 | + await cancel_pending(tasks) |
156 | 169 |
|
157 | 170 | return StreamingResponse(generate(), media_type="application/x-ndjson") |
0 commit comments