273 lines
8.2 KiB
Python
273 lines
8.2 KiB
Python
import importlib
|
|
import logging
|
|
import os
|
|
import sys
|
|
from typing import Any, Dict, Generator
|
|
|
|
import httpx
|
|
import pytest
|
|
import starlette
|
|
from starlette.middleware import Middleware
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import wait_for_condition
|
|
from ray.exceptions import RayActorError
|
|
from ray.serve._private.test_utils import get_application_url
|
|
from ray.serve._private.utils import call_function_from_import_path
|
|
from ray.serve.config import HTTPOptions, gRPCOptions
|
|
from ray.serve.context import _get_global_client
|
|
from ray.serve.schema import (
|
|
LoggingConfig,
|
|
ProxyStatus,
|
|
ServeInstanceDetails,
|
|
)
|
|
|
|
|
|
# ==== Callbacks used in this test ====
|
|
class ASGIMiddleware:
|
|
def __init__(self, app):
|
|
self.app = app
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
scope.get("headers").append((b"custom_header_key", "custom_header_value"))
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
def add_middleware():
|
|
return [Middleware(ASGIMiddleware)]
|
|
|
|
|
|
class MyServeFormatter(logging.Formatter):
|
|
def format(self, record: logging.LogRecord):
|
|
log_msg = super().format(record)
|
|
return "MyCustom message: hello " + log_msg
|
|
|
|
|
|
def add_logger():
|
|
ray_logger = logging.getLogger("ray.serve")
|
|
handler = logging.StreamHandler()
|
|
handler.setFormatter(MyServeFormatter())
|
|
ray_logger.addHandler(handler)
|
|
|
|
|
|
def raise_error_callback():
|
|
raise RuntimeError("this is from raise_error_callback")
|
|
|
|
|
|
def return_bad_objects():
|
|
return [1, 2, 3]
|
|
|
|
|
|
ADD_MIDDLEWARE_IMPORT_PATH = "ray.serve.tests.test_callback.add_middleware"
|
|
ADD_LOGGER_IMPORT_PATH = "ray.serve.tests.test_callback.add_logger"
|
|
RAISE_ERROR_IMPORT_PATH = "ray.serve.tests.test_callback.raise_error_callback"
|
|
RETURN_BAD_OBJECTS_IMPORT_PATH = "ray.serve.tests.test_callback.return_bad_objects"
|
|
NOT_CALLABLE_OBJECT = 1
|
|
# ==== end ====
|
|
|
|
|
|
@pytest.fixture()
|
|
def ray_instance(
|
|
request: pytest.FixtureRequest,
|
|
) -> Generator[Dict[str, Any], None, None]:
|
|
"""Starts and stops a Ray instance for this test.
|
|
|
|
Args:
|
|
request: request.param should contain a dictionary of env vars and
|
|
their values. The Ray instance will be started with these env vars.
|
|
|
|
Yields:
|
|
Dict[str, Any]: The dict returned by ``ray.init`` for the started cluster.
|
|
"""
|
|
|
|
original_env_vars = os.environ.copy()
|
|
|
|
try:
|
|
requested_env_vars = request.param
|
|
except AttributeError:
|
|
requested_env_vars = {}
|
|
|
|
os.environ.update(requested_env_vars)
|
|
importlib.reload(ray.serve._private.constants)
|
|
importlib.reload(ray.serve._private.controller)
|
|
importlib.reload(ray.serve._private.proxy)
|
|
|
|
yield ray.init()
|
|
|
|
serve.shutdown()
|
|
# wait_for_processes=True blocks until the raylet/GCS/etc. subprocesses
|
|
# have fully exited. Without it, this teardown races the next test's
|
|
# ray.init() and the new raylet can fail to register the driver.
|
|
ray.shutdown(wait_for_processes=True)
|
|
|
|
os.environ.clear()
|
|
os.environ.update(original_env_vars)
|
|
|
|
|
|
def test_call_function_from_import_path():
|
|
"""Basic test for call_function_from_import_path"""
|
|
|
|
# basic
|
|
assert [1, 2, 3] == call_function_from_import_path(RETURN_BAD_OBJECTS_IMPORT_PATH)
|
|
|
|
# rasie exception when callback function raise exception
|
|
with pytest.raises(RuntimeError, match="this is from raise_error_callback"):
|
|
call_function_from_import_path(RAISE_ERROR_IMPORT_PATH)
|
|
|
|
# raise exception when providing invalid import path
|
|
with pytest.raises(ValueError, match="cannot be imported"):
|
|
call_function_from_import_path("not_exist")
|
|
|
|
# raise exception when providing non callable object
|
|
with pytest.raises(TypeError, match="is not callable"):
|
|
call_function_from_import_path(
|
|
"ray.serve.tests.test_callback.NOT_CALLABLE_OBJECT"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_instance",
|
|
[
|
|
{
|
|
"RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH": ADD_LOGGER_IMPORT_PATH,
|
|
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": ADD_MIDDLEWARE_IMPORT_PATH,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_callback(ray_instance, capsys):
|
|
"""Test callback function works in http proxy and controller"""
|
|
serve.start(
|
|
http_options=HTTPOptions(
|
|
host="0.0.0.0",
|
|
request_timeout_s=500,
|
|
),
|
|
)
|
|
|
|
@serve.deployment
|
|
class Model:
|
|
def __call__(self, request: starlette.requests.Request):
|
|
headers = request.scope.get("headers")
|
|
for k, v in headers:
|
|
if k == b"custom_header_key":
|
|
return v
|
|
return "Not found custom headers"
|
|
|
|
serve.run(Model.bind())
|
|
url = get_application_url()
|
|
resp = httpx.get(url)
|
|
|
|
assert resp.text == "custom_header_value"
|
|
captured = capsys.readouterr()
|
|
assert "MyCustom message: hello" in captured.err
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_instance",
|
|
[
|
|
{
|
|
"RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH": "not_exist",
|
|
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": RAISE_ERROR_IMPORT_PATH,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_callback_fail(ray_instance):
|
|
"""Test actor call call_function_from_import_path rasing exception.
|
|
|
|
Actor will fail to be started and further call will raise RayActorError.
|
|
"""
|
|
|
|
actor_def = ray.serve._private.proxy.ProxyActor
|
|
handle = actor_def.remote(
|
|
http_options=HTTPOptions(host="http_proxy", root_path="/", port=123),
|
|
grpc_options=gRPCOptions(),
|
|
node_ip_address="127.0.0.1",
|
|
node_id="123",
|
|
logging_config=LoggingConfig(),
|
|
long_poll_client="fake_client",
|
|
)
|
|
with pytest.raises(RayActorError, match="this is from raise_error_callback"):
|
|
ray.get(handle.ready.remote())
|
|
|
|
serve_controller = ray.serve._private.controller.ServeController
|
|
actor_def = ray.actor._make_actor(serve_controller, {})
|
|
handle = actor_def.remote(
|
|
http_options=HTTPOptions(),
|
|
grpc_options=gRPCOptions(),
|
|
global_logging_config=LoggingConfig(),
|
|
)
|
|
with pytest.raises(RayActorError, match="cannot be imported"):
|
|
ray.get(handle.check_alive.remote())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_instance",
|
|
[
|
|
{
|
|
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": RETURN_BAD_OBJECTS_IMPORT_PATH,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_http_proxy_return_aribitary_objects(ray_instance):
|
|
"""Test invalid callback path in http proxy"""
|
|
|
|
actor_def = ray.serve._private.proxy.ProxyActor
|
|
handle = actor_def.remote(
|
|
http_options=HTTPOptions(host="http_proxy", root_path="/", port=123),
|
|
grpc_options=gRPCOptions(),
|
|
node_ip_address="127.0.0.1",
|
|
node_id="123",
|
|
logging_config=LoggingConfig(),
|
|
long_poll_client="fake_client",
|
|
)
|
|
with pytest.raises(
|
|
RayActorError, match="must return a list of Starlette middlewares"
|
|
):
|
|
ray.get(handle.ready.remote())
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ray_instance",
|
|
[
|
|
{
|
|
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": RAISE_ERROR_IMPORT_PATH,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
def test_http_proxy_callback_failures(ray_instance, capsys):
|
|
"""Test http proxy keeps restarting when callback function fails"""
|
|
|
|
try:
|
|
serve.start()
|
|
except RayActorError:
|
|
# serve.start will fail because the http proxy is not started successfully
|
|
# and client use proxy handle to check the proxy readiness, so it will raise
|
|
# RayActorError.
|
|
pass
|
|
|
|
client = _get_global_client()
|
|
|
|
def check_http_proxy_keep_restarting():
|
|
# The proxy will be under "STARTING" status and keep restarting.
|
|
prev_actor_id = None
|
|
while True:
|
|
serve_details = ServeInstanceDetails(**client.get_serve_details())
|
|
for _, proxy_info in serve_details.proxies.items():
|
|
if proxy_info.status != ProxyStatus.STARTING:
|
|
return False
|
|
if prev_actor_id is None:
|
|
prev_actor_id = proxy_info.actor_id
|
|
break
|
|
elif prev_actor_id != proxy_info.actor_id:
|
|
return True
|
|
|
|
wait_for_condition(check_http_proxy_keep_restarting)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|