Files
2026-07-13 13:17:40 +08:00

236 lines
6.9 KiB
Python

import asyncio
import sys
import httpx
import pytest
from fastapi import FastAPI
from starlette.requests import Request
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.test_utils import (
get_application_url,
send_signal_on_cancellation,
)
from ray.serve.exceptions import RequestCancelledError
@ray.remote
class Collector:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def get(self):
return self.items
def test_collector_class(serve_instance):
collector = Collector.remote()
random_items = ["this", "is", 1, "demo", "string"]
for item in random_items:
collector.add.remote(item)
result = ray.get(collector.get.remote())
assert len(result) == len(random_items)
for i in range(0, len(result)):
assert result[i] == random_items[i]
@pytest.mark.parametrize("use_fastapi", [False, True])
def test_cancel_on_http_client_disconnect_during_execution(
serve_instance, use_fastapi: bool
):
"""Test the client disconnecting while the handler is executing."""
inner_signal_actor = SignalActor.remote()
outer_signal_actor = SignalActor.remote()
@serve.deployment
async def inner():
async with send_signal_on_cancellation(inner_signal_actor):
pass
if use_fastapi:
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class Ingress:
def __init__(self, handle):
self._handle = handle
@app.get("/")
async def wait_for_cancellation(self):
_ = self._handle.remote()
async with send_signal_on_cancellation(outer_signal_actor):
pass
else:
@serve.deployment
class Ingress:
def __init__(self, handle):
self._handle = handle
async def __call__(self, request: Request):
_ = self._handle.remote()
async with send_signal_on_cancellation(outer_signal_actor):
pass
serve.run(Ingress.bind(inner.bind()))
# Intentionally time out on the client, causing it to disconnect.
with pytest.raises(httpx.ReadTimeout):
httpx.get(get_application_url("HTTP"), timeout=0.5)
# Both the HTTP handler and the inner deployment handle call should be cancelled.
ray.get(inner_signal_actor.wait.remote(), timeout=10)
ray.get(outer_signal_actor.wait.remote(), timeout=10)
def test_cancel_on_http_client_disconnect_during_assignment(serve_instance):
"""Test the client disconnecting while the proxy is assigning the request."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class Ingress:
def __init__(self):
self._num_requests = 0
async def __call__(self, *args):
self._num_requests += 1
await signal_actor.wait.remote()
return self._num_requests
h = serve.run(Ingress.bind())
# Send a request and wait for it to be ongoing so we know that further requests
# will block trying to assign a replica.
initial_response = h.remote()
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# Intentionally time out on the client, causing it to disconnect.
with pytest.raises(httpx.ReadTimeout):
httpx.get(get_application_url("HTTP"), timeout=0.5)
# Now signal the initial request to finish and check that the request sent via HTTP
# never reaches the replica.
ray.get(signal_actor.send.remote())
assert initial_response.result() == 1
for i in range(2, 12):
assert h.remote().result() == i
@pytest.mark.asyncio
async def test_request_cancelled_error_on_http_client_disconnect_during_execution(
serve_instance,
):
"""Test the exception thrown for executing request on http client disconnect"""
collector = Collector.remote()
child_signal = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class Child:
async def __call__(self):
try:
await child_signal.wait.remote()
except asyncio.CancelledError:
await collector.add.remote("Child_CancelledError")
raise
@serve.deployment
class Parent:
def __init__(self, child):
self.child = child
async def __call__(self):
try:
await self.child.remote()
except asyncio.CancelledError:
await collector.add.remote("Parent_AsyncioCancelledError")
raise
except RequestCancelledError:
await collector.add.remote("Parent_RequestCancelledError")
raise
serve.run(Parent.bind(Child.bind()))
# Make a request with short timeout that will cause disconnection
try:
await httpx.AsyncClient(timeout=0.5).get(get_application_url("HTTP"))
except httpx.ReadTimeout:
pass
wait_for_condition(
lambda: set(ray.get(collector.get.remote()))
== {"Child_CancelledError", "Parent_AsyncioCancelledError"}
)
@pytest.mark.asyncio
async def test_request_cancelled_error_on_http_client_disconnect_during_assignment(
serve_instance,
):
"""Test the exception thrown for queued request on http client disconnect"""
collector = Collector.remote()
child_signal = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1)
class Child:
async def __call__(self):
try:
await child_signal.wait.remote()
except asyncio.CancelledError:
await collector.add.remote("Child_CancelledError")
raise
@serve.deployment
class Parent:
def __init__(self, child):
self.child = child
async def __call__(self):
try:
await self.child.remote()
except asyncio.CancelledError:
await collector.add.remote("Parent_AsyncioCancelledError")
raise
except RequestCancelledError:
await collector.add.remote("Parent_RequestCancelledError")
raise
h = serve.run(Parent.bind(Child.bind()))
# Block Child with first request
r = h.remote()
wait_for_condition(lambda: ray.get(child_signal.cur_num_waiters.remote()) == 1)
# Make a second request with short timeout that will cause disconnection
try:
await httpx.AsyncClient(timeout=0.5).get(get_application_url("HTTP"))
except httpx.ReadTimeout:
pass
wait_for_condition(
lambda: ray.get(collector.get.remote()) == ["Parent_AsyncioCancelledError"]
)
# Clean up first request
r.cancel()
try:
await r
except RequestCancelledError:
pass
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))