Files
ray-project--ray/python/ray/serve/tests/test_http_routes.py
T
2026-07-13 13:17:40 +08:00

256 lines
7.6 KiB
Python

import time
import httpx
import pytest
from fastapi import FastAPI, Request
from starlette.responses import RedirectResponse
import ray
from ray import serve
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
from ray.serve._private.test_utils import get_application_url
def test_path_validation(serve_instance):
@serve.deployment
class D:
pass
# Path prefix must start with /.
with pytest.raises(ValueError):
serve.run(D.bind(), route_prefix="hello")
# Path prefix must not end with / unless it's the root.
with pytest.raises(ValueError):
serve.run(D.bind(), route_prefix="/hello/")
# Wildcards not allowed with new ingress support.
with pytest.raises(ValueError):
serve.run(D.bind(), route_prefix="/{hello}")
def test_routes_healthz(serve_instance):
# Should return 503 until there are any routes populated.
resp = httpx.get("http://localhost:8000/-/healthz")
assert resp.status_code == 503
assert resp.text == "Route table is not populated yet."
@serve.deployment
class D1:
def __call__(self, *args):
return "hi"
# D1 not exposed over HTTP so should still return 503.
serve.run(D1.bind(), route_prefix=None)
resp = httpx.get("http://localhost:8000/-/healthz")
assert resp.status_code == 503
assert resp.text == "Route table is not populated yet."
# D1 exposed over HTTP, should return 200 OK.
serve.run(D1.bind(), route_prefix="/")
resp = httpx.get("http://localhost:8000/-/healthz")
assert resp.status_code == 200
assert resp.text == "success"
def test_routes_endpoint(serve_instance):
@serve.deployment
class D1:
def __call__(self, *args):
return "D1"
@serve.deployment
class D2:
def __call__(self, *args):
return "D2"
serve.run(D1.bind(), name="app1", route_prefix="/D1")
serve.run(D2.bind(), name="app2", route_prefix="/hello/world")
routes = httpx.get("http://localhost:8000/-/routes").json()
assert len(routes) == 2, routes
app1_url = get_application_url(app_name="app1")
app2_url = get_application_url(app_name="app2")
assert httpx.get(app1_url).text == "D1"
assert httpx.get(app1_url).status_code == 200
assert httpx.get(app2_url).text == "D2"
assert httpx.get(app2_url).status_code == 200
assert httpx.get("http://localhost:8000/not_exist").status_code == 404
app1_url = get_application_url(app_name="app1", exclude_route_prefix=True)
app2_url = get_application_url(app_name="app2", exclude_route_prefix=True)
assert httpx.get(f"{app1_url}/").status_code == 404
assert httpx.get(f"{app2_url}/").status_code == 404
def test_deployment_without_route(serve_instance):
@serve.deployment
class D:
def __call__(self, *args):
return "1"
serve.run(D.bind(), route_prefix=None)
routes = httpx.get("http://localhost:8000/-/routes")
assert len(routes.json()) == 0
# make sure the deployment is not exposed under the default route
r = httpx.get("http://localhost:8000/")
assert r.status_code == 404
def test_deployment_options_default_route(serve_instance):
@serve.deployment
class D1:
pass
serve.run(D1.bind())
url = get_application_url(exclude_route_prefix=True)
routes = httpx.get(f"{url}/-/routes").json()
assert len(routes) == 1
assert "/" in routes, routes
assert routes["/"] == SERVE_DEFAULT_APP_NAME
def test_path_prefixing_1(serve_instance):
def check_req(subpath, app_name, text=None, status=None):
url = get_application_url(app_name=app_name, exclude_route_prefix=True)
r = httpx.get(f"{url}{subpath}")
if text is not None:
assert r.text == text, f"{r.text} != {text}"
if status is not None:
assert r.status_code == status, f"{r.status_code} != {status}"
return r
@serve.deployment
class D1:
def __call__(self, *args):
return "1"
serve.run(D1.bind(), route_prefix="/hello", name="app1")
check_req("/", "app1", status=404)
check_req("/hello", "app1", text="1")
check_req("/hello/", "app1", text="1")
check_req("/hello/a", "app1", text="1")
@serve.deployment
class D2:
def __call__(self, *args):
return "2"
serve.run(D2.bind(), route_prefix="/", name="app2")
check_req("/hello/", "app1", text="1")
check_req("/hello/a", "app1", text="1")
check_req("/", "app2", text="2")
check_req("/a", "app2", text="2")
@serve.deployment
class D3:
def __call__(self, *args):
return "3"
serve.run(D3.bind(), route_prefix="/hello/world", name="app3")
check_req("/hello/", "app1", text="1")
check_req("/", "app2", text="2")
check_req("/hello/world/", "app3", text="3")
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class D4:
@app.get("/")
def root(self):
return 4
@app.get("/{p}")
def subpath(self, p: str):
return p
serve.run(D4.bind(), route_prefix="/hello/world/again", name="app4")
check_req("/hello/", "app1") == "1"
check_req("/", "app2") == "2"
check_req("/hello/world/", "app3") == "3"
check_req("/hello/world/again/", "app4") == "4"
check_req("/hello/world/again/hi", "app4") == '"hi"'
@pytest.mark.parametrize("base_path", ["", "subpath"])
def test_redirect(serve_instance, base_path):
app = FastAPI()
route_prefix = f"/{base_path}"
@serve.deployment()
@serve.ingress(app)
class D:
@app.get("/")
def root(self):
return "hello from /"
@app.get("/redirect")
def redirect_root(self, request: Request):
root_path = request.scope.get("root_path")
if not root_path.endswith("/"):
root_path += "/"
return RedirectResponse(url=root_path)
@app.get("/redirect2")
def redirect_twice(self, request: Request):
root_path = request.scope.get("root_path")
if root_path.endswith("/"):
root_path = root_path[:-1]
return RedirectResponse(url=root_path + app.url_path_for("redirect_root"))
serve.run(D.bind(), route_prefix=route_prefix)
if route_prefix != "/":
route_prefix += "/"
url = get_application_url(exclude_route_prefix=True)
r = httpx.get(f"{url}{route_prefix}redirect", follow_redirects=True)
assert r.status_code == 200
assert len(r.history) == 1
assert r.json() == "hello from /"
r = httpx.get(f"{url}{route_prefix}redirect2", follow_redirects=True)
assert r.status_code == 200
assert len(r.history) == 2
assert r.json() == "hello from /"
def test_default_error_handling(serve_instance):
@serve.deployment
def f():
_ = 1 / 0
serve.run(f.bind())
url = get_application_url(exclude_route_prefix=True)
# Error is raised when the request reaches the deployed replica.
r = httpx.get(f"{url}/f")
assert r.status_code == 500
assert r.text == "Internal Server Error"
@ray.remote(num_cpus=0)
def intentional_kill(actor_handle):
ray.kill(actor_handle, no_restart=False)
@serve.deployment
def h():
ray.get(intentional_kill.remote(ray.get_runtime_context().current_actor))
time.sleep(100) # Don't return here to leave time for actor exit.
serve.run(h.bind())
# Error is raised before the request reaches the deployed replica as the replica does not exist.
r = httpx.get("http://localhost:8000/h")
assert r.status_code == 500
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))