import asyncio import os import sys from typing import Dict, List, Optional, overload import httpx import pytest import starlette.responses from fastapi import FastAPI from pydantic import BaseModel, ValidationError import ray from ray import serve from ray._common.test_utils import SignalActor, wait_for_condition from ray.serve._private.api import call_user_app_builder_with_args_if_necessary from ray.serve._private.common import DeploymentID from ray.serve._private.constants import ( DEFAULT_MAX_ONGOING_REQUESTS, RAY_SERVE_ENABLE_HA_PROXY, SERVE_DEFAULT_APP_NAME, ) from ray.serve._private.request_router.common import ( PendingRequest, ) from ray.serve._private.request_router.replica_wrapper import ( RunningReplica, ) from ray.serve._private.request_router.request_router import ( RequestRouter, ) from ray.serve._private.test_utils import SharedCounter, get_application_url from ray.serve.config import GangSchedulingConfig, RequestRouterConfig from ray.serve.deployment import Application from ray.serve.exceptions import RayServeException from ray.serve.handle import DeploymentHandle @pytest.fixture def serve_and_ray_shutdown(): yield serve.shutdown() ray.shutdown() @serve.deployment() def sync_d(): return "sync!" @serve.deployment() async def async_d(): return "async!" @serve.deployment class Counter: def __init__(self): self.count = 0 def __call__(self): self.count += 1 return {"count": self.count} @serve.deployment class AsyncCounter: async def __init__(self): await asyncio.sleep(0.01) self.count = 0 async def __call__(self): self.count += 1 await asyncio.sleep(0.01) return {"count": self.count} def test_ingress_wrapper_preserves_metadata(): app = FastAPI() class OriginalIngress: """Sample ingress class.""" value: int wrapped_cls = serve.ingress(app)(OriginalIngress) assert wrapped_cls.__name__ == OriginalIngress.__name__ assert wrapped_cls.__qualname__ == OriginalIngress.__qualname__ assert wrapped_cls.__module__ == OriginalIngress.__module__ assert wrapped_cls.__doc__ == OriginalIngress.__doc__ assert wrapped_cls.__annotations__ == OriginalIngress.__annotations__ assert getattr(wrapped_cls, "__wrapped__", None) is OriginalIngress def test_ingress_async_init(serve_instance): """Test that async __init__ works correctly with @serve.ingress. Without the fix, cls.__init__() returns a coroutine that is silently discarded, so self.msg is never set and the /check endpoint raises AttributeError. """ app = FastAPI() @serve.deployment @serve.ingress(app) class MyDeployment: async def __init__(self): await asyncio.sleep(0.01) self.msg = "initialized" @app.get("/check") async def check(self): return self.msg serve.run(MyDeployment.bind()) resp = httpx.get(f"{get_application_url()}/check") assert resp.status_code == 200 assert resp.json() == "initialized" def test_ingress_sync_init_subclass_raises(): """Subclassing a @serve.ingress class with a sync __init__ must fail loudly. The parent __init__ is async, so a sync `super().__init__(...)` call from a subclass would silently drop the returned coroutine and leave the replica uninitialized (e.g. `_serve_asgi_lifespan` never set), surfacing later as a cryptic AttributeError at runtime. We reject the bad pattern at class-definition time with a clear migration message instead. """ app = FastAPI() class BaseIngress: pass WrappedIngress = serve.ingress(app)(BaseIngress) with pytest.raises(TypeError, match="async def"): class ExtendedIngress(WrappedIngress): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def test_ingress_sync_init_via_mixin_subclass_raises(): """A sync __init__ inherited from a mixin earlier in the MRO must also raise. `class Sub(SyncMixin, WrappedIngress)` doesn't define `__init__` on `Sub` itself, but the resolved `Sub.__init__` is the sync mixin's, which would still silently drop the wrapper's async coroutine. The check must use MRO resolution, not just the subclass `__dict__`. """ app = FastAPI() class BaseIngress: pass WrappedIngress = serve.ingress(app)(BaseIngress) class SyncMixin: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) with pytest.raises(TypeError, match="async def"): class ExtendedIngress(SyncMixin, WrappedIngress): pass def test_ingress_async_init_subclass_allowed(): """Subclassing a @serve.ingress class with an async __init__ is allowed.""" app = FastAPI() class BaseIngress: pass WrappedIngress = serve.ingress(app)(BaseIngress) class ExtendedIngress(WrappedIngress): async def __init__(self, *args, **kwargs): await super().__init__(*args, **kwargs) # No __init__ defined on subclass is also allowed (inherits parent's async). class InheritingIngress(WrappedIngress): pass assert ExtendedIngress is not None assert InheritingIngress is not None class FakeRequestRouter(RequestRouter): async def choose_replicas( self, candidate_replicas: List[RunningReplica], pending_request: Optional[PendingRequest] = None, ) -> List[List[RunningReplica]]: return [candidate_replicas] def initialize_state(self, test_parameter: int = 0): print("Called initialize_state in FakeRequestRouter") self.test_parameter = test_parameter @serve.deployment( request_router_config=RequestRouterConfig(request_router_class=FakeRequestRouter) ) class AppWithCustomRequestRouter: def __call__(self) -> str: return "Hello, world!" def test_e2e(serve_instance): @serve.deployment(name="api") def function(starlette_request): return {"method": starlette_request.method} serve.run(function.bind()) url = f"{get_application_url()}/api" resp = httpx.get(url).json()["method"] assert resp == "GET" resp = httpx.post(url).json()["method"] assert resp == "POST" def test_starlette_response_basic(serve_instance): @serve.deployment def basic(): return starlette.responses.Response("Hello, world!", media_type="text/plain") serve.run(basic.bind()) url = f"{get_application_url()}/" assert httpx.get(url).text == "Hello, world!" def test_starlette_response_html(serve_instance): @serve.deployment def html(): return starlette.responses.HTMLResponse( "

Hello, world!

" ) serve.run(html.bind()) url = f"{get_application_url()}/" assert httpx.get(url).text == "

Hello, world!

" def test_starlette_response_plain_text(serve_instance): @serve.deployment def plain_text(): return starlette.responses.PlainTextResponse("Hello, world!") serve.run(plain_text.bind()) url = f"{get_application_url()}/" assert httpx.get(url).text == "Hello, world!" def test_starlette_response_json(serve_instance): @serve.deployment def json(): return starlette.responses.JSONResponse({"hello": "world"}) serve.run(json.bind()) url = f"{get_application_url()}/json" assert httpx.get(url).json()["hello"] == "world" def test_starlette_response_redirect(serve_instance): @serve.deployment def basic(): return starlette.responses.Response("Hello, world!", media_type="text/plain") @serve.deployment(name="redirect") def redirect(): url = get_application_url("HTTP", app_name="app1") return starlette.responses.RedirectResponse(url=url) serve.run(basic.bind(), name="app1", route_prefix="/") serve.run(redirect.bind(), name="app2", route_prefix="/redirect") url = f"{get_application_url(app_name='app2')}" assert httpx.get(url, follow_redirects=True).text == "Hello, world!" def test_starlette_response_streaming(serve_instance): @serve.deployment def streaming(): async def slow_numbers(): for number in range(1, 4): yield str(number) await asyncio.sleep(0.01) return starlette.responses.StreamingResponse( slow_numbers(), media_type="text/plain", status_code=418 ) serve.run(streaming.bind()) url = f"{get_application_url()}/" resp = httpx.get(url) assert resp.text == "123" assert resp.status_code == 418 @pytest.mark.parametrize("use_async", [False, True]) def test_deploy_function_no_params(serve_instance, use_async): if use_async: expected_output = "async!" deployment_cls = async_d else: expected_output = "sync!" deployment_cls = sync_d handle = serve.run(deployment_cls.bind()) url = f"{get_application_url()}/{deployment_cls.name}" assert httpx.get(url).text == expected_output assert handle.remote().result() == expected_output @pytest.mark.parametrize("use_async", [False, True]) def test_deploy_function_no_params_call_with_param(serve_instance, use_async): if use_async: expected_output = "async!" deployment_cls = async_d else: expected_output = "sync!" deployment_cls = sync_d handle = serve.run(deployment_cls.bind()) url = f"{get_application_url()}/{deployment_cls.name}" assert httpx.get(url).text == expected_output with pytest.raises( TypeError, match=r"\(\) takes 0 positional arguments but 1 was given" ): handle.remote(1).result() with pytest.raises(TypeError, match=r"\(\) got an unexpected keyword argument"): handle.remote(key=1).result() @pytest.mark.parametrize("use_async", [False, True]) def test_deploy_class_no_params(serve_instance, use_async): if use_async: deployment_cls = AsyncCounter else: deployment_cls = Counter handle = serve.run(deployment_cls.bind()) url = f"{get_application_url()}/{deployment_cls.name}" assert httpx.get(url).json() == {"count": 1} assert httpx.get(url).json() == {"count": 2} assert handle.remote().result() == {"count": 3} def test_user_config(serve_instance): @serve.deployment("counter", num_replicas=2, user_config={"count": 123, "b": 2}) class Counter: def __init__(self): self.count = 10 def __call__(self, *args): return self.count, os.getpid() def reconfigure(self, config): self.count = config["count"] handle = serve.run(Counter.bind()) def check(val, num_replicas): pids_seen = set() for i in range(100): result = handle.remote().result() if str(result[0]) != val: return False pids_seen.add(result[1]) return len(pids_seen) == num_replicas wait_for_condition(lambda: check("123", 2)) Counter = Counter.options(num_replicas=3) serve.run(Counter.bind()) wait_for_condition(lambda: check("123", 3)) Counter = Counter.options(user_config={"count": 456}) serve.run(Counter.bind()) wait_for_condition(lambda: check("456", 3)) def test_user_config_empty(serve_instance): @serve.deployment(user_config={}) class Counter: def __init__(self): self.count = 0 def __call__(self, *args): return self.count def reconfigure(self, config): self.count += 1 handle = serve.run(Counter.bind()) assert handle.remote().result() == 1 def test_scaling_replicas(serve_instance): @serve.deployment(name="counter", num_replicas=2) class Counter: def __init__(self): self.count = 0 def __call__(self, _): self.count += 1 return self.count serve.run(Counter.bind()) counter_result = [] for _ in range(10): url = f"{get_application_url()}/counter" resp = httpx.get(url).json() counter_result.append(resp) # If the load is shared among two replicas. The max result cannot be 10. assert max(counter_result) < 10 serve.run(Counter.options(num_replicas=1).bind()) counter_result = [] for _ in range(10): url = f"{get_application_url()}/counter" resp = httpx.get(url).json() counter_result.append(resp) # Give some time for a replica to spin down. But majority of the request # should be served by the only remaining replica. assert max(counter_result) - min(counter_result) > 6 def test_starlette_request(serve_instance): @serve.deployment(name="api") async def echo_body(starlette_request): data = await starlette_request.body() return data serve.run(echo_body.bind()) # Long string to test serialization of multiple messages. UVICORN_HIGH_WATER_MARK = 65536 # max bytes in one message long_string = "x" * 10 * UVICORN_HIGH_WATER_MARK url = f"{get_application_url()}/api" resp = httpx.post(url, data=long_string).text assert resp == long_string def test_shutdown_destructor(serve_instance): signal = SignalActor.remote() @serve.deployment class A: def __del__(self): signal.send.remote() serve.run(A.bind(), name="A") serve.delete("A") ray.get(signal.wait.remote(), timeout=10) def test_run_get_ingress_node(serve_instance): """Check that serve.run() with a node returns the ingress.""" @serve.deployment class Driver: def __init__(self, handle): self._h = handle async def __call__(self, *args): return await self._h.remote() @serve.deployment class f: def __call__(self, *args): return "got f" handle = serve.run(Driver.bind(f.bind())) assert handle.remote().result() == "got f" def test_deploy_application_basic(serve_instance): """Test deploy multiple applications""" @serve.deployment def f(): return "got f" @serve.deployment def g(): return "got g" @serve.deployment def h(): return "got h" @serve.deployment class Model1: def __call__(self, *args): return "got model1" app = FastAPI() @serve.deployment @serve.ingress(app) class MyFastAPIDeployment: @app.get("/") def root(self): return "Hello, world!" # Test function deployment with app name f_handle = serve.run(f.bind(), name="app_f") assert f_handle.remote().result() == "got f" url = f"{get_application_url(app_name='app_f')}/" assert httpx.get(url).text == "got f" # Test function deployment with app name and route_prefix g_handle = serve.run(g.bind(), name="app_g", route_prefix="/app_g") assert g_handle.remote().result() == "got g" url = f"{get_application_url(app_name='app_g')}" assert httpx.get(url).text == "got g" # Test function deployment with app name and route_prefix h_handle = serve.run(h.bind(), name="app_h", route_prefix="/my_prefix") assert h_handle.remote().result() == "got h" url = f"{get_application_url(app_name='app_h')}" assert httpx.get(url).text == "got h" # Test FastAPI serve.run(MyFastAPIDeployment.bind(), name="FastAPI", route_prefix="/hello") url = f"{get_application_url(app_name='FastAPI')}" assert httpx.get(url, follow_redirects=True).text == '"Hello, world!"' def test_delete_application(serve_instance): """Test delete single application""" @serve.deployment def f(): return "got f" @serve.deployment def g(): return "got g" f_handle = serve.run(f.bind(), name="app_f") g_handle = serve.run(g.bind(), name="app_g", route_prefix="/app_g") assert f_handle.remote().result() == "got f" url = get_application_url("HTTP", app_name="app_f") assert httpx.get(url).text == "got f" serve.delete("app_f") url = "http://localhost:8000/app_f" assert "Path '/app_f' not found" in httpx.get(url).text # delete again, no exception & crash expected. serve.delete("app_f") # make sure no affect to app_g assert g_handle.remote().result() == "got g" url = get_application_url("HTTP", app_name="app_g") assert httpx.get(url).text == "got g" @pytest.mark.asyncio async def test_delete_while_initializing(serve_instance): """Test that __del__ runs when a replica terminates while initializing.""" signal = SignalActor.remote() counter = SharedCounter.remote() @serve.deployment(graceful_shutdown_timeout_s=0.01) class HangingStart: async def __init__( self, signal: ray.actor.ActorHandle, counter: ray.actor.ActorHandle ): self.signal = signal self.counter = counter await signal.send.remote() print("HangingStart set the EventHolder.") await asyncio.sleep(10000) async def __del__(self): print("Running __del__") await self.counter.inc.remote() serve._run(HangingStart.bind(signal, counter), _blocking=False) print("Waiting for the deployment to start initialization.") await signal.wait.remote() print("Calling serve.delete().") serve.delete(name=SERVE_DEFAULT_APP_NAME) # Ensure that __del__ ran once, even though the deployment terminated # during initialization. assert (await counter.get.remote()) == 1 def test_deployment_name_with_app_name(serve_instance): """Test replica name with app name as prefix""" controller = serve_instance._controller @serve.deployment def g(): return "got g" serve.run(g.bind()) deployment_info = ray.get(controller._all_running_replicas.remote()) assert DeploymentID(name="g") in deployment_info @serve.deployment def f(): return "got f" serve.run(f.bind(), route_prefix="/f", name="app1") deployment_info = ray.get(controller._all_running_replicas.remote()) assert DeploymentID(name="f", app_name="app1") in deployment_info def test_deploy_application_with_same_name(serve_instance): """Test deploying two applications with the same name.""" controller = serve_instance._controller @serve.deployment class Model: def __call__(self): return "got model" handle = serve.run(Model.bind(), name="app") assert handle.remote().result() == "got model" url = get_application_url("HTTP", app_name="app") assert httpx.get(url).text == "got model" deployment_info = ray.get(controller._all_running_replicas.remote()) assert DeploymentID(name="Model", app_name="app") in deployment_info # After deploying a new app with the same name, no Model replicas should be running @serve.deployment class Model1: def __call__(self): return "got model1" handle = serve.run(Model1.bind(), name="app") assert handle.remote().result() == "got model1" url = get_application_url("HTTP", app_name="app") assert httpx.get(url).text == "got model1" deployment_info = ray.get(controller._all_running_replicas.remote()) assert DeploymentID(name="Model1", app_name="app") in deployment_info assert ( DeploymentID(name="Model", app_name="app") not in deployment_info or deployment_info[DeploymentID(name="Model", app_name="app")] == [] ) # Redeploy with same app to update route prefix serve.run(Model1.bind(), name="app", route_prefix="/my_app") url_new = get_application_url("HTTP", app_name="app") # Reread the url to get the correct port value old_url_route_prefix = "/" url = ( get_application_url("HTTP", app_name="app", exclude_route_prefix=True) ) + old_url_route_prefix assert httpx.get(url_new).text == "got model1" assert httpx.get(url).status_code == 404 def test_deploy_application_with_route_prefix_conflict(serve_instance): """Test route_prefix conflicts with different apps.""" @serve.deployment class Model: def __call__(self): return "got model" handle = serve.run(Model.bind(), name="app") assert handle.remote().result() == "got model" url = get_application_url("HTTP", app_name="app") assert httpx.get(url).text == "got model" # Second app with the same route_prefix fails to be deployed @serve.deployment class Model1: def __call__(self): return "got model1" with pytest.raises(RayServeException): serve.run(Model1.bind(), name="app1") # Update the route prefix handle = serve.run(Model1.bind(), name="app1", route_prefix="/model1") assert handle.remote().result() == "got model1" url_new = get_application_url("HTTP", app_name="app1") assert httpx.get(url_new).text == "got model1" # The "app" application should still work properly assert httpx.get(url).text == "got model" class TestAppBuilder: @serve.deployment class A: pass @serve.deployment def f(): pass class TypedArgs(BaseModel): message: str num_replicas: Optional[int] = None def test_prebuilt_app(self): a = self.A.bind() assert call_user_app_builder_with_args_if_necessary(a, {}) == a f = self.f.bind() assert call_user_app_builder_with_args_if_necessary(f, {}) == f with pytest.raises( ValueError, match="Arguments can only be passed to an application builder function", ): call_user_app_builder_with_args_if_necessary(f, {"key": "val"}) def test_invalid_builder(self): class ThisShouldBeAFunction: pass with pytest.raises( TypeError, match=( "Expected a built Serve application " "or an application builder function" ), ): call_user_app_builder_with_args_if_necessary(ThisShouldBeAFunction, {}) def test_invalid_signature(self): def builder_with_two_args(args1, args2): return self.f.bind() with pytest.raises( TypeError, match="Application builder functions should take exactly one parameter", ): call_user_app_builder_with_args_if_necessary(builder_with_two_args, {}) def test_builder_returns_bad_type(self): def return_none(args): self.f.bind() with pytest.raises( TypeError, match="Application builder functions must return a", ): call_user_app_builder_with_args_if_necessary(return_none, {}) def return_unbound_deployment(args): return self.f with pytest.raises( TypeError, match="Application builder functions must return a", ): call_user_app_builder_with_args_if_necessary(return_unbound_deployment, {}) def test_basic_no_args(self): def build_function(args): return self.A.bind() assert isinstance( call_user_app_builder_with_args_if_necessary(build_function, {}), Application, ) def build_class(args): return self.f.bind() assert isinstance( call_user_app_builder_with_args_if_necessary(build_class, {}), Application ) def test_args_dict(self): args_dict = {"message": "hiya", "num_replicas": "3"} def build(args): assert len(args) == 2 assert args["message"] == "hiya" assert args["num_replicas"] == "3" return self.A.options(num_replicas=int(args["num_replicas"])).bind( args["message"] ) app = call_user_app_builder_with_args_if_necessary(build, args_dict) assert isinstance(app, Application) def test_args_typed(self): args_dict = {"message": "hiya", "num_replicas": "3"} def build(args): """Builder with no type hint.""" return self.A.options(num_replicas=args["num_replicas"]).bind( args["message"] ) app = call_user_app_builder_with_args_if_necessary(build, args_dict) assert isinstance(app, Application) def build(args: Dict[str, str]): """Builder with vanilla type hint.""" return self.A.options(num_replicas=args["num_replicas"]).bind( args["message"] ) app = call_user_app_builder_with_args_if_necessary(build, args_dict) assert isinstance(app, Application) class ForwardRef: def build(args: "ForwardRef"): """Builder with forward reference as type hint.""" return self.A.options(num_replicas=args["num_replicas"]).bind( args["message"] ) app = call_user_app_builder_with_args_if_necessary(ForwardRef.build, args_dict) assert isinstance(app, Application) def build(args: self.TypedArgs): """Builder with Pydantic model type hint.""" assert isinstance(args, self.TypedArgs) assert args.message == "hiya" assert args.num_replicas == 3 return self.A.options(num_replicas=args.num_replicas).bind(args.message) app = call_user_app_builder_with_args_if_necessary(build, args_dict) assert isinstance(app, Application) # Sanity check that pydantic validation works. # 1) Check that validation permits a missing optional field. def check_missing_optional(args: self.TypedArgs): assert args.message == "hiya" assert args.num_replicas is None return self.A.bind() app = call_user_app_builder_with_args_if_necessary( check_missing_optional, {"message": "hiya"} ) assert isinstance(app, Application) # 2) Check that validation rejects a missing required field. def check_missing_required(args: self.TypedArgs): assert False, "Shouldn't get here because validation failed." # Pydantic v2 uses "Field required" (capitalized) with pytest.raises(ValidationError, match="Field required"): call_user_app_builder_with_args_if_necessary( check_missing_required, {"num_replicas": "10"} ) def test_pydantic_version_compatibility(self): """Check compatibility with pydantic v2.""" from pydantic import BaseModel cat_dict = {"color": "orange", "age": 10} class Cat(BaseModel): color: str age: int def build(args: Cat): """Builder with Pydantic model type hint.""" assert isinstance(args, Cat), f"args type: {type(args)}" assert args.color == cat_dict["color"] assert args.age == cat_dict["age"] return self.A.bind(f"My {args.color} cat is {args.age} years old.") app = call_user_app_builder_with_args_if_necessary(build, cat_dict) assert isinstance(app, Application) def test_no_slash_route_prefix(serve_instance): """Test serve run with no slash route_prefix. This test ensure when serve runs with no prefix slash in route_prefix, it will throw good error message. """ @serve.deployment def f(): pass with pytest.raises( ValueError, match=( r"Invalid route_prefix 'no_slash', " r"must start with a forward slash \('/'\)" ), ): serve.run(f.bind(), route_prefix="no_slash") def test_mutually_exclusive_max_replicas_per_node_and_placement_group_bundles(): with pytest.raises( ValueError, match=( "Setting max_replicas_per_node is not allowed when " "placement_group_bundles is provided." ), ): @serve.deployment(max_replicas_per_node=3, placement_group_bundles=[{"CPU": 1}]) def f(): pass with pytest.raises( ValueError, match=( "Setting max_replicas_per_node is not allowed when " "placement_group_bundles is provided." ), ): @serve.deployment def g(): pass g.options(max_replicas_per_node=3, placement_group_bundles=[{"CPU": 1}]) def test_mutually_exclusive_max_replicas_per_node_and_gang_scheduling_config(): with pytest.raises( ValueError, match=( "Setting max_replicas_per_node is not allowed when " "gang_scheduling_config is provided." ), ): @serve.deployment( max_replicas_per_node=3, num_replicas=4, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) class A: pass with pytest.raises( ValueError, match=( "Setting max_replicas_per_node is not allowed when " "gang_scheduling_config is provided." ), ): @serve.deployment class B: pass B.options( max_replicas_per_node=3, num_replicas=4, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) def test_mutually_exclusive_max_replicas_per_node_and_gang_scheduling_config_merged(): """Verify the check catches conflicts from the merged config.""" @serve.deployment(max_replicas_per_node=1, num_replicas=2) class A: pass with pytest.raises( ValueError, match=( "Setting max_replicas_per_node is not allowed when " "gang_scheduling_config is provided." ), ): A.options( num_replicas=2, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) def test_mutually_exclusive_placement_group_strategy_and_gang_scheduling_config(): with pytest.raises( ValueError, match=( "Setting placement_group_strategy is not allowed when " "gang_scheduling_config is provided." ), ): @serve.deployment( placement_group_strategy="SPREAD", placement_group_bundles=[{"CPU": 1}], num_replicas=4, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) class A: pass with pytest.raises( ValueError, match=( "Setting placement_group_strategy is not allowed when " "gang_scheduling_config is provided." ), ): @serve.deployment class B: pass B.options( placement_group_strategy="SPREAD", placement_group_bundles=[{"CPU": 1}], num_replicas=4, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) def test_mutually_exclusive_placement_group_strategy_and_gang_scheduling_config_merged(): """Verify the check catches conflicts from the merged config.""" @serve.deployment( placement_group_strategy="SPREAD", placement_group_bundles=[{"CPU": 1}], num_replicas=2, ) class A: pass with pytest.raises( ValueError, match=( "Setting placement_group_strategy is not allowed when " "gang_scheduling_config is provided." ), ): A.options( num_replicas=2, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) def test_status_basic(serve_instance): # Before Serve is started, serve.status() should have an empty list of applications assert len(serve.status().applications) == 0 @serve.deployment(ray_actor_options={"num_cpus": 0.1}) class A: def __call__(self, val: int): return val + 1 @serve.deployment(ray_actor_options={"num_cpus": 0.1}) def f(): return "hello world" @serve.deployment(ray_actor_options={"num_cpus": 0.1}) class MyDriver: def __init__(self, handle): self._h = handle async def __call__(self): return await self._h.remote() handle_1 = serve.run(A.bind(), name="plus", route_prefix="/a") handle_2 = serve.run(MyDriver.bind(f.bind()), name="hello", route_prefix="/b") assert handle_1.remote(8).result() == 9 assert handle_2.remote().result() == "hello world" app_status = serve.status().applications assert len(app_status) == 2 assert set(app_status["plus"].deployments.keys()) == {"A"} assert set(app_status["hello"].deployments.keys()) == {"MyDriver", "f"} for d in app_status["plus"].deployments.values(): assert d.status == "HEALTHY" and d.replica_states == {"RUNNING": 1} for d in app_status["plus"].deployments.values(): assert d.status == "HEALTHY" and d.replica_states == {"RUNNING": 1} proxy_status = serve.status().proxies assert all(p == "HEALTHY" for p in proxy_status.values()) def test_status_constructor_error(serve_instance): """Deploys Serve deployment that errors out in constructor, checks that the traceback is surfaced in serve.status(). """ @serve.deployment class A: def __init__(self): _ = 1 / 0 serve._run(A.bind(), _blocking=False) def check_for_failed_app(): default_app = serve.status().applications[SERVE_DEFAULT_APP_NAME] error_substr = "ZeroDivisionError: division by zero" assert ( default_app.status == "DEPLOY_FAILED" and error_substr in default_app.deployments["A"].message ) assert default_app.deployments["A"].status == "DEPLOY_FAILED" return True wait_for_condition(check_for_failed_app) # Instead of hanging forever, a request to the application should # return a 503 error to reflect the failed deployment state. # The timeout is there to prevent the test from hanging and blocking # the test suite if it does fail. r = httpx.post("http://localhost:8000/", timeout=10) assert r.status_code == 503 and "unavailable" in r.text.lower() @serve.deployment class A: def __init__(self): pass serve._run(A.bind(), _blocking=False) def check_for_running_app(): default_app = serve.status().applications[SERVE_DEFAULT_APP_NAME] assert default_app.status == "RUNNING" assert default_app.deployments["A"].status == "HEALTHY" return True wait_for_condition(check_for_running_app) @pytest.mark.skipif( sys.platform == "win32", reason="Runtime env support experimental on windows" ) def test_status_package_unavailable_in_controller(serve_instance): """Test that exceptions raised from packages that are installed on deployment actors but not on controller is serialized and surfaced properly in serve.status(). """ @serve.deployment class MyDeployment: def __init__(self): import pymysql from sqlalchemy import create_engine pymysql.install_as_MySQLdb() create_engine("mysql://some_wrong_url:3306").connect() ray_actor_options = {"runtime_env": {"pip": ["PyMySQL", "sqlalchemy==1.3.19"]}} serve._run( MyDeployment.options(ray_actor_options=ray_actor_options).bind(), _blocking=False, ) def check_for_failed_deployment(): default_app = serve.status().applications[SERVE_DEFAULT_APP_NAME] assert default_app.status == "DEPLOY_FAILED" assert "some_wrong_url" in default_app.deployments["MyDeployment"].message return True wait_for_condition(check_for_failed_deployment, timeout=60) def test_get_app_handle_basic(serve_instance): @serve.deployment(ray_actor_options={"num_cpus": 0.1}) class M: def __call__(self, val: int): return val + 1 @serve.deployment(ray_actor_options={"num_cpus": 0.1}) def f(): return "hello world" @serve.deployment(ray_actor_options={"num_cpus": 0.1}) class MyDriver: def __init__(self, handle): self._h = handle async def __call__(self): return await self._h.remote() serve.run(M.bind(), name="A", route_prefix="/a") serve.run(MyDriver.bind(f.bind()), name="B", route_prefix="/b") handle = serve.get_app_handle("A") assert handle.remote(8).result() == 9 handle = serve.get_app_handle("B") assert handle.remote().result() == "hello world" def test_get_app_handle_dne(serve_instance): """Test getting app handle to an app that doesn't exist.""" with pytest.raises(RayServeException) as e: serve.get_app_handle("random") assert "Application 'random' does not exist" in str(e.value) def test_get_app_handle_within_deployment_async(serve_instance): @serve.deployment() class a: def __init__(self, handle): self.handle = handle def __call__(self, val: int): return val + 2 @serve.deployment() class b: def __call__(self, val: int): return val @serve.deployment async def f(val): handle = serve.get_app_handle(SERVE_DEFAULT_APP_NAME) result = await handle.remote(val) return f"The answer is {result}" serve.run(a.bind(b.bind()), route_prefix="/math") serve.run(f.bind(), name="call") handle = serve.get_app_handle("call") assert handle.remote(7).result() == "The answer is 9" def test_get_deployment_handle_basic(serve_instance): @serve.deployment(ray_actor_options={"num_cpus": 0.1}) def f(): return "hello world" @serve.deployment(ray_actor_options={"num_cpus": 0.1}) class MyDriver: def __init__(self, handle): self._h = handle async def __call__(self): return f"{await self._h.remote()}!!" serve.run(MyDriver.bind(f.bind())) handle = serve.get_deployment_handle("f", SERVE_DEFAULT_APP_NAME) assert isinstance(handle, DeploymentHandle) assert handle.remote().result() == "hello world" app_handle = serve.get_app_handle(SERVE_DEFAULT_APP_NAME) assert isinstance(app_handle, DeploymentHandle) assert app_handle.remote().result() == "hello world!!" def test_deployment_handle_nested_in_obj(serve_instance): """Test binding a handle within a custom object.""" class HandleWrapper: def __init__(self, handle: DeploymentHandle): self._handle = handle def get(self) -> DeploymentHandle: return self._handle @serve.deployment def f() -> str: return "hi" @serve.deployment class MyDriver: def __init__(self, handle_wrapper: HandleWrapper): self.handle_wrapper = handle_wrapper async def __call__(self) -> str: return await self.handle_wrapper.get().remote() handle_wrapper = HandleWrapper(f.bind()) h = serve.run(MyDriver.bind(handle_wrapper)) assert h.remote().result() == "hi" def test_max_ongoing_requests_none(serve_instance): """We should not allow setting `max_ongoing_requests` to None.""" def get_max_ongoing_requests(): details = serve_instance.get_serve_details() return details["applications"]["default"]["deployments"]["A"][ "deployment_config" ]["max_ongoing_requests"] class A: pass with pytest.raises(ValueError): serve.deployment(max_ongoing_requests=None)(A).bind() with pytest.raises(ValueError): serve.deployment(A).options(max_ongoing_requests=None).bind() serve.run(serve.deployment(A).bind()) assert get_max_ongoing_requests() == DEFAULT_MAX_ONGOING_REQUESTS serve.run( serve.deployment(max_ongoing_requests=8, graceful_shutdown_timeout_s=2)( A ).bind() ) assert get_max_ongoing_requests() == 8 serve.run(serve.deployment(A).options(max_ongoing_requests=12).bind()) assert get_max_ongoing_requests() == 12 @pytest.mark.skipif( RAY_SERVE_ENABLE_HA_PROXY, reason=( "Custom ingress request routers are currently only available with Ray " "Serve LLM and RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING." ), ) def test_deploy_app_with_custom_request_router(serve_instance): """Test deploying an app with a custom request router configured in the deployment decorator.""" handle = serve.run(AppWithCustomRequestRouter.bind()) assert handle.remote().result() == "Hello, world!" @serve.deployment( request_router_config=RequestRouterConfig( request_router_class="ray.serve.tests.test_api.FakeRequestRouter", request_router_kwargs=dict(test_parameter=4848), ) ) class AppWithCustomRequestRouterAndKwargs: def __call__(self) -> str: return "Hello, world!" @pytest.mark.skipif( RAY_SERVE_ENABLE_HA_PROXY, reason=( "Custom ingress request routers are currently only available with Ray " "Serve LLM and RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING." ), ) def test_custom_request_router_kwargs(serve_instance): """Check that custom kwargs can be passed to the request router.""" handle = serve.run(AppWithCustomRequestRouterAndKwargs.bind()) assert handle.remote().result() == "Hello, world!" def test_backoff_params_imperative(serve_instance): """Check that custom initial_backoff_s is actually applied during retries. Deploys with max_ongoing_requests=1 and a large initial_backoff_s. Sends a blocking request to fill the replica, then sends a second request that enters backoff. Verifies the second request takes at least initial_backoff_s to complete (proving the backoff sleep was applied). """ import time custom_initial_backoff_s = 3.0 signal = SignalActor.remote() @serve.deployment( max_ongoing_requests=1, request_router_config=RequestRouterConfig( initial_backoff_s=custom_initial_backoff_s, backoff_multiplier=2.0, max_backoff_s=custom_initial_backoff_s, ), ) class SlowApp: async def __call__(self, block: bool = False) -> str: if block: await signal.wait.remote() return "ok" handle = serve.run(SlowApp.bind()) # Send a few warm-up requests so the long poll has time to propagate # the custom backoff config to the router (avoids the race where the # router is lazy-initialized with default params before the long poll # fires). for _ in range(5): assert handle.remote().result() == "ok" # Send a blocking request to fill the single slot. blocking_ref = handle.remote(block=True) # Wait for the blocking request to be processing. time.sleep(0.5) # Send a second request — the router's queue length probe will find the # replica at capacity and enter the backoff loop. start = time.monotonic() second_ref = handle.remote(block=False) # Unblock the first request while the backoff sleep is in progress. # The router exhausts locality probes then sleeps for initial_backoff_s; # we wait 1s (< 3s) so the signal arrives during the sleep. time.sleep(1) ray.get(signal.send.remote()) # Wait for both requests to complete. blocking_ref.result() second_ref.result() elapsed = time.monotonic() - start # With initial_backoff_s=3.0 and max_backoff_s=3.0, every backoff sleep # is exactly 3.0s (min(3.0 * 2.0^attempt, 3.0) = 3.0 for all attempts). # A lower bound of 3.0s proves the backoff sleep was applied. # An upper bound of 5.0s proves only ONE sleep occurred (two would be ≥6s), # confirming it was the initial backoff (attempt 0). assert custom_initial_backoff_s <= elapsed < 5.0, ( f"Expected [{custom_initial_backoff_s}, 5.0)s due to a single backoff sleep, " f"but request completed in {elapsed:.2f}s" ) def test_overloaded_app_builder_signatures(): """Test that call_user_app_builder_with_args_if_necessary validates the base function signature with a pydantic basemodel, rather than the overload that accepts a dict (for the sake of lint permissiveness). """ class Config(BaseModel): name: str value: int = 42 @serve.deployment class MockDeployment: def __call__(self): return "mock" mock_app = MockDeployment.bind() # Overloaded function where the implementation has a pydantic annotation @overload def overloaded_builder(args: dict) -> Application: ... def overloaded_builder(args: Config) -> Application: """Implementation with pydantic BaseModel annotation.""" assert isinstance(args, Config), f"Expected Config but got {type(args)}" return mock_app # Test 1: Valid input should work and convert to Config model result = call_user_app_builder_with_args_if_necessary( overloaded_builder, {"name": "test", "value": 123} ) assert isinstance(result, Application) # Test 2: Invalid dict input should raise validation error # Missing required field 'name' with pytest.raises(ValidationError): call_user_app_builder_with_args_if_necessary( overloaded_builder, {"value": 123} # Missing required 'name' field ) # Test 3: Wrong type should also raise validation error with pytest.raises(ValidationError): call_user_app_builder_with_args_if_necessary( overloaded_builder, {"name": "test", "value": "not_an_int"}, # 'value' should be int ) def test_max_constructor_retry_count(serve_instance): @ray.remote(num_cpus=0) class Counter: def __init__(self): self.count = 0 async def increase(self): self.count += 1 async def decrease(self): self.count -= 1 async def get_count(self) -> int: return self.count counter = Counter.remote() @serve.deployment(num_replicas=3, max_constructor_retry_count=7) class A: def __init__(self, counter): counter.increase.remote() raise Exception("Test exception") try: app = A.bind(counter) serve.run(app) except Exception: pass # we are triggering 3 replicas at once, and for understanding, let's assume then only one replica fail 7 times, # hence total count should be 7(one replica with 7 failures and 2 replicas with 0 failures) = 9 wait_for_condition(lambda: ray.get(counter.get_count.remote()) == 9) def test_run_with_external_scaler_enabled(serve_instance): """Test that serve.run correctly passes external_scaler_enabled parameter. This test verifies that when serve.run is called with external_scaler_enabled=True or external_scaler_enabled=False, the application state manager correctly stores the external_scaler_enabled value. """ controller = serve_instance._controller @serve.deployment class Model: def __call__(self): return "model response" # Test with external_scaler_enabled=True handle = serve.run( Model.bind(), name="app_with_scaler", route_prefix="/with_scaler", external_scaler_enabled=True, ) assert handle.remote().result() == "model response" # Verify that external_scaler_enabled is set to True assert ( ray.get(controller.get_external_scaler_enabled.remote("app_with_scaler")) is True ) # Test with external_scaler_enabled=False (explicit) handle = serve.run( Model.bind(), name="app_without_scaler", route_prefix="/without_scaler", external_scaler_enabled=False, ) assert handle.remote().result() == "model response" # Verify that external_scaler_enabled is set to False assert ( ray.get(controller.get_external_scaler_enabled.remote("app_without_scaler")) is False ) # Test with default value (should be False) handle = serve.run( Model.bind(), name="app_default", route_prefix="/default", ) assert handle.remote().result() == "model response" # Verify that external_scaler_enabled defaults to False assert ( ray.get(controller.get_external_scaler_enabled.remote("app_default")) is False ) if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", "-s", __file__]))