Skip to content

Commit 78d8959

Browse files
authored
Merge pull request #150 from luozirong2025/main
Preserve error codes and surface root cause in agentic RL errors
2 parents a7edd37 + ebe2b6d commit 78d8959

7 files changed

Lines changed: 215 additions & 41 deletions

File tree

dashscope/finetune/agentic_rl.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ async def register_functions(
106106
)
107107
logger.info("Function components registered")
108108
except Exception as e:
109+
if hasattr(e, "error_code"):
110+
raise
109111
raise RegistrationError(
110112
"Function registration failed",
111113
error_code=3002,
@@ -249,6 +251,8 @@ def submit_job(
249251
**kwargs,
250252
)
251253
except Exception as e:
254+
if hasattr(e, "error_code"):
255+
raise
252256
raise RuntimeErrorWithCode(
253257
"Job submission failed",
254258
error_code=3005,
@@ -312,6 +316,8 @@ async def run(
312316
**kwargs,
313317
)
314318
except Exception as e:
319+
if hasattr(e, "error_code"):
320+
raise
315321
raise RuntimeErrorWithCode(
316322
"RL tuning workflow failed",
317323
error_code=3006,

dashscope/finetune/reinforcement/common/errors.py

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,32 @@
77
from typing import Optional, Dict
88

99

10-
class AgenticRLError(Exception):
10+
class _RootCauseMixin:
11+
"""Mixin that provides root-cause traversal and formatting for exceptions
12+
that carry an error code."""
13+
14+
@property
15+
def root_cause(self) -> "Exception":
16+
root: "Exception" = self # type: ignore[assignment]
17+
seen = {id(root)}
18+
while root.__cause__ and id(root.__cause__) not in seen:
19+
root = root.__cause__
20+
seen.add(id(root))
21+
return root
22+
23+
def _format_cause(self) -> str:
24+
"""Format root cause information if available."""
25+
if self.__cause__ is not None:
26+
root = self.root_cause
27+
if self is not root:
28+
cause_msg = str(root).split("\n", maxsplit=1)[0][:100].strip()
29+
if cause_msg:
30+
return f" (caused by: {type(root).__name__}: {cause_msg})"
31+
return f" (caused by: {type(root).__name__})"
32+
return ""
33+
34+
35+
class AgenticRLError(_RootCauseMixin, Exception):
1136
"""Base class for all Agentic RL exceptions."""
1237

1338
def __init__(self, message: str, error_code: int = 1000):
@@ -16,15 +41,9 @@ def __init__(self, message: str, error_code: int = 1000):
1641
self.timestamp = datetime.now().isoformat()
1742
self.message = message
1843

19-
@property
20-
def root_cause(self) -> Exception:
21-
root = self
22-
while root.__cause__:
23-
root = root.__cause__
24-
return root
25-
2644
def __str__(self):
27-
return f"[{self.error_code}] {self.message} (at {self.timestamp})"
45+
base = f"[{self.error_code}] {self.message} (at {self.timestamp})"
46+
return f"{base}{self._format_cause()}"
2847

2948

3049
class IOErrorWithCode(AgenticRLError):
@@ -42,7 +61,7 @@ def __init__(
4261
self.operation = operation
4362

4463

45-
class RuntimeErrorWithCode(RuntimeError):
64+
class RuntimeErrorWithCode(_RootCauseMixin, RuntimeError):
4665
"""Enhanced RuntimeError that supports error codes for better error
4766
categorization."""
4867

@@ -51,18 +70,11 @@ def __init__(self, message: str, error_code: int = 0):
5170
self.error_code = error_code
5271
self.message = message
5372

54-
@property
55-
def root_cause(self) -> Exception:
56-
root = self
57-
while root.__cause__:
58-
root = root.__cause__
59-
return root
60-
6173
def __str__(self):
62-
return f"[{self.error_code}] {self.message}"
74+
return f"[{self.error_code}] {self.message}{self._format_cause()}"
6375

6476

65-
class ValueErrorWithCode(ValueError):
77+
class ValueErrorWithCode(_RootCauseMixin, ValueError):
6678
"""Enhanced ValueError that supports error codes for better error
6779
categorization."""
6880

@@ -71,15 +83,8 @@ def __init__(self, message: str, error_code: int = 0):
7183
self.error_code = error_code
7284
self.message = message
7385

74-
@property
75-
def root_cause(self) -> Exception:
76-
root = self
77-
while root.__cause__:
78-
root = root.__cause__
79-
return root
80-
8186
def __str__(self):
82-
return f"[{self.error_code}] {self.message}"
87+
return f"[{self.error_code}] {self.message}{self._format_cause()}"
8388

8489

8590
class InputError(AgenticRLError):

dashscope/finetune/reinforcement/common/model.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,8 @@ async def get_oss(self, oss_id: Optional[str] = None) -> str:
348348
)
349349
return self.oss_signed_url
350350

351+
except OSSConnectionError:
352+
raise
351353
except Exception as e:
352354
raise OSSConnectionError(
353355
"Failed to obtain OSS URL",
@@ -1243,6 +1245,8 @@ async def register_functions(
12431245
)
12441246

12451247
except Exception as e:
1248+
if hasattr(e, "error_code"):
1249+
raise
12461250
raise RegistrationError(
12471251
"Function component registration failed",
12481252
error_code=2055,

dashscope/finetune/reinforcement/common/utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,3 +1004,46 @@ def serialize_for_output(data: Any) -> Any:
10041004

10051005
# Return basic types directly
10061006
return data
1007+
1008+
1009+
def get_fc_request_id(request) -> str:
1010+
"""Extract Function Compute request ID from request headers.
1011+
1012+
Retrieves the 'x-fc-request-id' header from a FastAPI/Starlette Request
1013+
object. This is useful for correlating logs with specific FC invocations.
1014+
1015+
Args:
1016+
request: FastAPI/Starlette Request object (or any object with a
1017+
`.headers` mapping).
1018+
1019+
Returns:
1020+
The FC request ID string, or "unknown" if the header is not present.
1021+
"""
1022+
if request is None:
1023+
return "unknown"
1024+
headers = getattr(request, "headers", None)
1025+
if headers is not None and hasattr(headers, "get"):
1026+
return headers.get("x-fc-request-id", "unknown")
1027+
return "unknown"
1028+
1029+
1030+
def get_business_summary(processor_input) -> str:
1031+
"""Extract summary business information from processor input.
1032+
1033+
Returns the string representation of request_metadata if present,
1034+
otherwise returns an empty string.
1035+
1036+
Args:
1037+
processor_input: The processor input object (BaseDataModel subclass).
1038+
1039+
Returns:
1040+
String representation of request_metadata, or empty string.
1041+
"""
1042+
if processor_input is None:
1043+
return ""
1044+
1045+
request_metadata = getattr(processor_input, "request_metadata", None)
1046+
if request_metadata is None:
1047+
return ""
1048+
1049+
return str(request_metadata)

dashscope/finetune/reinforcement/component/server/server.py

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
GET /health Health check
3838
"""
3939

40+
import asyncio
4041
import logging
4142
import os
4243
import time
@@ -47,6 +48,10 @@
4748
from fastapi.responses import JSONResponse
4849

4950
from 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+
)
5055
from dashscope.finetune.reinforcement.common.model_types import (
5156
FunctionType as FuncType,
5257
)
@@ -72,7 +77,7 @@
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

7883
if 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")
381432
async 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")
450567
async def health_check_endpoint() -> JSONResponse:

0 commit comments

Comments
 (0)