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

2734 lines
99 KiB
Python

import asyncio
import collections
import logging
import os
import signal
import subprocess
import sys
import tempfile
import threading
import time
from typing import Optional
from unittest import mock
import pytest
import pytest_asyncio
import requests
import uvicorn
from fastapi import FastAPI, Request, Response
from ray._common.network_utils import find_free_port
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
from ray.serve._private.constants import (
PROXY_MIN_DRAINING_PERIOD_S,
RAY_SERVE_ENABLE_HA_PROXY,
)
from ray.serve._private.haproxy import (
BackendConfig,
HAProxyApi,
HAProxyConfig,
HAProxyManager,
ServerConfig,
)
from ray.serve.config import HTTPOptions
logger = logging.getLogger(__name__)
# Skip all tests in this module if the HAProxy feature flag is not enabled
pytestmark = pytest.mark.skipif(
not RAY_SERVE_ENABLE_HA_PROXY,
reason="RAY_SERVE_ENABLE_HA_PROXY not set.",
)
EXCLUDED_ACL_NAMES = ("healthcheck", "routes")
def check_haproxy_ready(stats_port: int, timeout: int = 2) -> bool:
"""Check if HAProxy is ready by verifying the stats endpoint is accessible."""
try:
response = requests.get(f"http://127.0.0.1:{stats_port}/stats", timeout=timeout)
return response.status_code == 200
except Exception:
return False
def _serve_fastapi_app(
app: FastAPI, port: int, ready_check, timeout_keep_alive: int = 60
):
"""Run `app` on uvicorn in a daemon thread; block until `ready_check()` is True."""
config = uvicorn.Config(
app=app,
host="127.0.0.1",
port=port,
log_level="error",
access_log=False,
timeout_keep_alive=timeout_keep_alive,
)
server = uvicorn.Server(config)
thread = threading.Thread(target=lambda: asyncio.run(server.serve()), daemon=True)
thread.start()
wait_for_condition(ready_check)
return server, thread
def _healthz_ready(port: int):
return (
lambda: requests.get(
f"http://127.0.0.1:{port}/-/healthz", timeout=2
).status_code
== 200
)
def create_test_backend_server(port: int):
"""Create a test backend server with slow and fast endpoints using uvicorn."""
app = FastAPI()
@app.get("/-/healthz")
async def health_endpoint():
return {"status": "OK"}
@app.get("/slow")
async def slow_endpoint():
await asyncio.sleep(3) # 3-second delay
return "Slow response completed"
@app.get("/fast")
async def fast_endpoint(req: Request, res: Response):
res.headers["x-haproxy-reload-id"] = req.headers.get("x-haproxy-reload-id", "")
return "Fast response"
return _serve_fastapi_app(app, port, _healthz_ready(port))
def process_exists(pid: int) -> bool:
"""Check if a process with the given PID exists."""
try:
# Send signal 0 to check if process exists without actually sending a signal
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
return False
def make_test_request(
url: str,
track_results: list = None,
signal_started: threading.Event = None,
timeout: int = 10,
):
"""Unified function to make test requests with optional result tracking."""
try:
if signal_started:
signal_started.set() # Signal that request has started
start_time = time.time()
response = requests.get(url, timeout=timeout)
end_time = time.time()
if track_results is not None:
track_results.append(
{
"status": response.status_code,
"duration": end_time - start_time,
"content": response.content,
}
)
except Exception as ex:
if track_results is not None:
track_results.append({"error": str(ex)})
@pytest.fixture(autouse=True)
def clean_up_haproxy_processes():
"""Clean up haproxy processes before and after each test."""
subprocess.run(
["pkill", "-x", "haproxy"], capture_output=True, text=True, check=False
)
yield
# After test: verify no haproxy processes are running
result = subprocess.run(
["pgrep", "-x", "haproxy"], capture_output=True, text=True, check=False
)
assert (
result.returncode != 0 or not result.stdout.strip()
), f"HAProxy processes still running after test: {result.stdout.strip()}"
@pytest_asyncio.fixture
async def haproxy_api_cleanup():
registered_apis = []
def register(api: Optional[HAProxyApi]) -> None:
if api is not None:
registered_apis.append(api)
yield register
for api in registered_apis:
proc = getattr(api, "_proc", None)
if proc and proc.returncode is None:
try:
await api.stop()
except Exception as exc: # pragma: no cover - best effort cleanup
logger.warning(f"Failed to stop HAProxy API cleanly: {exc}")
try:
proc.kill()
await proc.wait()
except Exception as kill_exc:
logger.error(
f"Failed to kill HAProxy process {proc.pid}: {kill_exc}"
)
elif proc and proc.returncode is not None:
continue
def test_generate_config_file_internal(haproxy_api_cleanup):
"""Test that initialize writes the correct config_stub file content using the actual template."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
config_stub = HAProxyConfig(
socket_path=socket_path,
maxconn=1000,
nbthread=2,
timeout_connect_s=5,
timeout_client_s=30,
timeout_server_s=30,
timeout_http_request_s=10,
timeout_queue_s=1,
stats_port=8080,
stats_uri="/mystats",
health_check_fall=3,
health_check_rise=2,
health_check_inter="2s",
http_health_check_path="/health",
http_options=HTTPOptions(
host="0.0.0.0",
port=8000,
keep_alive_timeout_s=55,
),
has_received_routes=True,
has_received_servers=True,
enable_hap_optimization=True,
)
backend_config_stub = {
"api_backend": BackendConfig(
name="api_backend",
path_prefix="/api",
app_name="api_backend",
timeout_http_keep_alive_s=60,
timeout_tunnel_s=60,
http_health_check_path="/api/health",
health_check_fall=2,
health_check_rise=3,
health_check_inter="5s",
servers=[
ServerConfig(name="api_server1", host="127.0.0.1", port=8001),
ServerConfig(name="api_server2", host="127.0.0.1", port=8002),
],
fallback_server=ServerConfig(
name="api_fallback_server", host="127.0.0.1", port=8500
),
),
"web_backend": BackendConfig(
name="web_backend",
path_prefix="/web",
app_name="web_backend",
timeout_connect_s=3,
timeout_server_s=25,
timeout_http_keep_alive_s=45,
timeout_tunnel_s=45,
servers=[
ServerConfig(name="web_server1", host="127.0.0.1", port=8003),
]
# No health check overrides - should use global defaults
),
}
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
config_file_path,
):
api = HAProxyApi(
cfg=config_stub,
backend_configs=backend_config_stub,
config_file_path=config_file_path,
)
api.cfg.balance_algorithm = "random(2)"
try:
api._generate_config_file_internal()
# Read and verify the generated file
with open(config_file_path, "r") as f:
actual_content = f.read()
routes = '{\\"/api\\":\\"api_backend\\",\\"/web\\":\\"web_backend\\"}'
# Expected configuration stub (matching the actual template output)
expected_config = f"""
global
# Access/event log at `info`. The system endpoints (/-/healthz, /-/routes)
# tag their logs `debug` so they are dropped here but still reach the
# rfc5424 metrics socket (level `debug`) when metrics are enabled -- mirroring
# the proxy, which records their metric but not their access log.
log 127.0.0.1:514 local0 info
stats socket {socket_path} mode 666 level admin expose-fd listeners
stats timeout 30s
maxconn 1000
nbthread 2
tune.bufsize 16384
server-state-base /tmp/haproxy-serve
server-state-file /tmp/haproxy-serve/server-state
hard-stop-after 120s
defaults
mode http
option log-health-checks
timeout connect 5s
timeout client 30s
timeout server 30s
timeout http-request 10s
timeout http-keep-alive 55s
timeout queue 1s
log global
option httplog
option abortonclose
option splice-request
option splice-response
# On a retry, use a different slot (`1`). retry-on defaults to connect
# failures only (nothing was sent → safe to replay); override globally via
# RAY_SERVE_HAPROXY_RETRY_ON. Inherited by every backend.
option redispatch 1
retry-on conn-failure
# Set TCP_NODELAY on all connections
option http-no-delay
option idle-close-on-response
# Normalize 502/503/504 to 500 per Serve's default behavior. 503
# covers HAProxy's own "all retries exhausted / no server" response.
errorfile 502 {temp_dir}/500.http
errorfile 503 {temp_dir}/500.http
errorfile 504 {temp_dir}/500.http
load-server-state-from-file global
balance random(2)
frontend prometheus
bind :9101
mode http
http-request use-service prometheus-exporter if {{ path /metrics }}
no log
frontend http_frontend
bind *:8000
log global
# Per-request HTTP ingress metrics. One RFC 5424 line per request matched to
# a Serve app backend, scraped into the serve_num_http_* /
# serve_http_request_latency_ms families (the metrics the Python proxy emits
# in non-HAProxy mode). Goes only to the rfc5424 target below; the inherited
# rfc3164 targets do not include the SD section, so their byte stream is
# unchanged. The general fields come from txn.serve_* vars set per backend
# below; %ST/%Ta/%ts render unquoted (HAProxy does not quote those aliases).
# term_state (%ts) is HAProxy's 2-char session termination state; a leading "C"
# means the client aborted, which the collector maps to status 499 to match the
# Python proxy's client-disconnect convention. When ingress-request-router
# metrics are also enabled, the router-specific fields are appended to the same
# line.
log /tmp/haproxy-serve/metrics.sock len 8192 format rfc5424 local1 debug
log-format-sd "%{{+Q,+E}}o [serve@1 app=%[var(txn.serve_app)] route=%[var(txn.serve_route)] method=%HM status=%ST latency_ms=%Ta deployment=%[var(txn.serve_deployment)] term_state=%ts]"
# Health check endpoint
acl healthcheck path -i /-/healthz
# Keep health checks out of the access log but still record their metric:
# tag them `debug` so the access-log target (level info) drops them while the
# metrics socket (level debug) keeps them. Mirrors the proxy, which records
# the healthz metric with should_record_access_log=False.
http-request set-log-level debug if healthcheck
http-request set-var-fmt(txn.serve_route) "/-/healthz" if healthcheck
# 200 if any backend has at least one server UP
acl backend_api_backend_server_up nbsrv(api_backend) ge 1
acl backend_web_backend_server_up nbsrv(web_backend) ge 1
# Any backend with a server UP passes the health check (OR logic)
http-request return status 200 content-type text/plain string "success" if healthcheck backend_api_backend_server_up
http-request return status 200 content-type text/plain string "success" if healthcheck backend_web_backend_server_up
http-request return status 503 content-type text/plain string "Service Unavailable" if healthcheck
# Routes endpoint
acl routes path -i /-/routes
# Like health checks: kept out of the access log (tagged `debug`); its metric
# is recorded (route=/-/routes, app unset) when metrics are enabled.
http-request set-log-level debug if routes
http-request set-var-fmt(txn.serve_route) "/-/routes" if routes
http-request return status 200 content-type application/json string "{routes}" if routes
# Per-backend path ACLs (used for both ingress-request-router dispatch
# and static use_backend selection below).
acl is_api_backend path_beg /api/
acl is_api_backend path /api
acl is_web_backend path_beg /web/
acl is_web_backend path /web
# Per-request HTTP metric vars (app / route / ingress deployment), set on the
# first matching backend. Backends are sorted longest-prefix-first and the
# !found guard makes the longest match win, mirroring the use_backend rules
# below. Requests that match no app backend (e.g. /-/routes, 404s) leave
# these unset, so the collector can skip them.
http-request set-var-fmt(txn.serve_app) "api_backend" if is_api_backend !{{ var(txn.serve_app) -m found }}
http-request set-var-fmt(txn.serve_route) "/api" if is_api_backend !{{ var(txn.serve_route) -m found }}
http-request set-var-fmt(txn.serve_app) "web_backend" if is_web_backend !{{ var(txn.serve_app) -m found }}
http-request set-var-fmt(txn.serve_route) "/web" if is_web_backend !{{ var(txn.serve_route) -m found }}
# Static routing based on path prefixes in decreasing length then alphabetical order
use_backend api_backend if is_api_backend
use_backend web_backend if is_web_backend
default_backend default_backend
backend default_backend
http-request return status 404 content-type text/plain lf-string "Path \'%[path]\' not found. Ping http://.../-/routes for available routes."
backend api_backend
log global
# Enable HTTP connection reuse for better performance
http-reuse always
# Set backend-specific timeouts, overriding defaults if specified
# Set timeouts to support keep-alive connections
timeout http-keep-alive 60s
timeout tunnel 60s
# Health check configuration - use backend-specific or global defaults
# HTTP health check with custom path
option httpchk GET /api/health
http-check expect status 200
default-server fastinter 250ms downinter 250ms fall 2 rise 3 inter 5s check
# Servers in this backend
server api_server1 127.0.0.1:8001 check
server api_server2 127.0.0.1:8002 check
# Fallback to head node's Serve proxy when no ingress replicas are available
server api_fallback_server 127.0.0.1:8500 check backup
backend web_backend
log global
# Enable HTTP connection reuse for better performance
http-reuse always
# Set backend-specific timeouts, overriding defaults if specified
timeout connect 3s
timeout server 25s
# Set timeouts to support keep-alive connections
timeout http-keep-alive 45s
timeout tunnel 45s
# Health check configuration - use backend-specific or global defaults
# HTTP health check with custom path
option httpchk GET /-/healthz
http-check expect status 200
default-server fastinter 250ms downinter 250ms fall 3 rise 2 inter 2s check
# Servers in this backend
server web_server1 127.0.0.1:8003 check
listen stats
bind *:8080
stats enable
stats uri /mystats
stats refresh 1s
"""
# Compare the entire configuration
assert actual_content.strip() == expected_config.strip()
finally:
# Clean up any temporary files created by initialize()
temp_files = ["haproxy.cfg", "routes.map"]
for temp_file in temp_files:
try:
if os.path.exists(temp_file):
os.remove(temp_file)
except (FileNotFoundError, OSError):
pass # File already removed or doesn't exist
def test_config_escapes_special_characters_in_names(haproxy_api_cleanup):
"""App / deployment names can contain any character (see
test_deploy_with_any_characters). They must be escaped when rendered into
the metric set-var-fmt values so a '#' (HAProxy comment char) or other
special character can't corrupt the config (regression for the
set-var(...) str(...) injection)."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
config = HAProxyConfig(
http_options=HTTPOptions(host="127.0.0.1", port=8000),
stats_port=8404,
socket_path=os.path.join(temp_dir, "admin.sock"),
metrics_enabled=True,
has_received_routes=True,
has_received_servers=True,
)
backend_configs = {
"http-test": BackendConfig(
name="http-test",
path_prefix="/test",
app_name="app#name",
ingress_deployment_name="dep#123",
servers=[ServerConfig(name="s1", host="127.0.0.1", port=8001)],
)
}
api = HAProxyApi(
cfg=config,
backend_configs=backend_configs,
config_file_path=config_file_path,
)
api._generate_config_file_internal()
with open(config_file_path, "r") as f:
cfg = f.read()
# Escaped set-var-fmt form (double-quoted), not the injection-prone
# unquoted str(...) form that treats '#' as a comment.
assert 'set-var-fmt(txn.serve_deployment) "dep#123"' in cfg
assert 'set-var-fmt(txn.serve_app) "app#name"' in cfg
assert "str(dep#123)" not in cfg
assert "str(app#name)" not in cfg
def test_generate_backends_in_order(haproxy_api_cleanup):
"""Test that the backends are generated in the correct order."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
backend_config_stub = {
"foo": BackendConfig(
name="foo",
path_prefix="/foo",
app_name="foo",
),
"foobar": BackendConfig(
name="foobar",
path_prefix="/foo/bar",
app_name="foobar",
),
"bar": BackendConfig(
name="bar",
path_prefix="/bar",
app_name="bar",
),
"default": BackendConfig(
name="default",
path_prefix="/",
app_name="default",
),
}
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
config_file_path,
):
api = HAProxyApi(
cfg=HAProxyConfig(),
config_file_path=config_file_path,
backend_configs=backend_config_stub,
)
api._generate_config_file_internal()
# Read and verify the generated file
lines = []
with open(config_file_path, "r") as f:
lines = f.readlines()
acl_names = []
path_begs = []
paths = []
backend_lines = []
for line in lines:
line = line.strip()
if line.startswith("acl"):
acl_name = line.split(" ")[1]
if acl_name in EXCLUDED_ACL_NAMES:
continue
acl_names.append(acl_name)
# strip prefix/suffix added for acl checks
backend_name = (
acl_name.lstrip("is_")
.replace("backend_", "")
.replace("_server_up", "")
)
assert backend_name in backend_config_stub
condition = line.split(" ")[-2]
if condition == "path_beg":
path_prefix = line.split(" ")[-1].rstrip("/") or "/"
assert backend_config_stub[backend_name].path_prefix == path_prefix
path_begs.append(path_prefix)
elif condition == "path":
path_prefix = line.split(" ")[-1]
assert backend_config_stub[backend_name].path_prefix == path_prefix
paths.append(path_prefix)
else:
# gt condition is used for health check, no need to check.
continue
if line.startswith("use_backend"):
acl_name = line.split(" ")[-1]
assert acl_name in acl_names
backend_lines.append(acl_name)
expected_order = ["is_foobar", "is_bar", "is_foo", "is_default"]
assert backend_lines == expected_order
def _make_api(temp_dir, backend_configs):
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
return HAProxyApi(
cfg=HAProxyConfig(socket_path=os.path.join(temp_dir, "admin.sock")),
config_file_path=config_file_path,
backend_configs=backend_configs,
)
def test_write_ingress_request_router_lua_no_routers(haproxy_api_cleanup):
"""No backend has ingress_request_router_servers -> returns None and writes no Lua file."""
with tempfile.TemporaryDirectory() as temp_dir:
backend = BackendConfig(
name="plain",
path_prefix="/",
app_name="plain",
servers=[
ServerConfig(
name="s1", host="10.0.0.1", port=8001, replica_id="replica_1"
),
],
)
api = _make_api(temp_dir, {"plain": backend})
result = api._write_ingress_request_router_lua([backend])
assert result is None
assert not os.path.exists(os.path.join(temp_dir, "ingress_request_router.lua"))
def test_router_servers_without_replica_ids_emits_no_lua_directives(
haproxy_api_cleanup,
):
"""Backend with router servers but no replica IDs must emit neither the
global `lua-load-per-thread` nor the frontend `lua.route_*` action."""
with tempfile.TemporaryDirectory() as temp_dir:
api = _make_api(
temp_dir,
{
"llm": BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
# Router is configured, but no replica has a replica_id.
servers=[
ServerConfig(
name="r1", host="10.0.0.1", port=30001, replica_id=None
)
],
ingress_request_router_servers=[
ServerConfig(name="router", host="10.0.0.10", port=9000)
],
),
},
)
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
api.config_file_path,
):
api._generate_config_file_internal()
with open(api.config_file_path) as f:
cfg = f.read()
assert "lua.route_via_ingress_request_router" not in cfg
assert not os.path.exists(os.path.join(temp_dir, "ingress_request_router.lua"))
def test_ingress_request_router_does_not_leak_into_other_backends(
haproxy_api_cleanup,
):
"""Pin the Jinja guard: a backend without ingress_request_router_servers gets no
via-ingress-request-router companion and doesn't contribute to
is_via_ingress_request_router. The end-to-end test only exercises a
single router-routed backend, so this regression isn't caught there."""
with tempfile.TemporaryDirectory() as temp_dir:
api = _make_api(
temp_dir,
{
"llm": BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
servers=[
ServerConfig(
name="r1",
host="10.0.0.1",
port=30001,
replica_id="rid_1",
)
],
ingress_request_router_servers=[
ServerConfig(name="router", host="10.0.0.10", port=9000)
],
),
"api": BackendConfig(
name="api",
path_prefix="/api",
app_name="api",
servers=[ServerConfig(name="api1", host="10.0.0.20", port=8001)],
),
},
)
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
api.config_file_path,
):
api._generate_config_file_internal()
with open(api.config_file_path) as f:
cfg = f.read()
assert "backend llm-via-ingress-request-router" in cfg
assert "backend api-via-ingress-request-router" not in cfg
assert "option http-buffer-request" not in cfg
direct_backend = cfg.split("backend llm-via-ingress-request-router", 1)[1]
direct_backend = direct_backend.split("listen stats", 1)[0]
assert "http-reuse always" in direct_backend
assert "option http-server-close" not in direct_backend
# Only router-bearing backends contribute a set-var directive that
# arms the Lua dispatch; the plain `api` backend must not.
assert "set-var(txn.ingress_request_router_app) str(llm)" in cfg
assert "set-var(txn.ingress_request_router_app) str(api)" not in cfg
def test_router_failure_503_rule_appears_before_use_backend(haproxy_api_cleanup):
"""The 503-on-router-failure rule must be rendered before any
``use_backend`` directive. If it isn't, a failed Lua dispatch would
silently fall through to the primary backend and the router policy
the operator opted into would be invisibly bypassed."""
with tempfile.TemporaryDirectory() as temp_dir:
api = _make_api(
temp_dir,
{
"llm": BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
servers=[
ServerConfig(
name="r1",
host="10.0.0.1",
port=30001,
replica_id="rid_1",
)
],
ingress_request_router_servers=[
ServerConfig(name="router", host="10.0.0.10", port=9000)
],
),
},
)
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
api.config_file_path,
):
api._generate_config_file_internal()
with open(api.config_file_path) as f:
cfg = f.read()
# Parse line-by-line so we don't accidentally match the substring
# "use_backend" inside an explanatory comment block rendered above.
lines = cfg.splitlines()
sentinel_line = next(
(
i
for i, ln in enumerate(lines)
if "var(txn.ingress_request_router_failed) -m found" in ln
),
None,
)
first_use_backend_line = next(
(i for i, ln in enumerate(lines) if ln.strip().startswith("use_backend ")),
None,
)
assert sentinel_line is not None, cfg
assert first_use_backend_line is not None, cfg
assert sentinel_line < first_use_backend_line, (
"503-on-router-failure rule must precede every use_backend so a "
"failed dispatch does not silently fall through to the primary "
"backend.\n" + cfg
)
# Spot-check rule shape.
assert "status 503" in cfg, cfg
assert "X-Serve-Reason" in cfg, cfg
def test_ingress_backend_inherits_global_retry_policy(haproxy_api_cleanup):
"""The ``-via-ingress-request-router`` backend defines no retry directives
of its own — it inherits retry-on / retries / redispatch from the defaults
block (one policy everywhere). Only timeout server remains a per-ingress
override."""
backends = {
"llm": BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
servers=[
ServerConfig(name="r1", host="10.0.0.1", port=30001, replica_id="rid_1")
],
ingress_request_router_servers=[
ServerConfig(name="router", host="10.0.0.10", port=9000)
],
),
}
def render(cfg_overrides):
with tempfile.TemporaryDirectory() as temp_dir:
api = HAProxyApi(
cfg=HAProxyConfig(
socket_path=os.path.join(temp_dir, "admin.sock"),
**cfg_overrides,
),
config_file_path=os.path.join(temp_dir, "haproxy.cfg"),
backend_configs=backends,
)
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
api.config_file_path,
):
api._generate_config_file_internal()
with open(api.config_file_path) as f:
return f.read()
# retry-on is defined once (defaults block); the ingress backend inherits it.
unset = render({})
assert "ingress-request-router" in unset # backend still rendered
assert unset.count("\n retry-on ") == 1
assert "\n retries " not in unset
# ingress retry knobs are inherited, not re-emitted in the ingress backend;
# only timeout server renders there as a per-ingress override.
set_cfg = render(
{
"ingress_retry_on": "conn-failure empty-response response-timeout",
"ingress_retries": 4,
"ingress_timeout_server_s": 5,
}
)
assert set_cfg.count("\n retry-on ") == 1 # still only the defaults block
assert "empty-response" not in set_cfg
assert "\n retries 4\n" not in set_cfg
assert "\n timeout server 5s\n" in set_cfg
def test_global_retry_knobs_render(haproxy_api_cleanup):
"""RAY_SERVE_HAPROXY_RETRY_ON / RETRIES drive the defaults block (inherited
by every backend). Defaults to `conn-failure` with no explicit `retries`."""
def render(overrides):
with tempfile.TemporaryDirectory() as temp_dir:
api = HAProxyApi(
cfg=HAProxyConfig(
socket_path=os.path.join(temp_dir, "admin.sock"), **overrides
),
config_file_path=os.path.join(temp_dir, "haproxy.cfg"),
)
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
api.config_file_path,
):
api._generate_config_file_internal()
with open(api.config_file_path) as f:
return f.read()
# Default: conn-failure only, no explicit retries line.
default_cfg = render({})
assert "\n retry-on conn-failure\n" in default_cfg
assert "\n retries " not in default_cfg
# Both knobs override the defaults block.
overridden = render({"retry_on": "conn-failure junk-response", "retries": 2})
assert "\n retry-on conn-failure junk-response\n" in overridden
assert "\n retries 2\n" in overridden
@pytest.mark.parametrize("forward_body", [True, False])
def test_ingress_request_router_forward_body_gate_renders(
haproxy_api_cleanup, monkeypatch, forward_body
):
"""The FORWARD_BODY escape hatch must drive both:
- HAProxy ``wait-for-body`` + ``tune.bufsize`` directives (memory cost
and the per-request body round-trip), and
- the Lua ``FORWARD_BODY`` constant (whether the action reads the body
and forwards it to ``/internal/route``).
Off by default: round-robin ignores the body, so neither cost is paid.
"""
monkeypatch.setattr(
"ray.serve._private.haproxy.RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY",
forward_body,
)
with tempfile.TemporaryDirectory() as temp_dir:
api = _make_api(
temp_dir,
{
"llm": BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
servers=[
ServerConfig(
name="r1",
host="10.0.0.1",
port=30001,
replica_id="rid_1",
)
],
ingress_request_router_servers=[
ServerConfig(name="router", host="10.0.0.10", port=9000)
],
),
},
)
with mock.patch(
"ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC",
api.config_file_path,
):
api._generate_config_file_internal()
with open(api.config_file_path) as f:
cfg = f.read()
with open(os.path.join(temp_dir, "ingress_request_router.lua")) as f:
lua = f.read()
if forward_body:
assert "wait-for-body" in cfg, cfg
assert "local FORWARD_BODY = true" in lua, lua
else:
assert "wait-for-body" not in cfg, cfg
assert "local FORWARD_BODY = false" in lua, lua
def _create_replica_server(port: int, replica_id_header: str):
"""Fake data-plane replica that echoes its identity in a response header."""
app = FastAPI()
@app.get("/-/healthz")
async def health():
return {"status": "OK"}
@app.post("/{path:path}")
async def root(path: str, req: Request, res: Response):
res.headers["x-replica-id"] = replica_id_header
body = await req.body()
return {"replica": replica_id_header, "echo": body.decode("utf-8")}
return _serve_fastapi_app(app, port, _healthz_ready(port))
def _create_router_server(port: int, replica_id_to_return: str):
"""Fake /internal/route. Captures bodies so tests can verify HAProxy
forwards the buffered request body prefix to the router."""
app = FastAPI()
captured = {"bodies": []}
@app.post("/internal/route")
async def route(req: Request):
body = await req.body()
captured["bodies"].append(body.decode("utf-8"))
return {"replica_id": replica_id_to_return}
def ready():
return (
requests.post(
f"http://127.0.0.1:{port}/internal/route", json={}, timeout=2
).status_code
== 200
)
server, thread = _serve_fastapi_app(app, port, ready)
# Discard the readiness-probe body so callers see only client traffic.
captured["bodies"].clear()
return server, thread, captured
async def _start_router_haproxy(
temp_dir, haproxy_port, stats_port, backend_configs, haproxy_api_cleanup
):
"""Build, start, and await readiness of an HAProxyApi for the ingress
request router e2e tests. These tests share this scaffold and differ only
in their backends, router behavior, and assertions."""
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=haproxy_port,
keep_alive_timeout_s=58,
),
stats_port=stats_port,
socket_path=os.path.join(temp_dir, "admin.sock"),
has_received_routes=True,
has_received_servers=True,
http_health_check_path="/-/healthz",
health_check_inter="500ms",
health_check_rise=1,
health_check_fall=2,
)
api = HAProxyApi(
cfg=config,
backend_configs=backend_configs,
config_file_path=os.path.join(temp_dir, "haproxy.cfg"),
)
haproxy_api_cleanup(api)
await api.start()
wait_for_condition(lambda: check_haproxy_ready(stats_port), timeout=10)
# Wait for primary-backend health checks to mark the replicas UP.
await async_wait_for_condition(
lambda: requests.get(
f"http://127.0.0.1:{haproxy_port}/-/healthz", timeout=2
).status_code
== 200,
timeout=10,
)
return api
def _shutdown_fake_servers(servers, threads):
"""Signal uvicorn fake servers to exit and join their threads. Mirrors the
teardown each router test runs in its finally block."""
for srv in servers:
try:
srv.should_exit = True
except Exception:
pass
for thr in threads:
try:
thr.join(timeout=5)
except Exception:
pass
@pytest.mark.asyncio
async def test_ingress_request_router_end_to_end(haproxy_api_cleanup, monkeypatch):
"""Run actual HAProxy against a fake router + two replicas; verify a POST
is pinned to the replica the router selects, while a GET (which doesn't
trigger the router-routed path) is not."""
monkeypatch.setattr(
"ray.serve._private.haproxy.RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY",
True,
)
with tempfile.TemporaryDirectory() as temp_dir:
haproxy_port = find_free_port()
stats_port = find_free_port()
replica_a_port = find_free_port()
replica_b_port = find_free_port()
router_port = find_free_port()
actor_name_a = "SERVE_REPLICA::app#dep#aaa"
actor_name_b = "SERVE_REPLICA::app#dep#bbb"
replica_a, replica_a_thread = _create_replica_server(
replica_a_port, replica_id_header="A"
)
replica_b, replica_b_thread = _create_replica_server(
replica_b_port, replica_id_header="B"
)
router, router_thread, router_captured = _create_router_server(
router_port, replica_id_to_return=actor_name_b # always pick B
)
try:
backend = BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
http_health_check_path="/-/healthz",
servers=[
ServerConfig(
name="A",
host="127.0.0.1",
port=replica_a_port,
replica_id=actor_name_a,
),
ServerConfig(
name="B",
host="127.0.0.1",
port=replica_b_port,
replica_id=actor_name_b,
),
],
ingress_request_router_servers=[
ServerConfig(name="router", host="127.0.0.1", port=router_port),
],
)
await _start_router_haproxy(
temp_dir,
haproxy_port,
stats_port,
{"llm": backend},
haproxy_api_cleanup,
)
# POST goes through the router. Router returns B's actor name,
# so the request must land on replica B regardless of LB ordering.
payload = {"prompt": "hello"}
resp = requests.post(
f"http://127.0.0.1:{haproxy_port}/predict",
json=payload,
timeout=5,
)
assert resp.status_code == 200, resp.text
assert resp.headers.get("x-replica-id") == "B"
# Direct streaming keeps a bounded request-body path for
# prefix-cache-aware routing.
assert router_captured["bodies"] == ['{"prompt": "hello"}']
# Repeat to confirm the pin holds across requests.
for _ in range(3):
resp = requests.post(
f"http://127.0.0.1:{haproxy_port}/predict",
json=payload,
timeout=5,
)
assert resp.headers.get("x-replica-id") == "B"
assert router_captured["bodies"] == ['{"prompt": "hello"}'] * 4
# GET is not POST, so Lua routing never runs; the router should
# have seen exactly the four POSTs above and nothing more.
n_router_calls_before_get = len(router_captured["bodies"])
requests.get(
f"http://127.0.0.1:{haproxy_port}/health-passthrough", timeout=5
)
assert (
len(router_captured["bodies"]) == n_router_calls_before_get
), "GET must not invoke /internal/route"
finally:
_shutdown_fake_servers(
(replica_a, replica_b, router),
(replica_a_thread, replica_b_thread, router_thread),
)
def _create_broken_router_server(port: int, status_code: int = 500):
"""Fake /internal/route that always returns ``status_code`` (default 500).
Used to verify the fail-loud path: a router non-200 must surface to the
client as 5xx with X-Serve-Reason, not silently fall back to a primary
backend."""
app = FastAPI()
@app.get("/-/healthz")
async def health():
return {"status": "OK"}
@app.post("/internal/route")
async def route(res: Response):
res.status_code = status_code
return {"error": "broken"}
return _serve_fastapi_app(app, port, _healthz_ready(port))
def _backend_stot(stats_csv: str, backend_name: str) -> int:
"""Pull ``stot`` (cumulative sessions) for the BACKEND aggregate row of
``backend_name`` from HAProxy's CSV stats. Returns -1 if not found."""
lines = stats_csv.splitlines()
if not lines:
return -1
header = lines[0].lstrip("# ").split(",")
pxname_idx = header.index("pxname")
svname_idx = header.index("svname")
stot_idx = header.index("stot")
for row in lines[1:]:
parts = row.split(",")
if (
len(parts) > stot_idx
and parts[pxname_idx] == backend_name
and parts[svname_idx] == "BACKEND"
):
return int(parts[stot_idx] or 0)
return -1
@pytest.mark.asyncio
async def test_router_failure_fails_loud_with_reason(haproxy_api_cleanup):
"""When ``/internal/route`` returns non-200, HAProxy must return 5xx with
``X-Serve-Reason`` rather than silently falling back to the primary
backend. The primary backend's cumulative session count must stay 0."""
with tempfile.TemporaryDirectory() as temp_dir:
haproxy_port = find_free_port()
stats_port = find_free_port()
replica_port = find_free_port()
router_port = find_free_port()
actor_name = "SERVE_REPLICA::app#dep#aaa"
replica, replica_thread = _create_replica_server(
replica_port, replica_id_header="A"
)
broken_router, broken_router_thread = _create_broken_router_server(router_port)
try:
backend = BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
http_health_check_path="/-/healthz",
servers=[
ServerConfig(
name="A",
host="127.0.0.1",
port=replica_port,
replica_id=actor_name,
),
],
ingress_request_router_servers=[
ServerConfig(name="router", host="127.0.0.1", port=router_port),
],
)
await _start_router_haproxy(
temp_dir,
haproxy_port,
stats_port,
{"llm": backend},
haproxy_api_cleanup,
)
# Every dispatch failure must surface as 5xx with a reason
# label, never as a silent primary-backend fallback. The broken
# router returns 500 for both empty and non-empty bodies, so
# both shapes surface the same ``router_non_200`` reason; the
# body shape is parametrized to pin that empty-body POSTs are
# routed through the router and not silently bypassed.
for body_kwargs in (dict(json={"prompt": "hi"}), dict(data="")):
for _ in range(3):
resp = requests.post(
f"http://127.0.0.1:{haproxy_port}/predict",
timeout=5,
**body_kwargs,
)
assert resp.status_code == 503, resp.text
assert (
resp.headers.get("X-Serve-Reason") == "router_non_200"
), resp.headers
stats_csv = requests.get(
f"http://127.0.0.1:{stats_port}/stats;csv", timeout=5
).text
assert _backend_stot(stats_csv, "llm") == 0, stats_csv
assert (
_backend_stot(stats_csv, "llm-via-ingress-request-router") == 0
), stats_csv
finally:
_shutdown_fake_servers(
(replica, broken_router), (replica_thread, broken_router_thread)
)
@pytest.mark.asyncio
async def test_pin_miss_falls_back_to_fallback_server(haproxy_api_cleanup):
"""When the router pins a replica_id that is not in HAProxy's server map
(the brief membership gap right after an app becomes RUNNING, where the
router's in-process view runs ahead of HAProxy's config reload), HAProxy
must hand the request to the fallback Serve proxy instead of returning 503.
The primary backend must not be load-balanced into, since that would break
session affinity."""
with tempfile.TemporaryDirectory() as temp_dir:
haproxy_port = find_free_port()
stats_port = find_free_port()
replica_port = find_free_port()
fallback_port = find_free_port()
router_port = find_free_port()
actor_name = "SERVE_REPLICA::app#dep#aaa"
# The router names a replica that is NOT among the configured servers,
# simulating HAProxy lagging the router's freshly-updated view.
unknown_actor_name = "SERVE_REPLICA::app#dep#not_loaded_yet"
replica, replica_thread = _create_replica_server(
replica_port, replica_id_header="A"
)
fallback, fallback_thread = _create_replica_server(
fallback_port, replica_id_header="FALLBACK"
)
router, router_thread, _ = _create_router_server(
router_port, replica_id_to_return=unknown_actor_name
)
try:
backend = BackendConfig(
name="llm",
path_prefix="/",
app_name="llm",
http_health_check_path="/-/healthz",
servers=[
ServerConfig(
name="A",
host="127.0.0.1",
port=replica_port,
replica_id=actor_name,
),
],
ingress_request_router_servers=[
ServerConfig(name="router", host="127.0.0.1", port=router_port),
],
fallback_server=ServerConfig(
name="fallback",
host="127.0.0.1",
port=fallback_port,
),
)
await _start_router_haproxy(
temp_dir,
haproxy_port,
stats_port,
{"llm": backend},
haproxy_api_cleanup,
)
# The fallback is a `backup` server, so the /-/healthz wait above
# (satisfied by the primary replica alone) does not gate it. Before
# its health check passes, a pin-miss use-server is skipped and the
# request load-balances onto the primary replica; once it passes,
# every pin-miss POST lands on the fallback proxy. Require several
# consecutive FALLBACK responses so a transient health-check flap
# during warmup retries here instead of failing an assertion.
def _pin_miss_consistently_reaches_fallback():
for _ in range(3):
resp = requests.post(
f"http://127.0.0.1:{haproxy_port}/predict",
json={"prompt": "hi"},
timeout=5,
)
if (
resp.status_code != 200
or resp.headers.get("x-replica-id") != "FALLBACK"
):
return False
return True
await async_wait_for_condition(
_pin_miss_consistently_reaches_fallback, timeout=10
)
# A pin-miss must route via the router backend, never through the
# plain primary backend (a silent router bypass). The router backend
# carries the fallback-served sessions; the plain backend stays 0.
stats_csv = requests.get(
f"http://127.0.0.1:{stats_port}/stats;csv", timeout=5
).text
assert _backend_stot(stats_csv, "llm") == 0, stats_csv
assert (
_backend_stot(stats_csv, "llm-via-ingress-request-router") >= 3
), stats_csv
finally:
_shutdown_fake_servers(
(replica, fallback, router),
(replica_thread, fallback_thread, router_thread),
)
@pytest.mark.asyncio
async def test_graceful_reload(haproxy_api_cleanup):
"""Test that graceful reload preserves long-running connections."""
with tempfile.TemporaryDirectory() as temp_dir:
# Setup ports
haproxy_port = 8000
backend_port = 8404
stats_port = 8405
# Create and start a backend server
backend_server, backend_thread = create_test_backend_server(backend_port)
# Configure HAProxy
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=haproxy_port,
keep_alive_timeout_s=58,
),
stats_port=stats_port,
inject_process_id_header=True, # Enable for testing graceful reload
reload_id=f"initial-{int(time.time() * 1000)}", # Set initial reload ID
socket_path=os.path.join(temp_dir, "admin.sock"),
)
backend_config = BackendConfig(
name="test_backend",
path_prefix="/",
app_name="test_app",
servers=[ServerConfig(name="backend", host="127.0.0.1", port=backend_port)],
timeout_http_keep_alive_s=58,
)
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
api = HAProxyApi(
cfg=config,
backend_configs={"test_backend": backend_config},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
try:
await api.start()
# Wait for HAProxy to be ready (check stat endpoint)
def check_stats_ready():
try:
response = requests.get(
f"http://127.0.0.1:{config.stats_port}/stats", timeout=2
)
return response.status_code == 200
except Exception:
return False
wait_for_condition(check_stats_ready, timeout=10, retry_interval_ms=100)
# Track slow request results
slow_results = []
request_started = threading.Event()
slow_thread = threading.Thread(
target=make_test_request,
args=[f"http://127.0.0.1:{haproxy_port}/slow"],
kwargs={
"track_results": slow_results,
"signal_started": request_started,
},
)
slow_thread.start()
wait_for_condition(
lambda: request_started.is_set(), timeout=5, retry_interval_ms=10
)
assert api._proc is not None
original_pid = api._proc.pid
await api._graceful_reload()
assert api._proc is not None
new_pid = api._proc.pid
def check_for_new_reload_id():
fast_response = requests.get(
f"http://127.0.0.1:{haproxy_port}/fast", timeout=5
)
# Reload ID should always match what exists in the config.
return (
fast_response.headers.get("x-haproxy-reload-id")
== api.cfg.reload_id
and fast_response.status_code == 200
)
wait_for_condition(
check_for_new_reload_id, timeout=5, retry_interval_ms=100
)
slow_thread.join(timeout=10)
assert (
original_pid != new_pid
), "Process should have been reloaded with new PID"
wait_for_condition(
lambda: not process_exists(original_pid),
timeout=15,
retry_interval_ms=100,
)
assert len(slow_results) == 1, "Slow request should have completed"
result = slow_results[0]
assert "error" not in result, f"Slow request failed: {result.get('error')}"
assert result["status"] == 200, "Slow request should have succeeded"
assert result["duration"] >= 3.0, "Slow request should have taken full time"
assert (
b"Slow response completed" in result["content"]
), "Slow request should have completed"
finally:
# Backend server cleanup
try:
backend_server.should_exit = True
backend_thread.join(timeout=5) # Wait for thread to finish
except Exception as e:
print(f"Error occurred while shutting down server stub. Error: {e}")
@pytest.mark.asyncio
async def test_start(haproxy_api_cleanup):
"""Test HAProxy start functionality."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
# Create HAProxy config
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
keep_alive_timeout_s=58,
),
stats_port=8404,
pass_health_checks=True,
socket_path=socket_path,
has_received_routes=True,
has_received_servers=True,
)
# Add a backend so routes are populated
backend = BackendConfig(
name="test_backend",
path_prefix="/",
app_name="test_app",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
api = HAProxyApi(
cfg=config,
backend_configs={"test_backend": backend},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
await api.start()
assert api._proc is not None, "HAProxy process should exist"
assert api._is_running(), "HAProxy should be running"
# Verify config file contains expected content
with open(config_file_path, "r") as f:
config_content = f.read()
assert "frontend http_frontend" in config_content
assert f"bind 127.0.0.1:{config.frontend_port}" in config_content
assert "acl healthcheck path -i /-/healthz" in config_content
health_response = requests.get(
f"http://127.0.0.1:{config.frontend_port}/-/healthz", timeout=5
)
assert (
health_response.status_code == 503
), "Health check with no servers up should return 503"
await api.stop()
assert api._proc is None
assert not api._is_running()
@pytest.mark.asyncio
async def test_get_running_pid_matches_live_proc(haproxy_api_cleanup):
"""`_get_running_pid` parses real `show info` and returns the forked pid.
Guards the reload-takeover gate against a `show info` format change that the
hard-coded fakes in test_haproxy_process_manager.py can't catch.
"""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
config = HAProxyConfig(
http_options=HTTPOptions(host="127.0.0.1", port=8000),
stats_port=8404,
socket_path=socket_path,
has_received_routes=True,
has_received_servers=True,
)
backend = BackendConfig(
name="test_backend",
path_prefix="/",
app_name="test_app",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
api = HAProxyApi(
cfg=config,
backend_configs={"test_backend": backend},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
await api.start()
# The socket's `show info` must report the pid we forked.
assert await api._get_running_pid() == api._proc.pid
await api.stop()
# Socket is gone once stopped, so the pid is unknown.
assert await api._get_running_pid() is None
@pytest.mark.asyncio
async def test_stop(haproxy_api_cleanup):
"""Test HAProxy stop functionality."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=os.path.join(temp_dir, "admin.sock"),
)
api = HAProxyApi(cfg=config, config_file_path=config_file_path)
haproxy_api_cleanup(api)
# Start HAProxy
await api.start()
haproxy_api_cleanup(api)
await api.stop()
# Verify it's stopped
assert not api._is_running(), "HAProxy should be stopped after shutdown"
@pytest.mark.asyncio
async def test_stop_kills_haproxy_process(haproxy_api_cleanup):
"""Test that stop() properly kills the HAProxy subprocess."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=os.path.join(temp_dir, "admin.sock"),
)
api = HAProxyApi(cfg=config, config_file_path=config_file_path)
haproxy_api_cleanup(api)
# Start HAProxy
await api.start()
assert api._proc is not None, "HAProxy process should exist after start"
haproxy_pid = api._proc.pid
assert process_exists(haproxy_pid), "HAProxy process should be running"
# Stop HAProxy
await api.stop()
# Verify the process is killed
assert api._proc is None, "HAProxy proc should be None after stop"
# Wait a bit for process cleanup
def haproxy_process_killed():
return not process_exists(haproxy_pid)
wait_for_condition(
haproxy_process_killed,
timeout=1,
retry_interval_ms=100,
)
@pytest.mark.asyncio
async def test_get_stats_integration(haproxy_api_cleanup):
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
# Create test backend servers
backend_port1 = 9900
backend_port2 = 9901
backend_server1, backend_thread1 = create_test_backend_server(backend_port1)
backend_server2, backend_thread2 = create_test_backend_server(backend_port2)
# Configure HAProxy with multiple backends
config = HAProxyConfig(
http_options=HTTPOptions(
port=8000,
keep_alive_timeout_s=58,
),
socket_path=socket_path,
stats_port=8404,
)
backend_configs = {
"test_backend1": BackendConfig(
name="test_backend1",
path_prefix="/api",
app_name="test_app1",
servers=[
ServerConfig(name="server1", host="127.0.0.1", port=backend_port1)
],
timeout_http_keep_alive_s=58,
),
"test_backend2": BackendConfig(
name="test_backend2",
path_prefix="/web",
app_name="test_app2",
servers=[
ServerConfig(name="server2", host="127.0.0.1", port=backend_port2)
],
timeout_http_keep_alive_s=58,
),
}
api = HAProxyApi(
cfg=config,
backend_configs=backend_configs,
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
try:
# Start HAProxy
await api.start()
# Wait for HAProxy to be ready
wait_for_condition(
lambda: check_haproxy_ready(config.stats_port),
timeout=10,
retry_interval_ms=500,
)
# Make some API calls to generate sessions and traffic
request_threads = []
for i in range(3):
thread = threading.Thread(
target=make_test_request,
args=[f"http://127.0.0.1:{config.frontend_port}/api/slow"],
)
thread.start()
request_threads.append(thread)
for i in range(3):
thread = threading.Thread(
target=make_test_request,
args=[f"http://127.0.0.1:{config.frontend_port}/web/slow"],
)
thread.start()
request_threads.append(thread)
# Get actual stats
async def two_servers_up():
stats = await api.get_haproxy_stats()
return stats.active_servers == 2
await async_wait_for_condition(
two_servers_up, timeout=10, retry_interval_ms=200
)
async def wait_for_running():
return await api.is_running()
await async_wait_for_condition(
wait_for_running, timeout=10, retry_interval_ms=200
)
all_stats = await api.get_all_stats()
haproxy_stats = await api.get_haproxy_stats()
# Assert against the expected stub with exact values
assert (
len(all_stats) == 2
), f"Should have exactly 2 backends, got {len(all_stats)}"
assert (
haproxy_stats.total_backends == 2
), f"Should have exactly 2 backends, got {haproxy_stats.total_backends}"
assert (
haproxy_stats.total_servers == 2
), f"Should have exactly 2 servers, got {haproxy_stats.total_servers}"
assert (
haproxy_stats.active_servers == 2
), f"Should have exactly 2 active servers, got {haproxy_stats.active_servers}"
# Wait for request threads to complete
for thread in request_threads:
thread.join(timeout=1)
finally:
# Stop HAProxy
await api.stop()
# Cleanup backend servers
try:
backend_server1.should_exit = True
backend_server2.should_exit = True
backend_thread1.join(timeout=5) # Wait for the thread to finish
backend_thread2.join(timeout=5) # Wait for the thread to finish
except Exception as e:
print(f"Error cleaning up backend servers: {e}")
@pytest.mark.asyncio
async def test_update_and_reload(haproxy_api_cleanup):
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
backend = BackendConfig(
name="backend",
path_prefix="/",
app_name="backend_app",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=socket_path,
)
api = HAProxyApi(
cfg=config,
backend_configs={backend.name: backend},
config_file_path=config_file_path,
)
await api.start()
haproxy_api_cleanup(api)
with open(config_file_path, "r") as f:
actual_content = f.read()
assert "backend_2" not in actual_content
original_proc = api._proc
original_pid = original_proc.pid
# Add another backend
backend2 = BackendConfig(
name="backend_2",
path_prefix="/",
app_name="backend_app_2",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
api.set_backend_configs({backend.name: backend, backend2.name: backend2})
await api.reload()
assert api._proc is not None
assert api._proc.pid != original_pid
with open(config_file_path, "r") as f:
actual_content = f.read()
assert "backend_2" in actual_content
wait_for_condition(
lambda: not process_exists(original_pid),
timeout=5,
retry_interval_ms=100,
)
@pytest.mark.asyncio
async def test_haproxy_start_should_throw_error_when_already_running(
haproxy_api_cleanup,
):
"""Test that HAProxy throws an error when trying to start on an already-used port (SO_REUSEPORT disabled)."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=socket_path,
enable_so_reuseport=False, # Disable SO_REUSEPORT
)
api = HAProxyApi(cfg=config, config_file_path=config_file_path)
haproxy_api_cleanup(api)
# Start HAProxy with SO_REUSEPORT disabled
await api.start()
assert api._proc is not None, "HAProxy process should be running"
first_pid = api._proc.pid
# Verify we can't start another instance on the same port (SO_REUSEPORT disabled)
config2 = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=config.frontend_port, # Same port
),
stats_port=8404,
socket_path=os.path.join(temp_dir, "admin2.sock"),
enable_so_reuseport=False, # Disable SO_REUSEPORT
)
api2 = HAProxyApi(
cfg=config2, config_file_path=os.path.join(temp_dir, "haproxy2.cfg")
)
# This should fail because SO_REUSEPORT is disabled
with pytest.raises(RuntimeError, match="(Address already in use)"):
await api2.start()
# Cleanup first instance
await api.stop()
assert not process_exists(first_pid), "HAProxy process should be stopped"
@pytest.mark.asyncio
async def test_toggle_health_checks(haproxy_api_cleanup):
"""Test that disable()/enable() toggle HAProxy health checks end-to-end."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
backend = BackendConfig(
name="backend",
path_prefix="/",
app_name="backend_app",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=socket_path,
inject_process_id_header=True,
has_received_routes=True,
has_received_servers=True,
)
# Start a real backend server so HAProxy can mark the server UP
backend_server, backend_thread = create_test_backend_server(9999)
try:
api = HAProxyApi(
cfg=config,
backend_configs={backend.name: backend},
config_file_path=config_file_path,
)
await api.start()
haproxy_api_cleanup(api)
# Verify HAProxy is running
assert api._is_running(), "HAProxy should be running"
# Health requires servers; wait until health passes
def health_ok():
resp = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
return resp.status_code == 200
wait_for_condition(health_ok, timeout=10)
# Verify a config file contains health check enabled
with open(api.config_file_path, "r") as f:
config_content = f.read()
assert (
"http-request return status 200" in config_content
), "Health checks should be enabled in config"
# Disable health checks
await api.disable()
# Verify HAProxy is still running after calling disable()
assert api._is_running(), "HAProxy should still be running after disable"
# Config should now deny the health endpoint
with open(api.config_file_path, "r") as f:
config_content = f.read()
assert (
"http-request return status 503" in config_content
), "Health checks should be disabled in config"
def health_check_condition(status_code: int):
# Test health check endpoint now fails
health_response = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
return health_response.status_code == status_code
wait_for_condition(health_check_condition, timeout=2, status_code=503)
# Re-enable health checks
await api.enable()
# Config should contain the 200 response again
with open(api.config_file_path, "r") as f:
config_content = f.read()
assert (
"http-request return status 200" in config_content
), "Health checks should be re-enabled in config"
wait_for_condition(health_check_condition, timeout=5, status_code=200)
finally:
backend_server.should_exit = True
backend_thread.join(timeout=5)
@pytest.mark.asyncio
async def test_health_endpoint_or_logic_multiple_backends(haproxy_api_cleanup):
"""Test that the health endpoint returns 200 if ANY backend has at least one server UP (OR logic)."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
backend1_port = 9996
backend2_port = 9997
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=socket_path,
has_received_routes=True,
has_received_servers=True,
)
backend1 = BackendConfig(
name="backend1",
path_prefix="/api1",
servers=[
ServerConfig(name="server1", host="127.0.0.1", port=backend1_port)
],
health_check_fall=1,
health_check_rise=1,
health_check_inter="1s",
)
backend2 = BackendConfig(
name="backend2",
path_prefix="/api2",
servers=[
ServerConfig(name="server2", host="127.0.0.1", port=backend2_port)
],
health_check_fall=1,
health_check_rise=1,
health_check_inter="1s",
)
backend1_server, backend1_thread = create_test_backend_server(backend1_port)
backend2_server, backend2_thread = create_test_backend_server(backend2_port)
try:
api = HAProxyApi(
cfg=config,
backend_configs={backend1.name: backend1, backend2.name: backend2},
config_file_path=config_file_path,
)
await api.start()
haproxy_api_cleanup(api)
# Wait for health check to pass (both servers are UP)
def health_ok():
resp = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
return resp.status_code == 200
wait_for_condition(health_ok, timeout=10, retry_interval_ms=200)
# Verify health check returns 200 when both servers are UP
health_response = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
assert (
health_response.status_code == 200
), "Health check should return 200 when both servers are UP"
assert b"success" in health_response.content
# Stop backend1 server
backend1_server.should_exit = True
backend1_thread.join(timeout=5)
# Wait a bit for HAProxy to detect backend1 is down
await asyncio.sleep(2)
# Verify health check STILL returns 200 (backend2 is still UP - OR logic)
health_response = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
assert (
health_response.status_code == 200
), "Health check should return 200 when at least one backend (backend2) is UP (OR logic)"
assert b"success" in health_response.content
# Stop backend2 server as well
backend2_server.should_exit = True
backend2_thread.join(timeout=5)
# Wait for health check to fail (both servers are DOWN)
def health_fails():
resp = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
return resp.status_code == 503
wait_for_condition(health_fails, timeout=10, retry_interval_ms=200)
# Verify health check returns 503 when ALL servers are DOWN
health_response = requests.get(
f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}",
timeout=5,
)
assert (
health_response.status_code == 503
), "Health check should return 503 when all servers are DOWN"
assert b"Service Unavailable" in health_response.content
await api.stop()
finally:
# Cleanup
try:
if not backend1_server.should_exit:
backend1_server.should_exit = True
backend1_thread.join(timeout=5)
except Exception:
pass
try:
if not backend2_server.should_exit:
backend2_server.should_exit = True
backend2_thread.join(timeout=5)
except Exception:
pass
@pytest.mark.asyncio
async def test_errorfile_creation_and_config(haproxy_api_cleanup):
"""Test that the errorfile is created and configured correctly for both 502 and 504."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
# Launch a simple backend server with /fast endpoint
backend_port = 9107
backend_server, backend_thread = create_test_backend_server(backend_port)
# Configure HAProxy with one backend under root ('/') so upstream sees '/fast'
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
keep_alive_timeout_s=58,
),
stats_port=8404,
socket_path=socket_path,
)
api = HAProxyApi(cfg=config, config_file_path=config_file_path)
haproxy_api_cleanup(api)
# Verify the error file was created during initialization
expected_error_file_path = os.path.join(temp_dir, "500.http")
assert os.path.exists(
expected_error_file_path
), "Error file 500.http should be created"
assert (
api.cfg.error_file_path == expected_error_file_path
), "Error file path should be set in config"
# Verify the error file content
with open(expected_error_file_path, "r") as ef:
error_content = ef.read()
assert (
"HTTP/1.1 500 Internal Server Error" in error_content
), "Error file should contain 500 status"
assert (
"Content-Type: text/plain" in error_content
), "Error file should contain content-type header"
assert (
"Internal Server Error" in error_content
), "Error file should contain error message"
# Start HAProxy and verify config contains errorfile directives
await api.start()
# Verify config file contains errorfile directives for 502, 503 and 504 pointing to the same file
with open(config_file_path, "r") as f:
config_content = f.read()
assert (
f"errorfile 502 {expected_error_file_path}" in config_content
), "HAProxy config should contain 502 errorfile directive"
assert (
f"errorfile 503 {expected_error_file_path}" in config_content
), "HAProxy config should contain 503 errorfile directive"
assert (
f"errorfile 504 {expected_error_file_path}" in config_content
), "HAProxy config should contain 504 errorfile directive"
await api.stop()
backend = BackendConfig(
name="app_backend",
path_prefix="/",
app_name="app",
servers=[ServerConfig(name="server1", host="127.0.0.1", port=backend_port)],
timeout_http_keep_alive_s=58,
)
api = HAProxyApi(
cfg=config,
backend_configs={backend.name: backend},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
try:
await api.start()
# Ensure HAProxy is up (stats endpoint reachable)
wait_for_condition(
lambda: check_haproxy_ready(config.stats_port),
timeout=10,
retry_interval_ms=100,
)
# Route exists -> expect 200
r = requests.get("http://127.0.0.1:8000/fast", timeout=5)
assert r.status_code == 200
# Remove backend (no targets for /app) and reload
api.set_backend_configs({})
await api.reload()
# After removal, route should fall back to default backend -> 404
def get_status():
resp = requests.get("http://127.0.0.1:8000/fast", timeout=5)
return resp.status_code
# Allow a brief window for reload to take effect
wait_for_condition(
lambda: get_status() == 404, timeout=5, retry_interval_ms=100
)
finally:
try:
await api.stop()
except Exception:
pass
try:
backend_server.should_exit = True
backend_thread.join(timeout=5)
except Exception:
pass
@pytest.mark.asyncio
async def test_routes_endpoint_returns_backends_and_respects_health(
haproxy_api_cleanup,
):
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
# Start two backend servers; health endpoint exists at '/-/healthz'.
backend_port1 = 9910
backend_port2 = 9911
backend_server1, backend_thread1 = create_test_backend_server(backend_port1)
backend_server2, backend_thread2 = create_test_backend_server(backend_port2)
# Configure HAProxy with two prefixed backends
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8013,
keep_alive_timeout_s=58,
),
stats_port=8413,
socket_path=socket_path,
)
backend_api = BackendConfig(
name="api_backend",
path_prefix="/api",
app_name="api_app",
servers=[
ServerConfig(name="server1", host="127.0.0.1", port=backend_port1)
],
timeout_http_keep_alive_s=58,
)
backend_web = BackendConfig(
name="web_backend",
path_prefix="/web",
app_name="web_app",
servers=[
ServerConfig(name="server2", host="127.0.0.1", port=backend_port2)
],
timeout_http_keep_alive_s=58,
)
api = HAProxyApi(
cfg=config,
backend_configs={
backend_api.name: backend_api,
backend_web.name: backend_web,
},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
try:
await api.start()
# Wait for HAProxy to be ready
wait_for_condition(
lambda: check_haproxy_ready(config.stats_port),
timeout=10,
retry_interval_ms=100,
)
# Helper to get fresh routes response (avoids connection reuse)
def get_routes():
with requests.Session() as session:
return session.get("http://127.0.0.1:8013/-/routes", timeout=1)
# Initial state: no routes
r = requests.get("http://127.0.0.1:8013/-/routes", timeout=5)
assert r.status_code == 503
assert r.headers.get("content-type", "").startswith("text/plain")
assert r.text == "Route table is not populated yet."
# Set has_received_routes but not has_received_servers -> should show "No replicas available"
api.cfg.has_received_routes = True
api.cfg.has_received_servers = False
await api.reload()
get_routes().text == "No replicas are available yet.",
r = get_routes()
assert r.status_code == 503
assert r.headers.get("content-type", "").startswith("text/plain")
# Set both flags -> should show routes JSON
api.cfg.has_received_routes = True
api.cfg.has_received_servers = True
await api.reload()
# Reload is not synchronous, so we need to wait for the config to be applied
def check_json_routes():
r = get_routes()
return r.status_code == 200 and r.headers.get(
"content-type", ""
).startswith("application/json")
wait_for_condition(check_json_routes, timeout=5, retry_interval_ms=50)
r = get_routes()
data = r.json()
assert data == {"/api": "api_app", "/web": "web_app"}
# Disable (simulate draining/unhealthy) -> wait for healthz to flip, then routes 503
await api.disable()
def health_is(code: int):
resp = requests.get("http://127.0.0.1:8013/-/healthz", timeout=5)
return resp.status_code == code
wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=503)
r = requests.get("http://127.0.0.1:8013/-/routes", timeout=5)
assert r.status_code == 503
assert r.headers.get("content-type", "").startswith("text/plain")
assert r.text == "This node is being drained."
# Re-enable -> wait for healthz to flip back, then routes 200
await api.enable()
wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=200)
r = requests.get("http://127.0.0.1:8013/-/routes", timeout=5)
assert r.status_code == 200
finally:
try:
await api.stop()
except Exception:
pass
@pytest.mark.asyncio
async def test_routes_endpoint_no_routes(haproxy_api_cleanup):
"""When no backends are configured, /-/routes should return {} and respect health gating."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8014,
keep_alive_timeout_s=58,
),
stats_port=8414,
socket_path=socket_path,
)
api = HAProxyApi(
cfg=config,
backend_configs={},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
try:
await api.start()
# Wait for HAProxy to be ready
wait_for_condition(
lambda: check_haproxy_ready(config.stats_port),
timeout=10,
retry_interval_ms=100,
)
# Healthy -> expect 200 and empty JSON
r = requests.get(
f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5
)
assert r.status_code == 503
assert r.headers.get("content-type", "").startswith("text/plain")
assert r.text == "Route table is not populated yet."
# Disable -> wait for healthz to flip, then expect 503 with draining message
await api.disable()
def health_is(code: int):
resp = requests.get(
f"http://127.0.0.1:{config.frontend_port}/-/healthz", timeout=5
)
return resp.status_code == code
wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=503)
# Wait for routes endpoint to also return draining message (graceful reload might take a moment)
def routes_is_draining():
try:
resp = requests.get(
f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5
)
return (
resp.status_code == 503
and resp.text == "This node is being drained."
)
except Exception:
return False
wait_for_condition(routes_is_draining, timeout=5, retry_interval_ms=100)
r = requests.get(
f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5
)
assert r.status_code == 503
assert r.headers.get("content-type", "").startswith("text/plain")
assert r.text == "This node is being drained."
# Re-enable -> wait for healthz back to 200, then routes 200
await api.enable()
wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=503)
def routes_is_healthy():
try:
r = requests.get(
f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5
)
return (
r.status_code == 503
and r.text == "Route table is not populated yet."
)
except Exception:
return False
wait_for_condition(routes_is_healthy, timeout=5, retry_interval_ms=100)
finally:
try:
await api.stop()
except Exception:
pass
@pytest.mark.asyncio
async def test_404_error_message(haproxy_api_cleanup):
"""Test that HAProxy returns the correct 404 error message for non-existent paths."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
# Create a backend that serves /api
backend = BackendConfig(
name="api_backend",
path_prefix="/api",
servers=[], # No servers, but we're testing the 404 path anyway
)
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
),
stats_port=8404,
socket_path=socket_path,
)
api = HAProxyApi(
cfg=config,
backend_configs={"api_backend": backend},
config_file_path=config_file_path,
)
await api.start()
haproxy_api_cleanup(api)
# Verify HAProxy is running
assert api._is_running(), "HAProxy should be running"
# Wait for HAProxy to be ready
wait_for_condition(
lambda: check_haproxy_ready(config.stats_port),
timeout=10,
retry_interval_ms=500,
)
# Request a non-existent path and verify the error message
response = requests.get(
f"http://127.0.0.1:{config.frontend_port}/nonexistent",
timeout=5,
)
assert response.status_code == 404, "Should return 404 for non-existent path"
assert (
"Path '/nonexistent' not found" in response.text
), f"Error message should contain path. Got: {response.text}"
assert (
"Ping http://.../-/routes for available routes" in response.text
), f"Error message should contain routes hint. Got: {response.text}"
@pytest.mark.asyncio
async def test_start_with_tcp_nodelay(haproxy_api_cleanup):
"""Test that HAProxy starts successfully with tcp_nodelay enabled."""
with tempfile.TemporaryDirectory() as temp_dir:
config_file_path = os.path.join(temp_dir, "haproxy.cfg")
socket_path = os.path.join(temp_dir, "admin.sock")
config = HAProxyConfig(
http_options=HTTPOptions(
host="127.0.0.1",
port=8000,
keep_alive_timeout_s=58,
),
stats_port=8404,
pass_health_checks=True,
socket_path=socket_path,
has_received_routes=True,
has_received_servers=True,
tcp_nodelay=True,
)
backend = BackendConfig(
name="test_backend",
path_prefix="/",
app_name="test_app",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
api = HAProxyApi(
cfg=config,
backend_configs={"test_backend": backend},
config_file_path=config_file_path,
)
haproxy_api_cleanup(api)
await api.start()
assert api._proc is not None, "HAProxy process should exist"
assert api._is_running(), "HAProxy should be running"
# Verify config file contains the tcp_nodelay directive
with open(config_file_path, "r") as f:
config_content = f.read()
assert (
"option http-no-delay" in config_content
), "Config should contain 'option http-no-delay' when tcp_nodelay=True"
await api.stop()
@pytest.mark.asyncio
async def test_std_streams_redirected_to_files(haproxy_api_cleanup):
"""Both HAProxy stdout and stderr must be files (not PIPEs) so a
full 64KB kernel pipe buffer can never block admin-socket threads
under load. Each spawn gets its own files so a reload doesn't lose
the prior worker's diagnostics.
"""
with tempfile.TemporaryDirectory() as temp_dir:
config = HAProxyConfig(
http_options=HTTPOptions(host="127.0.0.1", port=8000),
stats_port=8404,
pass_health_checks=True,
socket_path=os.path.join(temp_dir, "admin.sock"),
has_received_routes=True,
has_received_servers=True,
reload_id=f"initial-{int(time.time() * 1000)}",
)
backend = BackendConfig(
name="test_backend",
path_prefix="/",
app_name="test_app",
servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)],
)
api = HAProxyApi(
cfg=config,
backend_configs={"test_backend": backend},
config_file_path=os.path.join(temp_dir, "haproxy.cfg"),
)
haproxy_api_cleanup(api)
await api.start()
first_stderr = api._proc._stderr_path
first_stdout = api._proc._stdout_path
# No pipes — both streams went to files.
assert api._proc.stderr is None
assert api._proc.stdout is None
# HAProxy's -db startup banner landed on stderr.
assert os.path.getsize(first_stderr) > 0
# stdout file exists even if it stays empty by default.
assert os.path.exists(first_stdout)
# Reload must open new files so the prior worker's logs survive.
config.reload_id = f"reload-{int(time.time() * 1000)}"
await api._graceful_reload()
assert api._proc.stderr is None
assert api._proc.stdout is None
assert api._proc._stderr_path != first_stderr
assert api._proc._stdout_path != first_stdout
assert os.path.exists(first_stderr)
assert os.path.exists(first_stdout)
await api.stop()
def _bare_haproxy_manager():
"""An uninitialized HAProxyManager for unit-testing its methods.
HAProxyManager is an ``@ray.remote`` actor, so the imported name is an
ActorClass, not a plain type. Reach the underlying Python class via
``__ray_metadata__.modified_class`` and instantiate it with ``__new__``
so ``__init__`` (actor base class + event loop) is skipped.
"""
cls = HAProxyManager.__ray_metadata__.modified_class
return cls.__new__(cls)
@pytest.mark.asyncio
async def test_is_drained_waits_for_old_procs():
"""is_drained() stays False while a soft-stopping old worker from a
prior reload is still alive, even past the drain period with an idle
current worker; it flips True once the old worker exits."""
# Set only the two attributes is_drained() reads.
manager = _bare_haproxy_manager()
manager._draining_start_time = time.time() - PROXY_MIN_DRAINING_PERIOD_S - 1
manager._haproxy = mock.Mock()
manager._haproxy.get_haproxy_stats = mock.AsyncMock(
return_value=mock.Mock(is_system_idle=True)
)
# Old worker still serving -> not drained despite idle current worker.
manager._haproxy.has_alive_old_procs = mock.Mock(return_value=True)
assert await manager.is_drained() is False
# Old worker has exited -> drained.
manager._haproxy.has_alive_old_procs = mock.Mock(return_value=False)
assert await manager.is_drained() is True
def test_soft_stop_old_procs_signals_live_workers():
"""_soft_stop_old_procs re-delivers SIGUSR1 to our live displaced workers,
healing a worker whose `-sf` signal was lost, and skips exited procs and a
pid that is no longer one of ours (recycled)."""
api = HAProxyApi.__new__(HAProxyApi)
alive_ours = mock.Mock(pid=111, returncode=None)
exited = mock.Mock(pid=222, returncode=0)
alive_recycled = mock.Mock(pid=333, returncode=None)
api._old_procs = [alive_ours, exited, alive_recycled]
api._retired_logs = collections.deque()
api._max_retained_logs = 10
# 333's pid was recycled onto an unrelated process.
api._is_our_haproxy = lambda pid: pid != 333
with mock.patch("ray.serve._private.haproxy.os.kill") as mock_kill:
api._soft_stop_old_procs()
# Only the live worker that is still ours is signaled; the exited one is
# pruned and the recycled pid is left alone.
mock_kill.assert_called_once_with(111, signal.SIGUSR1)
@pytest.mark.asyncio
async def test_is_drained_false_before_min_period():
"""is_drained() is False until PROXY_MIN_DRAINING_PERIOD_S has elapsed,
regardless of old-proc / idle state."""
manager = _bare_haproxy_manager()
manager._draining_start_time = time.time() # just started draining
manager._haproxy = mock.Mock()
manager._haproxy.has_alive_old_procs = mock.Mock(return_value=False)
manager._haproxy.get_haproxy_stats = mock.AsyncMock(
return_value=mock.Mock(is_system_idle=True)
)
assert await manager.is_drained() is False
@pytest.mark.asyncio
async def test_failed_spawn_retires_log_files(monkeypatch):
"""A spawn that fails startup must not orphan its std-stream log files —
they should be retired into the bounded ring like an exited worker's."""
class _FakeProc:
def __init__(self):
self.returncode = None
self.pid = 4321
def kill(self):
self.returncode = -9
async def wait(self):
return self.returncode
async def _fake_exec(*args, **kwargs):
return _FakeProc()
with tempfile.TemporaryDirectory() as temp_dir:
api = HAProxyApi(
cfg=HAProxyConfig(socket_path=os.path.join(temp_dir, "admin.sock")),
config_file_path=os.path.join(temp_dir, "haproxy.cfg"),
)
monkeypatch.setattr(
"ray.serve._private.haproxy.get_haproxy_binary", lambda: "haproxy"
)
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec)
async def _boom(proc, timeout_s=5):
raise RuntimeError("startup failed")
monkeypatch.setattr(api, "_wait_for_hap_availability", _boom)
with pytest.raises(RuntimeError, match="startup failed"):
await api._start_and_wait_for_haproxy()
# The failed spawn's files were created then retired into the ring,
# not left orphaned on disk.
assert len(api._retired_logs) == 1
stdout_path, stderr_path = api._retired_logs[0]
assert stdout_path.endswith(".stdout.log")
assert stderr_path.endswith(".stderr.log")
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))