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

384 lines
12 KiB
Python

import asyncio
import os
import sys
from typing import Generator, Set
import httpx
import pytest
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import StreamingResponse
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.dashboard.modules.serve.sdk import ServeSubmissionClient
from ray.serve._private.test_utils import (
get_application_url,
send_signal_on_cancellation,
)
from ray.serve.schema import ApplicationStatus, ServeInstanceDetails
from ray.util.state import list_tasks
@ray.remote
def do_request():
# Set a timeout to 10 because some tests use request_timeout_s = 5
# and httpx default timeout is 5 seconds.
return httpx.get(get_application_url(use_localhost=True), timeout=10)
@pytest.fixture
def shutdown_serve():
yield
serve.shutdown()
def test_normal_operation(ray_instance, shutdown_serve):
"""
Verify that a moderate timeout doesn't affect normal operation.
"""
@serve.deployment(num_replicas=2)
def f(*args):
return "Success!"
serve.start(http_options={"request_timeout_s": 5})
serve.run(f.bind())
assert all(
response.text == "Success!"
for response in ray.get([do_request.remote() for _ in range(10)])
)
def test_request_hangs_in_execution(ray_instance, shutdown_serve):
"""
Verify that requests are timed out if they take longer than the timeout to execute.
"""
@ray.remote
class PidTracker:
def __init__(self):
self.pids = set()
def add_pid(self, pid: int) -> None:
self.pids.add(pid)
def get_pids(self) -> Set[int]:
return self.pids
pid_tracker = PidTracker.remote()
signal_actor = SignalActor.remote()
@serve.deployment(num_replicas=2, graceful_shutdown_timeout_s=0)
class HangsOnFirstRequest:
def __init__(self):
self._saw_first_request = False
async def __call__(self):
ray.get(pid_tracker.add_pid.remote(os.getpid()))
if not self._saw_first_request:
self._saw_first_request = True
await asyncio.sleep(10)
return "Success!"
serve.start(http_options={"request_timeout_s": 0.1})
serve.run(HangsOnFirstRequest.bind())
response = httpx.get(get_application_url(use_localhost=True))
assert response.status_code == 408
ray.get(signal_actor.send.remote())
@serve.deployment(graceful_shutdown_timeout_s=0)
class HangsOnFirstRequest:
def __init__(self):
self._saw_first_request = False
self.signal_actor = SignalActor.remote()
async def __call__(self):
if not self._saw_first_request:
self._saw_first_request = True
await self.signal_actor.wait.remote()
else:
ray.get(self.signal_actor.send.remote())
return "Success!"
hangs_on_first_request_app = HangsOnFirstRequest.bind()
def test_with_rest_api(ray_instance, shutdown_serve):
"""Verify the REST API can configure the request timeout."""
config = {
"proxy_location": "EveryNode",
"http_options": {"request_timeout_s": 1},
"applications": [
{
"name": "app",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_request_timeout:hangs_on_first_request_app"
),
}
],
}
ServeSubmissionClient("http://localhost:8265").deploy_applications(config)
def application_running():
response = httpx.get(
"http://localhost:8265/api/serve/applications/", timeout=15
)
assert response.status_code == 200
serve_details = ServeInstanceDetails(**response.json())
return serve_details.applications["app"].status == ApplicationStatus.RUNNING
wait_for_condition(application_running, timeout=15)
print("Application has started running. Testing requests...")
response = httpx.get(get_application_url(app_name="app", use_localhost=True))
assert response.status_code == 408
response = httpx.get(get_application_url(app_name="app", use_localhost=True))
assert response.status_code == 200
print("Requests succeeded! Deleting application.")
ServeSubmissionClient("http://localhost:8265").delete_applications()
def test_request_hangs_in_assignment(ray_instance, shutdown_serve):
"""
Verify that requests are timed out if they take longer than the timeout while
pending assignment (queued in the handle).
"""
signal_actor = SignalActor.remote()
@serve.deployment(graceful_shutdown_timeout_s=0, max_ongoing_requests=1)
class HangsOnFirstRequest:
def __init__(self):
self._saw_first_request = False
async def __call__(self):
await signal_actor.wait.remote()
return "Success!"
serve.start(http_options={"request_timeout_s": 0.5})
serve.run(HangsOnFirstRequest.bind())
# First request will hang executing, second pending assignment.
response_ref1 = do_request.remote()
response_ref2 = do_request.remote()
# Streaming path does not retry on timeouts, so the requests should be failed.
assert ray.get(response_ref1).status_code == 408
assert ray.get(response_ref2).status_code == 408
ray.get(signal_actor.send.remote())
assert ray.get(do_request.remote()).status_code == 200
def test_streaming_request_already_sent_and_timed_out(ray_instance, shutdown_serve):
"""
Verify that streaming requests are timed out even if some chunks have already
been sent.
"""
signal_actor = SignalActor.remote()
@serve.deployment(graceful_shutdown_timeout_s=0, max_ongoing_requests=1)
class BlockOnSecondChunk:
async def generate_numbers(self) -> Generator[str, None, None]:
for i in range(2):
yield f"generated {i}"
await signal_actor.wait.remote()
def __call__(self, request: Request) -> StreamingResponse:
gen = self.generate_numbers()
return StreamingResponse(gen, status_code=200, media_type="text/plain")
serve.start(http_options={"request_timeout_s": 5})
serve.run(BlockOnSecondChunk.bind())
def health_check():
response = httpx.get(f"{get_application_url(use_localhost=True)}/-/healthz")
assert response.status_code == 200
return True
# Wait for the server to start by doing health check.
wait_for_condition(health_check, timeout=10)
with httpx.stream("GET", get_application_url(use_localhost=True), timeout=10) as r:
iterator = r.iter_text()
# The first chunk should be received successfully.
assert next(iterator) == "generated 0"
assert r.status_code == 200
# The second chunk should time out and raise error.
with pytest.raises(httpx.RemoteProtocolError) as request_error:
next(iterator)
assert "peer closed connection" in str(request_error.value)
@pytest.mark.parametrize(
"ray_instance",
[
{
"RAY_SERVE_ENABLE_TASK_EVENTS": "1",
}
],
indirect=True,
)
def test_request_timeout_does_not_leak_tasks(ray_instance, shutdown_serve):
"""Verify that the ASGI-related tasks exit when a request is timed out.
See https://github.com/ray-project/ray/issues/38368 for details.
"""
@serve.deployment
class Hang:
async def __call__(self):
await asyncio.sleep(1000000)
serve.start(http_options={"request_timeout_s": 0.5})
serve.run(Hang.bind())
def get_num_running_tasks():
return len(
list_tasks(
address=ray_instance["gcs_address"],
filters=[
("NAME", "!=", "ServeController.listen_for_change"),
("TYPE", "=", "ACTOR_TASK"),
("STATE", "=", "RUNNING"),
],
)
)
wait_for_condition(lambda: get_num_running_tasks() == 0)
# Send a number of requests that all will be timed out.
results = ray.get([do_request.remote() for _ in range(10)])
assert all(r.status_code == 408 for r in results)
# The tasks should all be cancelled.
wait_for_condition(lambda: get_num_running_tasks() == 0)
@pytest.mark.parametrize("use_fastapi", [False, True])
def test_cancel_on_http_timeout_during_execution(
ray_instance, shutdown_serve, use_fastapi: bool
):
"""Test the request timing out 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.start(http_options={"request_timeout_s": 0.5})
serve.run(Ingress.bind(inner.bind()))
# Request should time out, causing the handler and handle call to be cancelled.
assert httpx.get(get_application_url(use_localhost=True)).status_code == 408
ray.get(inner_signal_actor.wait.remote(), timeout=10)
ray.get(outer_signal_actor.wait.remote(), timeout=10)
def test_cancel_on_http_timeout_during_assignment(ray_instance, shutdown_serve):
"""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
serve.start(http_options={"request_timeout_s": 0.5})
h = serve.run(Ingress.bind())
# Send a request and wait for it to be ongoing so we know that further requests
# will be blocking trying to assign a replica.
initial_response = h.remote()
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# Request should time out, causing the handler and handle call to be cancelled.
assert httpx.get(get_application_url(use_localhost=True)).status_code == 408
# 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
def test_timeout_error_in_child_deployment_of_fastapi(ray_instance, shutdown_serve):
"""Test that timeout error in child deployment returns 408 with FastAPI ingress."""
app = FastAPI()
signal = SignalActor.remote()
@serve.deployment
class Child:
async def __call__(self):
await signal.wait.remote()
return "ok"
@serve.deployment
@serve.ingress(app)
class Parent:
def __init__(self, child):
self.child = child
@app.get("/")
async def root(self):
return await self.child.remote()
serve.start(http_options={"request_timeout_s": 0.5})
serve.run(Parent.bind(Child.bind()))
r = httpx.get(get_application_url(use_localhost=True))
assert r.status_code == 408
ray.get(signal.send.remote())
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))