test/stream/test_timeout.py
Rama main · réplica de lectura del repositorio original.
# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0
import asyncio
import threading
from collections.abc import AsyncGenerator
from contextlib import suppress
from typing import Annotated
import pytest
from fast_depends import Depends
from ag2 import Context, MemoryStream
from ag2.events import ModelMessage, ToolCallEvent
class WaitingHandler:
def __init__(self) -> None:
self.started = asyncio.Event()
self.release = asyncio.Event()
self.settled = asyncio.Event()
async def handle(self, event: ModelMessage) -> ModelMessage:
self.started.set()
try:
await self.release.wait()
return event
finally:
self.settled.set()
class ThreadHandler:
def __init__(self) -> None:
self.started = threading.Event()
self.release = threading.Event()
self.settled = threading.Event()
def handle(self, event: ModelMessage) -> None:
self.started.set()
try:
self.release.wait()
finally:
self.settled.set()
class RecordingHandler:
def __init__(self) -> None:
self.events: list[ModelMessage] = []
def handle(self, event: ModelMessage) -> None:
self.events.append(event)
async def _resource(ctx: Context) -> AsyncGenerator[str]:
try:
if ctx.dependencies["phase"] == "setup":
await ctx.dependencies["handler"].handle(ModelMessage("setup"))
yield "resource"
finally:
try:
if ctx.dependencies["phase"] == "cleanup":
await ctx.dependencies["handler"].handle(ModelMessage("cleanup"))
finally:
ctx.dependencies["cleaned_up"].set()
async def _use_resource(event: ModelMessage, ctx: Context, resource: Annotated[str, Depends(_resource)]) -> None:
if ctx.dependencies["phase"] == "handler":
await ctx.dependencies["handler"].handle(event)
def _raise_timeout(event: ModelMessage) -> None:
raise TimeoutError("upstream request timed out")
def _transform(event: ModelMessage) -> ModelMessage:
return ModelMessage(event.content.upper())
@pytest.mark.asyncio
async def test_successful_timed_handlers_preserve_filtering_and_event_replacement() -> None:
stream = MemoryStream()
ctx = Context(stream)
recorder = RecordingHandler()
stream.where(ModelMessage).subscribe(_transform, interrupt=True, timeout=1, sync_to_thread=False)
stream.where(ModelMessage).subscribe(recorder.handle, timeout=1, sync_to_thread=False)
await ctx.send(ToolCallEvent(name="ignored"))
await ctx.send(ModelMessage("first"))
await ctx.send(ModelMessage("second"))
assert recorder.events == [ModelMessage("FIRST"), ModelMessage("SECOND")]
@pytest.mark.asyncio
@pytest.mark.parametrize("registration", ["subscribe", "decorator", "scope"])
@pytest.mark.parametrize("filtered", [False, True])
@pytest.mark.parametrize("interrupt", [False, True])
async def test_timeout_through_subscription_helpers(registration: str, filtered: bool, interrupt: bool) -> None:
stream = MemoryStream()
target = stream.where(ModelMessage) if filtered else stream
handler = WaitingHandler()
recorder = RecordingHandler()
ctx = Context(stream)
if registration == "subscribe":
sub_id = target.subscribe(handler.handle, interrupt=interrupt, timeout=0.02)
elif registration == "decorator":
sub_id = target.subscribe(interrupt=interrupt, timeout=0.02)(handler.handle)
else:
with target.sub_scope(handler.handle, interrupt=interrupt, timeout=0.02), pytest.raises(TimeoutError):
await ctx.send(ModelMessage("blocked"))
stream.subscribe(recorder.handle, sync_to_thread=False)
await ctx.send(ModelMessage("after scope"))
assert recorder.events == [ModelMessage("after scope")]
assert handler.settled.is_set()
return
stream.subscribe(recorder.handle, sync_to_thread=False)
with pytest.raises(TimeoutError):
await ctx.send(ModelMessage("blocked"))
assert recorder.events == []
assert handler.started.is_set()
assert handler.settled.is_set()
target.unsubscribe(sub_id)
await ctx.send(ModelMessage("after unsubscribe"))
assert recorder.events == [ModelMessage("after unsubscribe")]
@pytest.mark.asyncio
@pytest.mark.parametrize("phase", ["setup", "handler", "cleanup"])
async def test_timeout_covers_dependencies_and_cleanup(phase: str) -> None:
stream = MemoryStream()
handler = WaitingHandler()
cleaned_up = asyncio.Event()
ctx = Context(stream, dependencies={"phase": phase, "handler": handler, "cleaned_up": cleaned_up})
stream.subscribe(_use_resource, timeout=0.02)
with pytest.raises(TimeoutError):
await ctx.send(ModelMessage("blocked"))
assert handler.started.is_set()
assert handler.settled.is_set()
assert cleaned_up.is_set()
@pytest.mark.asyncio
async def test_each_invocation_gets_its_own_timeout() -> None:
stream = MemoryStream()
ctx = Context(stream)
handler = WaitingHandler()
stream.subscribe(handler.handle, timeout=0.02)
for _ in range(2):
handler.settled.clear()
with pytest.raises(TimeoutError):
await ctx.send(ModelMessage("blocked"))
assert handler.settled.is_set()
handler.release.set()
await ctx.send(ModelMessage("released"))
@pytest.mark.asyncio
@pytest.mark.parametrize("timeout", [None, 60])
async def test_caller_cancellation_propagates(timeout: float | None) -> None:
stream = MemoryStream()
ctx = Context(stream)
handler = WaitingHandler()
stream.subscribe(handler.handle, timeout=timeout)
task = asyncio.create_task(ctx.send(ModelMessage("blocked")))
try:
await handler.started.wait()
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert handler.settled.is_set()
@pytest.mark.asyncio
async def test_handler_raised_timeout_keeps_original_message() -> None:
stream = MemoryStream()
stream.subscribe(_raise_timeout, timeout=1)
with pytest.raises(TimeoutError, match="^upstream request timed out$"):
await Context(stream).send(ModelMessage("trigger"))
@pytest.mark.asyncio
async def test_sync_timeout_leaves_thread_running_until_released() -> None:
stream = MemoryStream()
handler = ThreadHandler()
stream.subscribe(handler.handle, timeout=0.1)
task = asyncio.create_task(Context(stream).send(ModelMessage("blocked")))
try:
assert await asyncio.to_thread(handler.started.wait, 5)
with pytest.raises(TimeoutError):
await task
assert not handler.settled.is_set()
finally:
handler.release.set()
with suppress(TimeoutError):
await task
assert await asyncio.to_thread(handler.settled.wait, 5)
@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf"), -float("inf")])
@pytest.mark.parametrize("filtered", [False, True])
def test_invalid_timeout_is_rejected(timeout: float, filtered: bool) -> None:
stream = MemoryStream()
target = stream.where(ModelMessage) if filtered else stream
with pytest.raises(ValueError, match="finite number > 0 seconds or None"):
target.subscribe(_raise_timeout, timeout=timeout)