Skip to content

Commit d4cab8a

Browse files
committed
refactor(fastapi): improve async handler wrapping to avoid coroutine misclassification
1 parent df56deb commit d4cab8a

File tree

1 file changed

+57
-7
lines changed

1 file changed

+57
-7
lines changed

src/agentscope_runtime/engine/deployers/utils/service_utils/fastapi_factory.py

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -693,9 +693,32 @@ def _create_streaming_parameter_wrapper(
693693
parsing."""
694694
is_async_gen = inspect.isasyncgenfunction(handler)
695695

696+
# NOTE:
697+
# -----
698+
# FastAPI >= 0.123.5 uses Dependant.is_coroutine_callable, which in
699+
# turn unwraps callables via inspect.unwrap() and then inspects the
700+
# unwrapped target to decide whether it is a coroutine function /
701+
# generator / async generator.
702+
#
703+
# If we decorate an async-generator handler with
704+
# functools.wraps(handler), FastAPI will unwrap back to the original
705+
# async-generator function and # *misclassify* the endpoint as
706+
# non-coroutine. It will then call our async wrapper *without awaiting
707+
# it*, and later try to JSON-encode the resulting coroutine object,
708+
# causing errors like:
709+
# TypeError("'coroutine' object is not iterable")
710+
#
711+
# To avoid that, we deliberately do NOT use functools.wraps() here.
712+
# Instead, we manually copy the key metadata (name, qualname, doc,
713+
# module, and signature) from the original handler, but we do NOT set
714+
# __wrapped__. This ensures:
715+
# * FastAPI sees the wrapper itself as the callable (an async def),
716+
# so Dependant.is_coroutine_callable is True, and it is properly
717+
# awaited.
718+
# * FastAPI still sees the correct signature for parameter parsing.
719+
696720
if is_async_gen:
697721

698-
@functools.wraps(handler)
699722
async def wrapped_handler(*args, **kwargs):
700723
async def generate():
701724
try:
@@ -720,12 +743,8 @@ async def generate():
720743
media_type="text/event-stream",
721744
)
722745

723-
wrapped_handler.__signature__ = inspect.signature(handler)
724-
return wrapped_handler
725-
726746
else:
727747

728-
@functools.wraps(handler)
729748
def wrapped_handler(*args, **kwargs):
730749
def generate():
731750
try:
@@ -748,8 +767,39 @@ def generate():
748767
media_type="text/event-stream",
749768
)
750769

751-
wrapped_handler.__signature__ = inspect.signature(handler)
752-
return wrapped_handler
770+
# Manually propagate essential metadata without creating a __wrapped__
771+
# chain that would confuse FastAPI's unwrap logic.
772+
wrapped_handler.__name__ = getattr(
773+
handler,
774+
"__name__",
775+
wrapped_handler.__name__,
776+
)
777+
wrapped_handler.__qualname__ = getattr(
778+
handler,
779+
"__qualname__",
780+
wrapped_handler.__qualname__,
781+
)
782+
wrapped_handler.__doc__ = getattr(
783+
handler,
784+
"__doc__",
785+
wrapped_handler.__doc__,
786+
)
787+
wrapped_handler.__module__ = getattr(
788+
handler,
789+
"__module__",
790+
wrapped_handler.__module__,
791+
)
792+
wrapped_handler.__signature__ = inspect.signature(handler)
793+
794+
# Make sure FastAPI doesn't see any stale __wrapped__ pointing back to
795+
# the original async-generator; if present, remove it.
796+
if hasattr(wrapped_handler, "__wrapped__"):
797+
try:
798+
delattr(wrapped_handler, "__wrapped__")
799+
except Exception: # pragma: no cover - very defensive
800+
pass
801+
802+
return wrapped_handler
753803

754804
@staticmethod
755805
def _add_custom_endpoints(app: FastAPI):

0 commit comments

Comments
 (0)