1130 lines
38 KiB
Python
1130 lines
38 KiB
Python
import asyncio
|
|
import pickle
|
|
import sys
|
|
from typing import Dict, List, Tuple
|
|
from unittest.mock import AsyncMock
|
|
|
|
import grpc
|
|
import pytest
|
|
|
|
import ray
|
|
from ray.serve._private.common import DeploymentID, EndpointInfo, RequestMetadata
|
|
from ray.serve._private.constants import HEALTHY_MESSAGE
|
|
from ray.serve._private.proxy import (
|
|
DRAINING_MESSAGE,
|
|
HTTPProxy,
|
|
ResponseGenerator,
|
|
ResponseStatus,
|
|
gRPCProxy,
|
|
)
|
|
from ray.serve._private.proxy_request_response import (
|
|
ASGIProxyRequest,
|
|
ProxyRequest,
|
|
gRPCStreamingType,
|
|
)
|
|
from ray.serve._private.proxy_router import (
|
|
NO_REPLICAS_MESSAGE,
|
|
NO_ROUTES_MESSAGE,
|
|
ProxyRouter,
|
|
)
|
|
from ray.serve._private.test_utils import FakeGrpcContext, MockDeploymentHandle
|
|
from ray.serve._private.thirdparty.get_asgi_route_name import RoutePattern
|
|
from ray.serve.generated import serve_pb2
|
|
from ray.serve.grpc_util import RayServegRPCContext
|
|
|
|
ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE = "Router is not ready for traffic"
|
|
|
|
|
|
class FakeRef:
|
|
def __init__(self, messages=()):
|
|
self.called = False
|
|
self.messages = messages
|
|
|
|
def _on_completed(self, func):
|
|
pass
|
|
|
|
def is_nil(self):
|
|
return False
|
|
|
|
def __await__(self):
|
|
future = asyncio.Future()
|
|
future.set_result(self)
|
|
result = yield from future
|
|
if self.called:
|
|
return pickle.dumps(self.messages)
|
|
self.called = True
|
|
return result
|
|
|
|
def _to_object_ref(self, *args, **kwargs):
|
|
return self
|
|
|
|
def cancel(self):
|
|
pass
|
|
|
|
|
|
class FakeGrpcHandle:
|
|
def __init__(self, streaming: bool, grpc_context: RayServegRPCContext):
|
|
self.deployment_id = DeploymentID(
|
|
name="fake_deployment_name", app_name="fake_app_name"
|
|
)
|
|
self.streaming = streaming
|
|
self.grpc_context = grpc_context
|
|
|
|
async def remote(self, *args, **kwargs):
|
|
def unary_call():
|
|
return "hello world"
|
|
|
|
def streaming_call():
|
|
for i in range(10):
|
|
yield f"hello world: {i}"
|
|
|
|
return (
|
|
self.grpc_context,
|
|
unary_call() if not self.streaming else streaming_call(),
|
|
)
|
|
|
|
def options(self, *args, **kwargs):
|
|
return self
|
|
|
|
@property
|
|
def deployment_name(self) -> str:
|
|
return self.deployment_id.name
|
|
|
|
@property
|
|
def app_name(self) -> str:
|
|
return self.deployment_id.app_name
|
|
|
|
|
|
class FakeProxyRouter(ProxyRouter):
|
|
def __init__(self, *args, **kwargs):
|
|
self.route = None
|
|
self.handle = None
|
|
self.app_is_cross_language = None
|
|
self._ready_for_traffic = False
|
|
self.route_patterns = {}
|
|
self._route_pattern_apps = {}
|
|
|
|
def update_routes(self, endpoints: Dict[DeploymentID, EndpointInfo]):
|
|
self.endpoints = endpoints
|
|
|
|
def get_handle_for_endpoint(self, *args, **kwargs):
|
|
if (
|
|
self.route is None
|
|
and self.handle is None
|
|
and self.app_is_cross_language is None
|
|
):
|
|
return None
|
|
|
|
return self.route, self.handle, self.app_is_cross_language
|
|
|
|
def match_route(self, *args, **kwargs):
|
|
if (
|
|
self.route is None
|
|
and self.handle is None
|
|
and self.app_is_cross_language is None
|
|
):
|
|
return None
|
|
|
|
return self.route, self.handle, self.app_is_cross_language
|
|
|
|
def set_ready_for_traffic(self):
|
|
self._ready_for_traffic = True
|
|
|
|
def ready_for_traffic(self, is_head: bool) -> bool:
|
|
if self._ready_for_traffic:
|
|
return True, ""
|
|
|
|
return False, ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
|
|
|
|
class FakeProxyRequest(ProxyRequest):
|
|
def __init__(
|
|
self,
|
|
app_name: str = "",
|
|
method: str = "",
|
|
request_type: str = "",
|
|
path: str = "",
|
|
route_path: str = "",
|
|
is_health_request: bool = False,
|
|
is_route_request: bool = False,
|
|
):
|
|
self._app_name = app_name
|
|
self._method = method
|
|
self._request_type = request_type
|
|
self._path = path
|
|
self._route_path = route_path
|
|
self._is_route_request = is_route_request
|
|
self._is_health_request = is_health_request
|
|
|
|
@property
|
|
def app_name(self) -> str:
|
|
return self._app_name
|
|
|
|
@property
|
|
def method(self) -> str:
|
|
return self._method
|
|
|
|
@property
|
|
def request_type(self) -> str:
|
|
return self._request_type
|
|
|
|
@property
|
|
def path(self) -> str:
|
|
return self._path
|
|
|
|
@property
|
|
def route_path(self) -> str:
|
|
return self._route_path
|
|
|
|
@property
|
|
def is_route_request(self) -> bool:
|
|
return self._is_route_request
|
|
|
|
@property
|
|
def is_health_request(self) -> bool:
|
|
return self._is_health_request
|
|
|
|
def populate_tracing_context(self):
|
|
pass
|
|
|
|
|
|
class FakeHTTPHandle:
|
|
def __init__(self, messages):
|
|
self.deployment_id = DeploymentID(
|
|
name="fake_deployment_name", app_name="fake_app_name"
|
|
)
|
|
self.messages = messages
|
|
|
|
async def remote(self, *args, **kwargs):
|
|
return pickle.dumps(self.messages)
|
|
|
|
def options(self, *args, **kwargs):
|
|
return self
|
|
|
|
@property
|
|
def deployment_name(self) -> str:
|
|
return self.deployment_id.name
|
|
|
|
@property
|
|
def app_name(self) -> str:
|
|
return self.deployment_id.app_name
|
|
|
|
|
|
class FakeHttpReceive:
|
|
def __init__(self, messages=None):
|
|
self.messages = messages or []
|
|
|
|
async def __call__(self):
|
|
while True:
|
|
if self.messages:
|
|
return self.messages.pop()
|
|
await asyncio.sleep(0.1)
|
|
|
|
|
|
class FakeHttpSend:
|
|
def __init__(self):
|
|
self.messages = []
|
|
|
|
async def __call__(self, message):
|
|
self.messages.append(message)
|
|
|
|
|
|
async def _consume_proxy_generator(
|
|
gen: ResponseGenerator,
|
|
) -> Tuple[ResponseStatus, List]:
|
|
status = None
|
|
messages = []
|
|
async for message in gen:
|
|
if isinstance(message, ResponseStatus):
|
|
status = message
|
|
else:
|
|
messages.append(message)
|
|
|
|
assert status is not None
|
|
return status, messages
|
|
|
|
|
|
class TestgRPCProxy:
|
|
"""Test methods implemented on gRPCProxy"""
|
|
|
|
def create_grpc_proxy(self, is_head: bool = False):
|
|
node_id = "fake-node_id"
|
|
node_ip_address = "fake-node_ip_address"
|
|
return gRPCProxy(
|
|
node_id=node_id,
|
|
node_ip_address=node_ip_address,
|
|
is_head=is_head,
|
|
proxy_router=FakeProxyRouter(),
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_found_response(self):
|
|
"""Test gRPCProxy returns the correct not found response."""
|
|
grpc_proxy = self.create_grpc_proxy()
|
|
grpc_proxy.proxy_router.update_routes({})
|
|
|
|
# Application name not provided.
|
|
status, _ = await _consume_proxy_generator(
|
|
grpc_proxy.proxy_request(
|
|
FakeProxyRequest(request_type="grpc", app_name=""),
|
|
)
|
|
)
|
|
|
|
assert status.code == grpc.StatusCode.NOT_FOUND
|
|
assert "Application metadata not set" in status.message
|
|
assert status.is_error is True
|
|
|
|
# Application name is provided but wasn't found.
|
|
status, _ = await _consume_proxy_generator(
|
|
grpc_proxy.proxy_request(
|
|
FakeProxyRequest(request_type="grpc", app_name="foobar"),
|
|
)
|
|
)
|
|
|
|
assert status.code == grpc.StatusCode.NOT_FOUND
|
|
assert "Application 'foobar' not found" in status.message
|
|
assert status.is_error is True
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("is_draining", [False, True])
|
|
@pytest.mark.parametrize("router_ready_for_traffic", [False, True])
|
|
async def test_routes_response(
|
|
self, is_draining: bool, router_ready_for_traffic: bool
|
|
):
|
|
"""Test responses to the routes method.
|
|
|
|
The response should be an OK success unless:
|
|
- the route table hasn't been updated yet.
|
|
- the proxy is draining.
|
|
"""
|
|
grpc_proxy = self.create_grpc_proxy()
|
|
grpc_proxy.proxy_router.update_routes(
|
|
{DeploymentID(name="deployment", app_name="app"): EndpointInfo("/route")},
|
|
)
|
|
if is_draining:
|
|
grpc_proxy.update_draining(True)
|
|
if router_ready_for_traffic:
|
|
grpc_proxy.proxy_router.set_ready_for_traffic()
|
|
|
|
status, [response_bytes] = await _consume_proxy_generator(
|
|
grpc_proxy.proxy_request(
|
|
FakeProxyRequest(
|
|
request_type="grpc",
|
|
is_route_request=True,
|
|
),
|
|
)
|
|
)
|
|
|
|
assert isinstance(status, ResponseStatus)
|
|
response = serve_pb2.ListApplicationsResponse()
|
|
response.ParseFromString(response_bytes)
|
|
|
|
if not is_draining and router_ready_for_traffic:
|
|
assert status.code == grpc.StatusCode.OK
|
|
assert status.message == HEALTHY_MESSAGE
|
|
assert status.is_error is False
|
|
assert response.application_names == ["app"]
|
|
elif is_draining:
|
|
assert status.code == grpc.StatusCode.UNAVAILABLE
|
|
assert status.message == DRAINING_MESSAGE
|
|
assert status.is_error is True
|
|
assert response.application_names == ["app"]
|
|
else:
|
|
assert status.code == grpc.StatusCode.UNAVAILABLE
|
|
assert status.message == ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
assert status.is_error is True
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("is_draining", [False, True])
|
|
@pytest.mark.parametrize("router_ready_for_traffic", [False, True])
|
|
async def test_health_response(
|
|
self, is_draining: bool, router_ready_for_traffic: bool
|
|
):
|
|
"""Test responses to the health method.
|
|
|
|
The response should be an OK success unless:
|
|
- the route table hasn't been updated yet.
|
|
- the proxy is draining.
|
|
"""
|
|
grpc_proxy = self.create_grpc_proxy()
|
|
if is_draining:
|
|
grpc_proxy.update_draining(True)
|
|
if router_ready_for_traffic:
|
|
grpc_proxy.proxy_router.set_ready_for_traffic()
|
|
|
|
status, [response_bytes] = await _consume_proxy_generator(
|
|
grpc_proxy.proxy_request(
|
|
FakeProxyRequest(
|
|
request_type="grpc",
|
|
is_health_request=True,
|
|
),
|
|
)
|
|
)
|
|
|
|
assert isinstance(status, ResponseStatus)
|
|
response = serve_pb2.HealthzResponse()
|
|
response.ParseFromString(response_bytes)
|
|
|
|
if not is_draining and router_ready_for_traffic:
|
|
assert status.code == grpc.StatusCode.OK
|
|
assert status.message == HEALTHY_MESSAGE
|
|
assert status.is_error is False
|
|
assert response.message == HEALTHY_MESSAGE
|
|
elif is_draining:
|
|
assert status.code == grpc.StatusCode.UNAVAILABLE
|
|
assert status.message == DRAINING_MESSAGE
|
|
assert status.is_error is True
|
|
assert response.message == DRAINING_MESSAGE
|
|
else:
|
|
assert status.code == grpc.StatusCode.UNAVAILABLE
|
|
assert status.message == ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
assert status.is_error is True
|
|
assert response.message == ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grpc_messages_missing_session_returns_cancelled(self):
|
|
"""Test receive_grpc_messages returns cancelled tuple for missing session.
|
|
|
|
When a streaming session is cleaned up (due to timeout, error, or completion),
|
|
calling receive_grpc_messages again should return (False, None, True) instead
|
|
of raising KeyError. This provides consistent graceful termination behavior.
|
|
"""
|
|
grpc_proxy = self.create_grpc_proxy()
|
|
|
|
# Call receive_grpc_messages with a non-existent session ID
|
|
# Before the fix, this would raise KeyError
|
|
# After the fix, it should return (False, None, True)
|
|
result = await grpc_proxy.receive_grpc_messages("non-existent-session-id")
|
|
has_more, message_bytes, is_cancelled = result
|
|
|
|
# Should indicate stream is done and was cancelled (graceful termination)
|
|
assert has_more is False
|
|
assert message_bytes is None
|
|
assert is_cancelled is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_grpc_messages_after_cleanup_returns_cancelled(self):
|
|
"""Test receive_grpc_messages returns cancelled after session cleanup.
|
|
|
|
After _cleanup_streaming_session is called, subsequent calls to
|
|
receive_grpc_messages should return (False, None, True) instead of KeyError.
|
|
"""
|
|
grpc_proxy = self.create_grpc_proxy()
|
|
session_id = "test-session-123"
|
|
|
|
# Simulate creating a streaming session
|
|
cancel_event = asyncio.Event()
|
|
|
|
async def mock_iterator():
|
|
yield "message1"
|
|
yield "message2"
|
|
|
|
grpc_proxy._streaming_sessions[session_id] = (mock_iterator(), cancel_event)
|
|
|
|
# Cleanup the session (simulates timeout, error, or completion)
|
|
grpc_proxy._cleanup_streaming_session(session_id)
|
|
|
|
# Verify session was removed
|
|
assert session_id not in grpc_proxy._streaming_sessions
|
|
|
|
# Now calling receive_grpc_messages should return cancelled tuple
|
|
# instead of raising KeyError
|
|
result = await grpc_proxy.receive_grpc_messages(session_id)
|
|
has_more, message_bytes, is_cancelled = result
|
|
|
|
assert has_more is False
|
|
assert message_bytes is None
|
|
assert is_cancelled is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_handler_factory(self):
|
|
"""Test gRPCProxy service_handler_factory returns the correct entrypoints."""
|
|
|
|
# Ensure gRPC unary call uses the correct entry point.
|
|
grpc_proxy = self.create_grpc_proxy()
|
|
request_proto = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
|
|
unary_entrypoint = grpc_proxy.service_handler_factory(
|
|
service_method="service_method",
|
|
streaming_type=gRPCStreamingType.UNARY_UNARY,
|
|
)
|
|
assert unary_entrypoint.__name__ == "unary_unary"
|
|
|
|
# Ensure the unary entry point returns the correct result and sets the
|
|
# code and details on the grpc context object.
|
|
grpc_proxy.proxy_router.route = "route"
|
|
context = FakeGrpcContext()
|
|
serve_grpc_context = RayServegRPCContext(context)
|
|
grpc_proxy.proxy_router.handle = FakeGrpcHandle(
|
|
streaming=False,
|
|
grpc_context=serve_grpc_context,
|
|
)
|
|
grpc_proxy.proxy_router.app_is_cross_language = False
|
|
result = await unary_entrypoint(request_proto=request_proto, context=context)
|
|
assert result == "hello world"
|
|
assert context.code() == grpc.StatusCode.OK
|
|
assert context.details() == ""
|
|
|
|
# Ensure gRPC streaming call uses the correct entry point.
|
|
streaming_entrypoint = grpc_proxy.service_handler_factory(
|
|
service_method="service_method",
|
|
streaming_type=gRPCStreamingType.UNARY_STREAM,
|
|
)
|
|
assert streaming_entrypoint.__name__ == "unary_stream"
|
|
|
|
# Ensure the streaming entry point returns the correct result and sets the
|
|
# code and details on the grpc context object.
|
|
grpc_proxy.proxy_router.route = "route"
|
|
context = FakeGrpcContext()
|
|
serve_grpc_context = RayServegRPCContext(context)
|
|
grpc_proxy.proxy_router.handle = FakeGrpcHandle(
|
|
streaming=True,
|
|
grpc_context=serve_grpc_context,
|
|
)
|
|
grpc_proxy.proxy_router.app_is_cross_language = False
|
|
result = await unary_entrypoint(request_proto=request_proto, context=context)
|
|
assert list(result) == [f"hello world: {i}" for i in range(10)]
|
|
assert context.code() == grpc.StatusCode.OK
|
|
assert context.details() == ""
|
|
|
|
|
|
class TestHTTPProxy:
|
|
"""Test methods implemented on HTTPProxy"""
|
|
|
|
def _check_asgi_messages(
|
|
self, messages: List[Dict], *, status_code: int, body: str
|
|
):
|
|
assert messages[0]["headers"] is not None
|
|
assert messages[0]["status"] == status_code
|
|
assert messages[1]["body"].decode("utf-8") == body
|
|
|
|
def create_http_proxy(self, is_head: bool = False):
|
|
node_id = "fake-node_id"
|
|
node_ip_address = "fake-node_ip_address"
|
|
return HTTPProxy(
|
|
node_id=node_id,
|
|
node_ip_address=node_ip_address,
|
|
is_head=is_head,
|
|
proxy_router=FakeProxyRouter(),
|
|
self_actor_name="fake-proxy-name",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_not_found_response(self):
|
|
"""Test the response returned when a route is not found."""
|
|
http_proxy = self.create_http_proxy()
|
|
http_proxy.proxy_router.update_routes({})
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(
|
|
FakeProxyRequest(request_type="http", path="/not-found"),
|
|
)
|
|
)
|
|
assert status.code == 404
|
|
assert status.is_error is True
|
|
self._check_asgi_messages(
|
|
messages,
|
|
status_code=404,
|
|
body=(
|
|
"Path '/not-found' not found. "
|
|
"Ping http://.../-/routes for available routes."
|
|
),
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("is_draining", [False, True])
|
|
@pytest.mark.parametrize("router_ready_for_traffic", [False, True])
|
|
async def test_routes_response(
|
|
self, is_draining: bool, router_ready_for_traffic: bool
|
|
):
|
|
"""Test responses to the routes method.
|
|
|
|
The response should be a 200 success unless:
|
|
- the route table hasn't been updated yet.
|
|
- the proxy is draining.
|
|
"""
|
|
http_proxy = self.create_http_proxy()
|
|
http_proxy.proxy_router.update_routes(
|
|
{DeploymentID(name="deployment", app_name="app"): EndpointInfo("/route")},
|
|
)
|
|
if is_draining:
|
|
http_proxy.update_draining(True)
|
|
if router_ready_for_traffic:
|
|
http_proxy.proxy_router.set_ready_for_traffic()
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(
|
|
FakeProxyRequest(
|
|
request_type="http",
|
|
is_route_request=True,
|
|
),
|
|
)
|
|
)
|
|
|
|
if not is_draining and router_ready_for_traffic:
|
|
assert status.code == 200
|
|
assert status.is_error is False
|
|
assert status.message == HEALTHY_MESSAGE
|
|
self._check_asgi_messages(
|
|
messages, status_code=200, body='{"/route":"app"}'
|
|
)
|
|
elif is_draining:
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == DRAINING_MESSAGE
|
|
self._check_asgi_messages(messages, status_code=503, body=DRAINING_MESSAGE)
|
|
else:
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
self._check_asgi_messages(
|
|
messages, status_code=503, body=ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("is_draining", [False, True])
|
|
@pytest.mark.parametrize("router_ready_for_traffic", [False, True])
|
|
async def test_health_response(
|
|
self, is_draining: bool, router_ready_for_traffic: bool
|
|
):
|
|
"""Test responses to the health check method.
|
|
|
|
The response should be a 200 success unless:
|
|
- the route table hasn't been updated yet.
|
|
- the proxy is draining.
|
|
"""
|
|
http_proxy = self.create_http_proxy()
|
|
if is_draining:
|
|
http_proxy.update_draining(True)
|
|
if router_ready_for_traffic:
|
|
http_proxy.proxy_router.set_ready_for_traffic()
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(
|
|
FakeProxyRequest(
|
|
request_type="http",
|
|
is_health_request=True,
|
|
),
|
|
)
|
|
)
|
|
if not is_draining and router_ready_for_traffic:
|
|
assert status.code == 200
|
|
assert status.is_error is False
|
|
assert status.message == HEALTHY_MESSAGE
|
|
self._check_asgi_messages(messages, status_code=200, body=HEALTHY_MESSAGE)
|
|
elif is_draining:
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == DRAINING_MESSAGE
|
|
self._check_asgi_messages(messages, status_code=503, body=DRAINING_MESSAGE)
|
|
else:
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
self._check_asgi_messages(
|
|
messages, status_code=503, body=ROUTER_NOT_READY_FOR_TRAFFIC_MESSAGE
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_asgi_messages(self):
|
|
"""Test HTTPProxy receive_asgi_messages received correct message."""
|
|
http_proxy = self.create_http_proxy()
|
|
internal_request_id = "fake-internal-request-id"
|
|
request_metadata = RequestMetadata(
|
|
request_id="fake-request-id",
|
|
internal_request_id="fake-internal-request-id",
|
|
)
|
|
queue = AsyncMock()
|
|
http_proxy.asgi_receive_queues[internal_request_id] = queue
|
|
|
|
await http_proxy.receive_asgi_messages(request_metadata=request_metadata)
|
|
queue.wait_for_message.assert_called_once()
|
|
queue.get_messages_nowait.assert_called_once()
|
|
|
|
with pytest.raises(KeyError):
|
|
request_metadata.internal_request_id = "non-existent-internal-request-id"
|
|
await http_proxy.receive_asgi_messages(request_metadata=request_metadata)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_call(self):
|
|
"""Test HTTPProxy __call__ calls proxy_request."""
|
|
expected_messages = [
|
|
{"type": "http.response.start", "status": "200"},
|
|
{"type": "http.response.body"},
|
|
]
|
|
|
|
http_proxy = self.create_http_proxy()
|
|
http_proxy.proxy_router.route = "route"
|
|
http_proxy.proxy_router.handle = FakeHTTPHandle(messages=expected_messages)
|
|
http_proxy.proxy_router.app_is_cross_language = False
|
|
|
|
receive = FakeHttpReceive()
|
|
scope = {
|
|
"type": "http",
|
|
"headers": [
|
|
(
|
|
b"x-request-id",
|
|
b"fake_request_id",
|
|
),
|
|
],
|
|
}
|
|
send = FakeHttpSend()
|
|
|
|
# Ensure before calling __call__, send.messages should be empty.
|
|
assert send.messages == []
|
|
await http_proxy(
|
|
scope=scope,
|
|
receive=receive,
|
|
send=send,
|
|
)
|
|
# Ensure after calling __call__, send.messages should be expected messages.
|
|
assert send.messages == expected_messages
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_asgi_receive(self):
|
|
"""Test HTTPProxy proxy_asgi_receive receives messages."""
|
|
http_proxy = self.create_http_proxy()
|
|
receive = AsyncMock()
|
|
receive.side_effect = [
|
|
{"type": "http.request"},
|
|
{"type": "http.request"},
|
|
{"type": "http.disconnect"},
|
|
]
|
|
queue = AsyncMock()
|
|
await http_proxy.proxy_asgi_receive(
|
|
receive=receive,
|
|
queue=queue,
|
|
)
|
|
|
|
queue.close.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"disconnect",
|
|
[
|
|
"client",
|
|
"server_with_disconnect_message",
|
|
"server_without_disconnect_message",
|
|
],
|
|
)
|
|
async def test_websocket_call(self, disconnect: str):
|
|
"""Test HTTPProxy websocket __call__ calls proxy_request."""
|
|
|
|
if disconnect == "client":
|
|
receive = FakeHttpReceive(
|
|
[{"type": "websocket.disconnect", "code": "1000"}]
|
|
)
|
|
expected_messages = [
|
|
{"type": "websocket.accept"},
|
|
{"type": "websocket.send"},
|
|
]
|
|
elif disconnect == "server_with_disconnect_message":
|
|
receive = FakeHttpReceive()
|
|
expected_messages = [
|
|
{"type": "websocket.accept"},
|
|
{"type": "websocket.send"},
|
|
{"type": "websocket.disconnect", "code": "1000"},
|
|
]
|
|
else:
|
|
receive = FakeHttpReceive()
|
|
expected_messages = [
|
|
{"type": "websocket.accept"},
|
|
{"type": "websocket.send"},
|
|
]
|
|
|
|
http_proxy = self.create_http_proxy()
|
|
http_proxy.proxy_router.route = "route"
|
|
http_proxy.proxy_router.handle = FakeHTTPHandle(messages=expected_messages)
|
|
http_proxy.proxy_router.app_is_cross_language = False
|
|
|
|
scope = {
|
|
"type": "websocket",
|
|
"headers": [
|
|
(
|
|
b"x-request-id",
|
|
b"fake_request_id",
|
|
),
|
|
],
|
|
}
|
|
send = FakeHttpSend()
|
|
|
|
# Ensure before calling __call__, send.messages should be empty.
|
|
assert send.messages == []
|
|
await http_proxy(
|
|
scope=scope,
|
|
receive=receive,
|
|
send=send,
|
|
)
|
|
# Ensure after calling __call__, send.messages should be expected messages.
|
|
assert send.messages == expected_messages
|
|
|
|
@pytest.mark.parametrize(
|
|
"header_key",
|
|
[
|
|
# Original underscore form (direct connection, no proxy)
|
|
b"serve_multiplexed_model_id",
|
|
# Hyphen form produced by proxies (nginx, AWS API Gateway, etc.)
|
|
# that convert underscores to hyphens in header names.
|
|
b"serve-multiplexed-model-id",
|
|
# Title-cased hyphen form produced by some proxies.
|
|
b"Serve-Multiplexed-Model-Id",
|
|
],
|
|
)
|
|
def test_setup_request_context_multiplexed_model_id_header_normalization(
|
|
self, header_key: bytes
|
|
):
|
|
"""Test that setup_request_context_and_handle recognises the multiplexed
|
|
model ID header regardless of whether the key uses underscores or hyphens.
|
|
|
|
Proxies such as nginx and AWS API Gateway convert underscores to hyphens
|
|
(and may also title-case the name), so the header sent by the client as
|
|
``serve_multiplexed_model_id`` can arrive at Ray Serve as
|
|
``serve-multiplexed-model-id`` or ``Serve-Multiplexed-Model-Id``.
|
|
All three forms must be treated as equivalent.
|
|
"""
|
|
import ray
|
|
|
|
model_id = "my-model-123"
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"root_path": "",
|
|
"headers": [
|
|
(header_key, model_id.encode()),
|
|
(b"x-request-id", b"test-request-id"),
|
|
],
|
|
"client": ("127.0.0.1", 12345),
|
|
}
|
|
proxy_request = ASGIProxyRequest(
|
|
scope=scope,
|
|
receive=AsyncMock(),
|
|
send=AsyncMock(),
|
|
)
|
|
|
|
http_proxy = self.create_http_proxy()
|
|
http_proxy.setup_request_context_and_handle(
|
|
app_name="test_app",
|
|
handle=FakeHTTPHandle(messages=[]),
|
|
route="/",
|
|
proxy_request=proxy_request,
|
|
internal_request_id="fake-internal-id",
|
|
)
|
|
|
|
ctx = ray.serve.context._get_serve_request_context()
|
|
assert ctx.multiplexed_model_id == model_id, (
|
|
f"Header key {header_key!r} was not recognised; "
|
|
f"got multiplexed_model_id={ctx.multiplexed_model_id!r}"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"header_key",
|
|
[
|
|
# Underscore form (direct connection, no intermediate proxy)
|
|
b"x_session_id",
|
|
# Hyphen form (the canonical form in HTTP; also what nginx /
|
|
# AWS API Gateway produce)
|
|
b"x-session-id",
|
|
# Title-cased hyphen form
|
|
b"X-Session-Id",
|
|
],
|
|
)
|
|
def test_setup_request_context_session_id_header_normalization(
|
|
self, header_key: bytes
|
|
):
|
|
"""The session_id header must be extracted regardless of case or
|
|
hyphen/underscore form.
|
|
|
|
Session-aware routers use this value to hash the request onto a replica.
|
|
"""
|
|
session_id = "sess_user_42"
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"root_path": "",
|
|
"headers": [
|
|
(header_key, session_id.encode()),
|
|
(b"x-request-id", b"test-request-id"),
|
|
],
|
|
"client": ("127.0.0.1", 12345),
|
|
}
|
|
proxy_request = ASGIProxyRequest(
|
|
scope=scope,
|
|
receive=AsyncMock(),
|
|
send=AsyncMock(),
|
|
)
|
|
|
|
http_proxy = self.create_http_proxy()
|
|
http_proxy.setup_request_context_and_handle(
|
|
app_name="test_app",
|
|
handle=FakeHTTPHandle(messages=[]),
|
|
route="/",
|
|
proxy_request=proxy_request,
|
|
internal_request_id="fake-internal-id",
|
|
)
|
|
|
|
ctx = ray.serve.context._get_serve_request_context()
|
|
assert ctx.session_id == session_id, (
|
|
f"Header key {header_key!r} was not recognized; "
|
|
f"got session_id={ctx.session_id!r}"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_head_http_unhealthy_until_route_table_updated():
|
|
"""Health endpoint should error until `update_routes` has been called."""
|
|
|
|
def get_handle_override(endpoint, info):
|
|
return MockDeploymentHandle(endpoint.name, endpoint.app_name)
|
|
|
|
http_proxy = HTTPProxy(
|
|
node_id="fake-node-id",
|
|
node_ip_address="fake-node-ip-address",
|
|
# proxy is on head node
|
|
is_head=True,
|
|
proxy_router=ProxyRouter(get_handle_override),
|
|
self_actor_name="fake-proxy-name",
|
|
)
|
|
proxy_request = FakeProxyRequest(
|
|
request_type="http",
|
|
is_health_request=True,
|
|
)
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(proxy_request)
|
|
)
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == NO_ROUTES_MESSAGE
|
|
assert messages[0]["headers"] is not None
|
|
assert messages[0]["status"] == 503
|
|
assert messages[1]["body"].decode("utf-8") == NO_ROUTES_MESSAGE
|
|
|
|
# Update route table, response should no longer error
|
|
http_proxy.proxy_router.update_routes(
|
|
{DeploymentID("a", "b"): EndpointInfo("/route")}
|
|
)
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(proxy_request)
|
|
)
|
|
assert status.code == 200
|
|
assert status.is_error is False
|
|
assert status.message == HEALTHY_MESSAGE
|
|
assert messages[0]["headers"] is not None
|
|
assert messages[0]["status"] == 200
|
|
assert messages[1]["body"].decode("utf-8") == HEALTHY_MESSAGE
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_http_unhealthy_until_replicas_populated():
|
|
"""Health endpoint should error until handle's running replicas is populated."""
|
|
|
|
handle = MockDeploymentHandle("a", "b")
|
|
http_proxy = HTTPProxy(
|
|
node_id="fake-node-id",
|
|
node_ip_address="fake-node-ip-address",
|
|
# proxy is on worker node
|
|
is_head=False,
|
|
proxy_router=ProxyRouter(lambda *args: handle),
|
|
self_actor_name="fake-proxy-name",
|
|
)
|
|
proxy_request = FakeProxyRequest(
|
|
request_type="http",
|
|
is_health_request=True,
|
|
)
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(proxy_request)
|
|
)
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == NO_ROUTES_MESSAGE
|
|
assert messages[0]["headers"] is not None
|
|
assert messages[0]["status"] == 503
|
|
assert messages[1]["body"].decode("utf-8") == NO_ROUTES_MESSAGE
|
|
|
|
# Update route table, response should still error because running
|
|
# replicas is not yet populated.
|
|
http_proxy.proxy_router.update_routes(
|
|
{DeploymentID("a", "b"): EndpointInfo("/route")}
|
|
)
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(proxy_request)
|
|
)
|
|
assert status.code == 503
|
|
assert status.is_error is True
|
|
assert status.message == NO_REPLICAS_MESSAGE
|
|
assert messages[0]["headers"] is not None
|
|
assert messages[0]["status"] == 503
|
|
assert messages[1]["body"].decode("utf-8") == NO_REPLICAS_MESSAGE
|
|
|
|
# Populate running replicas. Now response should be healthy and return 200.
|
|
handle.set_running_replicas_populated(True)
|
|
|
|
status, messages = await _consume_proxy_generator(
|
|
http_proxy.proxy_request(proxy_request)
|
|
)
|
|
assert status.code == 200
|
|
assert status.is_error is False
|
|
assert status.message == HEALTHY_MESSAGE
|
|
assert messages[0]["headers"] is not None
|
|
assert messages[0]["status"] == 200
|
|
assert messages[1]["body"].decode("utf-8") == HEALTHY_MESSAGE
|
|
|
|
|
|
class TestProxyRouterMatchRoutePattern:
|
|
"""Test ProxyRouter.match_route_pattern functionality."""
|
|
|
|
@pytest.fixture
|
|
def mock_get_handle(self):
|
|
def _get_handle(endpoint: DeploymentID, info: EndpointInfo):
|
|
return MockDeploymentHandle(deployment_name=endpoint.name)
|
|
|
|
return _get_handle
|
|
|
|
def test_match_route_pattern_no_patterns(self, mock_get_handle):
|
|
"""Test that match_route_pattern returns route_prefix when no patterns exist."""
|
|
router = ProxyRouter(mock_get_handle)
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api", route_patterns=None
|
|
)
|
|
}
|
|
)
|
|
|
|
scope = {"type": "http", "path": "/api/users/123", "method": "GET"}
|
|
result = router.match_route_pattern("/api", scope)
|
|
assert result == "/api"
|
|
|
|
def test_match_route_pattern_with_patterns(self, mock_get_handle):
|
|
"""Test that match_route_pattern matches specific route patterns."""
|
|
router = ProxyRouter(mock_get_handle)
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api",
|
|
route_patterns=[
|
|
RoutePattern(methods=None, path="/api/"),
|
|
RoutePattern(methods=None, path="/api/users/{user_id}"),
|
|
RoutePattern(methods=None, path="/api/items/{item_id}/details"),
|
|
],
|
|
)
|
|
}
|
|
)
|
|
|
|
# Test matching parameterized route
|
|
scope = {"type": "http", "path": "/api/users/123", "method": "GET"}
|
|
result = router.match_route_pattern("/api", scope)
|
|
assert result == "/api/users/{user_id}"
|
|
|
|
# Test matching nested parameterized route
|
|
scope = {"type": "http", "path": "/api/items/abc/details", "method": "GET"}
|
|
result = router.match_route_pattern("/api", scope)
|
|
assert result == "/api/items/{item_id}/details"
|
|
|
|
# Test matching root
|
|
scope = {"type": "http", "path": "/api/", "method": "GET"}
|
|
result = router.match_route_pattern("/api", scope)
|
|
assert result == "/api/"
|
|
|
|
def test_match_route_pattern_caching(self, mock_get_handle):
|
|
"""Test that mock Starlette apps are cached for performance."""
|
|
router = ProxyRouter(mock_get_handle)
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api",
|
|
route_patterns=[
|
|
RoutePattern(methods=None, path="/api/users/{user_id}")
|
|
],
|
|
)
|
|
}
|
|
)
|
|
|
|
scope = {"type": "http", "path": "/api/users/123", "method": "GET"}
|
|
|
|
# First call should create and cache the mock app
|
|
assert "/api" not in router._route_pattern_apps
|
|
result1 = router.match_route_pattern("/api", scope)
|
|
assert result1 == "/api/users/{user_id}"
|
|
assert "/api" in router._route_pattern_apps
|
|
|
|
# Second call should use cached app
|
|
cached_app = router._route_pattern_apps["/api"]
|
|
result2 = router.match_route_pattern("/api", scope)
|
|
assert result2 == "/api/users/{user_id}"
|
|
assert router._route_pattern_apps["/api"] is cached_app
|
|
|
|
def test_match_route_pattern_cache_invalidation(self, mock_get_handle):
|
|
"""Test that cache is cleared when routes are updated."""
|
|
router = ProxyRouter(mock_get_handle)
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api",
|
|
route_patterns=[
|
|
RoutePattern(methods=None, path="/api/users/{user_id}")
|
|
],
|
|
)
|
|
}
|
|
)
|
|
|
|
scope = {"type": "http", "path": "/api/users/123", "method": "GET"}
|
|
router.match_route_pattern("/api", scope)
|
|
assert "/api" in router._route_pattern_apps
|
|
|
|
# Update routes should clear cache
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api",
|
|
route_patterns=[
|
|
RoutePattern(methods=None, path="/api/items/{item_id}")
|
|
],
|
|
)
|
|
}
|
|
)
|
|
assert len(router._route_pattern_apps) == 0
|
|
|
|
def test_match_route_pattern_empty_patterns(self, mock_get_handle):
|
|
"""Test that empty pattern list returns route_prefix."""
|
|
router = ProxyRouter(mock_get_handle)
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api", route_patterns=[]
|
|
)
|
|
}
|
|
)
|
|
|
|
scope = {"type": "http", "path": "/api/users/123", "method": "GET"}
|
|
result = router.match_route_pattern("/api", scope)
|
|
assert result == "/api"
|
|
|
|
def test_match_route_pattern_no_match_fallback(self, mock_get_handle):
|
|
"""Test that unmatched requests fall back to route_prefix."""
|
|
router = ProxyRouter(mock_get_handle)
|
|
router.update_routes(
|
|
{
|
|
DeploymentID("api", "default"): EndpointInfo(
|
|
route="/api",
|
|
route_patterns=[
|
|
RoutePattern(methods=None, path="/api/users/{user_id}")
|
|
],
|
|
)
|
|
}
|
|
)
|
|
|
|
# Request to path not in patterns
|
|
scope = {"type": "http", "path": "/api/admin/settings", "method": "GET"}
|
|
result = router.match_route_pattern("/api", scope)
|
|
# Should fall back to prefix since no pattern matches
|
|
assert result == "/api"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|