384 lines
12 KiB
Python
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__]))
|