3737 GET /health Health check
3838"""
3939
40+ import asyncio
4041import logging
4142import os
4243import time
4748from fastapi .responses import JSONResponse
4849
4950from dashscope .finetune .reinforcement .common .log import logger
51+ from dashscope .finetune .reinforcement .common .utils import (
52+ get_fc_request_id ,
53+ get_business_summary ,
54+ )
5055from dashscope .finetune .reinforcement .common .model_types import (
5156 FunctionType as FuncType ,
5257)
7277 "0" ,
7378 "no" ,
7479)
75- _THREAD_POOL_WORKERS = int (os .getenv ("THREAD_POOL_WORKERS" , "4 " ))
80+ _THREAD_POOL_WORKERS = int (os .getenv ("THREAD_POOL_WORKERS" , "32 " ))
7681_THREAD_POOL_QUEUE = int (os .getenv ("THREAD_POOL_QUEUE" , "100" ))
7782
7883if not _ENABLE_LOGGING :
@@ -377,6 +382,52 @@ def _serialize_result(result: Any) -> Dict:
377382 return {"result" : result }
378383
379384
385+ async def _cancel_task_on_disconnect (
386+ process_task : "asyncio.Task" ,
387+ disconnect_listener : "asyncio.Task" ,
388+ disconnected : "asyncio.Event" ,
389+ ) -> bool :
390+ """Wait for processing or disconnect, cancel task if disconnected.
391+
392+ Returns True if disconnected, False if processing completed normally.
393+ """
394+ _done , _pending = await asyncio .wait (
395+ [process_task , disconnect_listener ],
396+ return_when = asyncio .FIRST_COMPLETED ,
397+ )
398+
399+ if disconnected .is_set ():
400+ process_task .cancel ()
401+ try :
402+ await process_task
403+ except asyncio .CancelledError :
404+ pass
405+ return True
406+
407+ return False
408+
409+
410+ async def _log_request_metrics (
411+ request : "Request" ,
412+ processor_input ,
413+ start_time : float ,
414+ success : bool ,
415+ cancelled : bool ,
416+ ) -> None :
417+ """Log request metrics and force flush if configured."""
418+ elapsed = round (time .time () - start_time , 4 )
419+ fc_req_id = get_fc_request_id (request )
420+ biz_summary = get_business_summary (processor_input )
421+ biz_part = f" | { biz_summary } " if biz_summary else ""
422+ logger .info (
423+ f"[Server] /api/v1 | func_type={ func_type .value } | "
424+ f"fc_request_id={ fc_req_id } | "
425+ f"success={ success } | cancelled={ cancelled } | "
426+ f"elapsed={ elapsed } s{ biz_part } " ,
427+ )
428+ await maybe_force_flush_async (reason = "request" )
429+
430+
380431@app .post ("/api/v1" )
381432async def handle_endpoint (request : Request ) -> JSONResponse :
382433 """
@@ -386,6 +437,10 @@ async def handle_endpoint(request: Request) -> JSONResponse:
386437 request body, executes business logic using configured processor,
387438 and returns serialized result.
388439
440+ Monitors client connection state via ASGI disconnect messages. If the
441+ client disconnects before processing completes, the processing task is
442+ cancelled to avoid wasting resources.
443+
389444 Request body format: JSON, fields determined by FuncType:
390445 - reward: See RewardInput
391446 - rollout: See RolloutInput
@@ -398,8 +453,25 @@ async def handle_endpoint(request: Request) -> JSONResponse:
398453 """
399454 start_time = time .time ()
400455 success = False
456+ cancelled = False
401457 processor_input = None
402458
459+ disconnect_listener = None
460+ disconnected = asyncio .Event ()
461+
462+ async def _listen_for_disconnect ():
463+ """Background listener for ASGI disconnect messages."""
464+ try :
465+ while True :
466+ message = await request .receive ()
467+ if message .get ("type" ) == "http.disconnect" :
468+ disconnected .set ()
469+ break
470+ except Exception :
471+ # If receive() raises (e.g. connection already closed),
472+ # treat as disconnected.
473+ disconnected .set ()
474+
403475 # Extract trace context from request headers
404476 _otel_ctx_token , _upstream_tokens = await _extract_trace_context (request )
405477
@@ -413,8 +485,39 @@ async def handle_endpoint(request: Request) -> JSONResponse:
413485 # Check thread pool queue capacity
414486 await _check_queue_capacity ()
415487
416- # Execute processor
417- result = await func_manager .processes (processor_input )
488+ # Start listening for disconnect only AFTER the request body has been
489+ # fully read. Otherwise the background listener competes with the body
490+ # reader for ASGI receive() messages, causing hung requests or parse
491+ # failures.
492+ disconnect_listener = asyncio .create_task (_listen_for_disconnect ())
493+
494+ # Execute processor as a task so we can cancel it on disconnect
495+ process_task = asyncio .create_task (
496+ func_manager .processes (processor_input ),
497+ )
498+
499+ # Wait for either the processing to finish or client disconnect
500+ disconnected_flag = await _cancel_task_on_disconnect (
501+ process_task ,
502+ disconnect_listener ,
503+ disconnected ,
504+ )
505+
506+ if disconnected_flag :
507+ cancelled = True
508+ fc_request_id = get_fc_request_id (request )
509+ logger .warning (
510+ "[Server] Client disconnected during processing, "
511+ "task cancelled. x-fc-request-id: %s" ,
512+ fc_request_id ,
513+ )
514+ return JSONResponse (
515+ status_code = 499 ,
516+ content = {"message" : "Client disconnected, request cancelled." },
517+ )
518+
519+ # Processing completed normally
520+ result = process_task .result ()
418521 success = True
419522
420523 # Serialize result
@@ -425,26 +528,40 @@ async def handle_endpoint(request: Request) -> JSONResponse:
425528 except HTTPException :
426529 # Re-raise HTTP exceptions as-is
427530 raise
531+ except asyncio .CancelledError :
532+ cancelled = True
533+ logger .warning ("[Server] Request processing was cancelled." )
534+ return JSONResponse (
535+ status_code = 499 ,
536+ content = {"message" : "Request cancelled." },
537+ )
428538 except Exception as ex :
429539 logger .error (f"[Server] Unexpected error: { ex } " , exc_info = True )
430540 return JSONResponse (
431541 status_code = 500 ,
432542 content = {"message" : str (ex )},
433543 )
434544 finally :
545+ # Cancel the disconnect listener if still running
546+ if disconnect_listener is not None :
547+ disconnect_listener .cancel ()
548+ try :
549+ await disconnect_listener
550+ except asyncio .CancelledError :
551+ pass
552+
435553 # Clean up trace context
436554 await _cleanup_trace_context (_otel_ctx_token , _upstream_tokens )
437555
438556 # Log request metrics
439- elapsed = round (time .time () - start_time , 4 )
440- logger .info (
441- f"[Server] /api/v1 | func_type={ func_type .value } | "
442- f"success={ success } | elapsed={ elapsed } s" ,
557+ await _log_request_metrics (
558+ request ,
559+ processor_input ,
560+ start_time ,
561+ success ,
562+ cancelled ,
443563 )
444564
445- # Best-effort force flush based on platform/internal env config
446- await maybe_force_flush_async (reason = "request" )
447-
448565
449566@app .get ("/health" )
450567async def health_check_endpoint () -> JSONResponse :
0 commit comments