-- HAProxy Lua action that picks a backend replica via the ingress request -- router. Templated at config-reload time; placeholders are filled in by -- _write_ingress_request_router_lua in haproxy.py. -- -- Bodies exceeding tune.bufsize are truncated; we forward what we have with -- X-Body-Truncated since prefix-routing only needs the head of the body. -- See RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY in constants.py. local ROUTER_REQUEST_TIMEOUT_S = ${TIMEOUT_S} local FORWARD_BODY = ${FORWARD_BODY} -- Name of the client header that carries the session id Serve should -- pin on. Filled from SERVE_SESSION_ID (set via the env var -- RAY_SERVE_SESSION_ID_HEADER_KEY) in lowercase form. Forwarded on the -- same name to /internal/route so session-aware routers (e.g. -- ConsistentHashRouter) can use it. local SESSION_HEADER = "${SESSION_HEADER}" -- Per-app state. The frontend sets txn.ingress_request_router_app to the -- backend name whose path prefix matched, and we look up that app's -- router pool and replica map here. Each app routes through its own -- router; replica IDs are scoped to the app to avoid cross-app pinning. local ROUTERS = ${ROUTERS} local REPLICA_TARGETS = ${REPLICA_TARGETS} local function call_router(router, body, truncated_length, session_id) local sock = core.tcp() sock:settimeout(ROUTER_REQUEST_TIMEOUT_S) if not sock:connect(router.host, router.port) then return nil end local truncation_header = "" if truncated_length then truncation_header = "X-Body-Truncated: " .. #body .. "/" .. truncated_length .. "\r\n" end local session_header = "" if session_id and session_id ~= "" then session_header = SESSION_HEADER .. ": " .. session_id .. "\r\n" end -- Connection: close so sock:receive("*a") terminates on EOF. local req = "POST /internal/route HTTP/1.0\r\n" .. "Host: " .. router.host_header .. "\r\n" .. "Connection: close\r\n" .. "Content-Type: application/json\r\n" .. "Content-Length: " .. #body .. "\r\n" .. truncation_header .. session_header .. "\r\n" .. body if not sock:send(req) then sock:close() return nil end local response = sock:receive("*a") sock:close() return response end local function is_http_200(response) return response:match("^HTTP/[%d%.]+ 200") ~= nil end local function http_response_body(response) local _, separator_end = response:find("\r\n\r\n", 1, true) if not separator_end then return "" end return response:sub(separator_end + 1) end -- Router contract: response MUST be a flat JSON object with `replica_id` as a -- plain string actor name (no escaped quotes, no nested objects). The regex -- relies on this; broaden the parser if the contract grows. local function extract_replica_id(json_body) return json_body:match('"replica_id"%s*:%s*"([^"]+)"') end -- Returns the original Content-Length when `body` is shorter than the -- advertised length (truncated by tune.bufsize), or nil otherwise. local function truncated_full_length(txn, body) local cl = tonumber(txn.sf:hdr("content-length")) if cl and #body < cl then return cl end end -- Core routing decision. Sets either txn.ingress_request_router_target + -- txn.via_ingress_request_router (success) or txn.ingress_request_router_failed -- (failure). Both outcomes are timed by the caller; only the silent early -- returns in the action handler skip timing because no routing was attempted. local function _route_via_ingress_request_router(txn, router, replica_map) -- FORWARD_BODY=false: don't read or forward the body; call the router -- with body="" so a Content-Length: 0 POST still goes through routing -- (any policy that needs the body must opt in via FORWARD_BODY=true). -- Empty body in FORWARD_BODY=true mode is treated the same -- a -- legitimate input the router can accept or reject on its own terms; -- we don't synthesize a sentinel for it here. local body = "" local truncated = nil if FORWARD_BODY then body = txn.sf:req_body() or "" if body ~= "" then truncated = truncated_full_length(txn, body) if truncated then core.log(core.warning, "ingress_request_router: forwarding truncated body to router (" .. #body .. "/" .. truncated .. " bytes)") ${METRICS_SET_TRUNCATED} end end end -- Look up the configured session-id header from the client request. -- req_get_headers() returns a table keyed by lowercase header name -- with array values (header can appear multiple times); take the -- first occurrence. Missing header -> nil -> call_router skips the -- forwarded header line entirely. -- -- Also probe the `-`/`_` variant so an intermediate proxy that rewrites -- the separator doesn't silently drop session affinity, matching the -- Python-side `_matches_session_id_header` behavior in http_util.py. local session_id = nil if SESSION_HEADER ~= "" then local hdrs = txn.http:req_get_headers() local entry = hdrs and hdrs[SESSION_HEADER] if hdrs and not entry then local alt = SESSION_HEADER:gsub("-", "_") if alt == SESSION_HEADER then alt = SESSION_HEADER:gsub("_", "-") end entry = hdrs[alt] end if entry then session_id = entry[0] end end local response = call_router(router, body, truncated, session_id) if not response then txn:set_var("txn.ingress_request_router_failed", "router_unreachable") return end if not is_http_200(response) then txn:set_var("txn.ingress_request_router_failed", "router_non_200") return end local replica_id = extract_replica_id(http_response_body(response)) if not replica_id then txn:set_var("txn.ingress_request_router_failed", "unparseable_replica_id") return end local server_name = replica_map[replica_id] if not server_name then -- Pin-miss: router named a replica not in HAProxy's server map, a -- transient gap between the router view and HAProxy's config snapshot. -- Arm `failed`. The frontend recovers it via the fallback proxy. txn:set_var("txn.ingress_request_router_failed", "unknown_replica_id") return end txn:set_var("txn.ingress_request_router_target", server_name) txn:set_var("txn.via_ingress_request_router", true) end -- Failure semantics: every path that reaches a router decision but cannot pin -- a replica must arm txn.ingress_request_router_failed so the frontend's 503 -- rule fires instead of letting the request silently fall through to the -- primary backend. The product invariant is: requests to a router-bearing app -- are served via the router or fail; there is no quiet alternative path. -- Exception: `unknown_replica_id` is recovered via the fallback proxy when the -- app has one, otherwise 503ed. The fallback re-pins via its own router so the -- policy is not bypassed. While the fallback is DOWN the request load-balances -- onto a primary replica in the router backend instead of 503ing, so affinity -- lapses for that window. Still counted as a failure. -- Two silent returns are correct: (1) the request didn't target a -- router-bearing app (no txn var set, no app entry in our maps), and -- (2) the controller hasn't pushed router/replica state for this app yet. -- Failure-mode bucketing belongs in observability (response header label, -- metric label), not in the data plane. core.register_action("route_via_ingress_request_router", {"http-req"}, function(txn) -- Time the routing attempt regardless of outcome: both successful pins -- and explicit failures (router_unreachable, router_non_200, -- unparseable_replica_id, unknown_replica_id) record latency_us so -- failure paths are visible in the metrics stream. ${METRICS_PRE_CALL_ROUTER} local app = txn:get_var("txn.ingress_request_router_app") if not app then return end local router = ROUTERS[app] local replica_map = REPLICA_TARGETS[app] if not router or not replica_map then return end _route_via_ingress_request_router(txn, router, replica_map) ${METRICS_POST_CALL_ROUTER} end, 0)